From 8a23b7407a8c6785a239d9d176c35a4dc9205a61 Mon Sep 17 00:00:00 2001 From: Joshua Simpson Date: Sat, 17 Oct 2026 14:38:45 +0000 Subject: [PATCH 1/7] Add scoped Cloud operational acceptance checks --- .github/workflows/cloud-contracts.yml | 70 +++ .github/workflows/cloud-platform-smoke.yml | 58 +++ docs/agents/cloud-build-resources.md | 77 +++ docs/agents/cloud-instruction-activation.md | 47 ++ docs/agents/cloud-platform-acceptance.md | 97 ++++ docs/agents/codex-cloud-legacy-bootstrap.md | 39 ++ docs/agents/cross-repo-cloud-contracts.md | 78 +++ docs/organisation/systems/delivery.json | 6 + docs/organisation/systems/knowledge.json | 6 + scripts/check-cloud-build-resources.mjs | 195 +++++++ scripts/check-cloud-platform-smoke.mjs | 148 ++++++ scripts/check-cross-repo-cloud-contracts.mjs | 502 +++++++++++++++++++ scripts/cloud-activation-evidence.mjs | 229 +++++++++ scripts/with-isolated-cloud-home.sh | 81 +++ tests/cloud-activation-evidence.test.ts | 148 ++++++ tests/cloud-build-resources.test.ts | 186 +++++++ tests/cloud-isolated-home.test.ts | 109 ++++ tests/cloud-platform-smoke.test.ts | 150 ++++++ tests/cross-repo-cloud-contracts.test.ts | 214 ++++++++ 19 files changed, 2440 insertions(+) create mode 100644 .github/workflows/cloud-contracts.yml create mode 100644 .github/workflows/cloud-platform-smoke.yml create mode 100644 docs/agents/cloud-build-resources.md create mode 100644 docs/agents/cloud-platform-acceptance.md create mode 100644 docs/agents/cross-repo-cloud-contracts.md create mode 100644 scripts/check-cloud-build-resources.mjs create mode 100644 scripts/check-cloud-platform-smoke.mjs create mode 100644 scripts/check-cross-repo-cloud-contracts.mjs create mode 100755 scripts/cloud-activation-evidence.mjs create mode 100755 scripts/with-isolated-cloud-home.sh create mode 100644 tests/cloud-activation-evidence.test.ts create mode 100644 tests/cloud-build-resources.test.ts create mode 100644 tests/cloud-isolated-home.test.ts create mode 100644 tests/cloud-platform-smoke.test.ts create mode 100644 tests/cross-repo-cloud-contracts.test.ts diff --git a/.github/workflows/cloud-contracts.yml b/.github/workflows/cloud-contracts.yml new file mode 100644 index 0000000000..b537fde603 --- /dev/null +++ b/.github/workflows/cloud-contracts.yml @@ -0,0 +1,70 @@ +name: Cloud source contracts + +on: + pull_request: + branches: [main] + paths: + - "README.md" + - ".nvmrc" + - "AGENTS.md" + - "CLAUDE.md" + - "GEMINI.md" + - "PROJECT-INSTRUCTIONS-CONTINUATION.md" + - "package.json" + - "package-lock.json" + - "scripts/*codex-cloud*" + - "scripts/check-cross-repo-cloud-contracts.mjs" + - "scripts/lib/codex-cloud*" + - "tests/cross-repo-cloud-contracts.test.ts" + - "docs/agents/*cloud*" + - "docs/task-receipts.md" + - ".github/workflows/cloud-contracts.yml" + push: + branches: [main] + paths: + - "README.md" + - ".nvmrc" + - "AGENTS.md" + - "CLAUDE.md" + - "GEMINI.md" + - "PROJECT-INSTRUCTIONS-CONTINUATION.md" + - "package.json" + - "package-lock.json" + - "scripts/*codex-cloud*" + - "scripts/check-cross-repo-cloud-contracts.mjs" + - "scripts/lib/codex-cloud*" + - "tests/cross-repo-cloud-contracts.test.ts" + - "docs/agents/*cloud*" + - "docs/task-receipts.md" + - ".github/workflows/cloud-contracts.yml" + workflow_dispatch: + inputs: + audit_public_main: + description: "Read maintained repositories' main anonymously; inaccessible sources fail as unverified." + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: cloud-contracts-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + source-contracts: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: ".nvmrc" + - name: Check this checkout's static Cloud contracts + run: node scripts/check-cross-repo-cloud-contracts.mjs --root . + # No dependencies, foreign scripts, tokens, provider clients or remote workflow execution. + - name: Audit maintained public main revisions (explicit request only) + if: github.event_name == 'workflow_dispatch' && inputs.audit_public_main + run: node scripts/check-cross-repo-cloud-contracts.mjs --root . --fetch-maintained diff --git a/.github/workflows/cloud-platform-smoke.yml b/.github/workflows/cloud-platform-smoke.yml new file mode 100644 index 0000000000..404fde5400 --- /dev/null +++ b/.github/workflows/cloud-platform-smoke.yml @@ -0,0 +1,58 @@ +name: Cloud platform smoke + +on: + pull_request: + paths: + - ".github/workflows/cloud-platform-smoke.yml" + - "scripts/check-cloud-platform-smoke.mjs" + - "scripts/setup-codex-worktree.mjs" + - "scripts/check-installed-lock-parity.mjs" + - "scripts/install-git-hooks.mjs" + - "scripts/lib/is-entrypoint.mjs" + - "tests/cloud-platform-smoke.test.ts" + - "docs/agents/cloud-platform-acceptance.md" + - "package.json" + - ".node-version" + - ".nvmrc" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: cloud-platform-smoke-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + windows-bootstrap: + name: Native Windows bootstrap fixture + runs-on: windows-2025 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: ".node-version" + + # Match the real bootstrap's npm lookup with an isolated job-local APPDATA. + # No global install and no application dependency installation are needed. + - name: Stage the declared npm in the runner temporary directory + shell: pwsh + run: | + $manifest = Get-Content package.json -Raw | ConvertFrom-Json + if ($manifest.packageManager -notmatch '^npm@(\d+\.\d+\.\d+)$') { throw 'Expected an exact npm packageManager version' } + $version = $Matches[1] + $toolAppData = Join-Path $env:RUNNER_TEMP 'cloud-platform-appdata' + $toolPrefix = Join-Path $toolAppData 'npm' + npm install --prefix $toolPrefix --ignore-scripts --no-save --package-lock=false --no-audit --no-fund "npm@$version" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + "APPDATA=$toolAppData" >> $env:GITHUB_ENV + + - name: Exercise native Windows Git Bash paths, donor copy, and hook installation + shell: pwsh + run: node scripts/check-cloud-platform-smoke.mjs --windows diff --git a/docs/agents/cloud-build-resources.md b/docs/agents/cloud-build-resources.md new file mode 100644 index 0000000000..10e6974f0e --- /dev/null +++ b/docs/agents/cloud-build-resources.md @@ -0,0 +1,77 @@ +# Cloud production-build resources + +Use the read-only preflight before a production build or the canonical production +Playwright command on a cloud runner: + +```bash +node scripts/check-cloud-build-resources.mjs +node scripts/check-cloud-build-resources.mjs --json +``` + +Both forms exit `0` when the recommended planning envelope is available, `1` when +heap/resource diagnostics need attention, and `2` for invalid arguments. The script +does not build, start a server, acquire the heavy-run lease, change the environment, +resize the host, or suppress any check. A pass is resource-planning evidence; the +existing build, typecheck and browser commands still supply their own acceptance +evidence. Follow [verification gates](verification-gates.md) for scope and reporting. + +## Heap inherited by Next.js workers + +In the prior cloud acceptance run, compilation completed with the default heap, +then Next.js typechecking exhausted roughly 4 GiB. Repeating the canonical +production Playwright run with command-scoped +`NODE_OPTIONS=--max-old-space-size=8192` completed 107 browser tests. That observed +result motivates the recommendation; it does not establish a universal minimum +or guarantee every future build fits. + +The preflight measures V8's heap limit in a short Node subprocess inheriting the +current environment. A heap flag on the preflight process alone cannot make it +pass. This matters because a parent `node --max-old-space-size=8192` flag does not +establish what a separately launched Next.js worker receives. The measured V8 +limit includes a little overhead beyond the old-space setting. + +When `NODE_OPTIONS` is unset or empty, a Bash invocation can keep the setting scoped +to the preflight and the existing command: + +```bash +NODE_OPTIONS=--max-old-space-size=8192 node scripts/check-cloud-build-resources.mjs && + NODE_OPTIONS=--max-old-space-size=8192 npm run build +``` + +Use the same command-scoped setting on the browser command selected for the task +when its runner builds the production app. Do not permanently export this setting +or replace existing `NODE_OPTIONS` wholesale. If other options are already set, +retain them; review any explicit heap option before editing it. Node accepts both +`--max-old-space-size` and `--max_old_space_size`, with values supplied using `=` or +as a separate argument. The preflight lets Node interpret these options rather +than rewriting or guessing their meaning. A small explicit heap produces a clear +diagnostic and remains unchanged. A larger heap increases the memory envelope. + +## Runner capacity and headroom + +Plan for an 8 GiB old-space heap **plus 4 GiB of headroom** for native allocations, +other Node workers, the browser and the OS: approximately 12 GiB total. The actual +check uses the measured V8 heap plus 4 GiB, including V8's small overhead. A 16 GiB +runner with adequate current availability is a practical starting point. An 8 GiB +runner cannot safely accommodate an 8 GiB heap. The headroom is a conservative +planning recommendation, not a measured worst-case bound for every worker mix. + +The report limits capacity to the smallest visible host/cgroup limit. Linux +discovery resolves cgroup v2 `memory.max`/`memory.current` or v1 +`memory.limit_in_bytes`/`memory.usage_in_bytes` using this process's membership and +mount information, then checks visible ancestors. Unlimited cgroups fall back to +finite host capacity. Host availability uses `MemAvailable` where provided, +otherwise free memory. Cgroup availability subtracts current usage and credits +bounded inactive file cache as reclaimable. A visible memory mount with unresolved +process membership, any unreadable visible ancestor limit, invalid limits, or +missing usage for a finite limit fails the preflight instead of trusting the +larger host. Unlimited fallback requires explicit readable `max` or sentinel +bounds through the visible hierarchy. Hierarchy roots without a readable memory +limit are reported as not ready; the preflight cannot prove their capacity from +the missing file alone. + +This is a snapshot: another process can consume memory after it passes, and hidden +ancestors cannot be inspected. Keep the existing heavy-run admission mechanism, +rerun immediately before the build, and wait for other work or choose a larger +runner when availability is insufficient. Do not reduce verification or change +host memory limits to obtain a passing report. diff --git a/docs/agents/cloud-instruction-activation.md b/docs/agents/cloud-instruction-activation.md index 32f06216e4..f86a7fe412 100644 --- a/docs/agents/cloud-instruction-activation.md +++ b/docs/agents/cloud-instruction-activation.md @@ -31,6 +31,46 @@ record its revision separately. Do not edit a generated policy snapshot or infer repository updates every saved environment. If the supported configuration view is unavailable, report that layer as unverified and continue independent repository work. +## Collect bounded local evidence + +From the selected checkout, run the provider-free collector with the full expected commit: + +```bash +node scripts/cloud-activation-evidence.mjs --expected-revision +``` + +It reports the physical checkout, current branch/revision, dirty state and SHA-256 of known +instruction/bootstrap sources, including the Claude profile guidance tree. Hashes describe the +current bytes, which may include uncommitted changes; they do not attest an installation. Missing +source entries stay explicit. The command never sources profiles, launches agents, calls providers, +reads environment values or opens authentication files. + +Installation inspection is opt-in. Add `--installed-home /absolute/scoped/home` for known Codex +legacy profile/shim artifacts; add `--codex-home /absolute/scoped/codex` if that installation used a +separate Codex home. Add `--claude-config-dir /absolute/scoped/claude` to inspect its ownership +manifest and compare currently recognized owned files to both recorded hashes and current sources. +The collector rejects symlink components, unsafe entries and mismatched checkout owners. It does +not read Codex config, Claude settings, memory seeds or unrecognized retired host files. A modified +owned file remains untouched and produces a mismatch. Config/settings activation stays unverified. + +A legacy generated marker and checkout line are provenance clues; no trusted legacy content +manifest exists. Their reported hashes never become a claim that generated content is correct. +A markerless historical profile has unknown ownership and remains eligible for the existing +explicit legacy migration procedure; collecting evidence neither migrates nor rejects that +migration. A Claude ownership manifest establishes recorded ownership, while matching bytes +establish only the inspected file state. Neither proves native loading. + +The JSON report always marks `activeSession.status` as `unverified`. Supplement it separately with +supported native context-inspection evidence from the actual session, naming its client/session, +selected revision and loaded entry/continuation/topic instructions. A chat's own assertion or a +manually authored receipt is not an independent inspection result. If the client exposes no such +inspection, keep loading unverified even after a fresh chat starts. No dummy dispatch is needed. + +Exit `0` means evidence collection found no detected mismatch; it does not mean operational +acceptance. Exit `1` records an exact revision, ownership, hash or safe-artifact mismatch in JSON. +Exit `2` means invalid arguments or evidence could not be safely collected. All modes are read-only; +review before storing a report because paths and revision metadata identify the selected checkout. + ## Client loading matrix Verify only clients actually supported by the selected host. Do this at the next useful task; @@ -49,6 +89,13 @@ chats can retain previous prompts. Do not overwrite personal memory or delete hi to simulate a context refresh. Use the managed installer’s ownership-aware reconciliation for retired installed files; preserve modified/unowned content for explicit review. +## Related operational checks + +Use [Cloud build resources](cloud-build-resources.md) to distinguish source checks from a +resource-backed build, [Cloud platform acceptance](cloud-platform-acceptance.md) for the next +useful task on each supported platform, and [cross-repository Cloud contracts](cross-repo-cloud-contracts.md) +when comparing shared guidance. Those checks retain their own authority and evidence boundaries. + ## Completion evidence Report the source revision, checks run, installation status and session-loading status separately. diff --git a/docs/agents/cloud-platform-acceptance.md b/docs/agents/cloud-platform-acceptance.md new file mode 100644 index 0000000000..0267d332f6 --- /dev/null +++ b/docs/agents/cloud-platform-acceptance.md @@ -0,0 +1,97 @@ +# Cloud platform acceptance + +This runbook separates local fixture evidence, an observed native Windows CI run, +and a real forwarded browser preview. None establishes the other two. It changes +no host installation, remote environment, credentials, or clinical configuration. + +## Native Windows bootstrap + +`.github/workflows/cloud-platform-smoke.yml` runs one bounded `windows-2025` job +on relevant pull-request changes or an explicit manual dispatch. It uses a +read-only checkout without persisted credentials. There is no scheduled run, +browser install, app dependency install, deployment, or duplicated Linux unit job. +The declared npm version is staged below the runner's temporary directory with +install scripts disabled; job-local `APPDATA` makes the existing bootstrap resolve +that CLI. No global npm installation is changed. + +```bash +node scripts/check-cloud-platform-smoke.mjs --windows +``` + +The command refuses to pass outside actual Windows. On Windows it requires Git +for Windows Bash and its native Windows `uname` result; WSL cannot supply this evidence. +It executes the existing `setup-codex-worktree.mjs` and `install-git-hooks.mjs` +inside a disposable Git worktree named `worktree's space`. It verifies the +missing-dependency dry run, complete matching donor selection, actual dependency +copy through Windows robocopy, copied file bytes, installed-tree parity, Git hook +registration, existing-install reuse, and refusal of a mismatched donor. Fixtures +contain tiny synthetic package files and inert hooks; no registry install fallback +is executed. Cleanup removes only the command's newly created temporary directory. + +Without `--windows`, the same command exercises the current platform and reports +its actual platform. A Linux pass is local bootstrap evidence. It does not prove +Windows, Codex Desktop settings, cold `npm ci`, a full Windows app build, or the +user's ReFS workstation. The Windows command and workflow must actually finish +successfully before recording native Windows acceptance; authoring the workflow +is not a completed hosted run. + +## Identity and synthetic forwarding + +```bash +node scripts/run-vitest.mjs run tests/cloud-platform-smoke.test.ts +``` + +Use the repository's declared Node/npm toolchain. The suite runs the bootstrap +fixture and the actual Playwright base-URL helper against a disposable loopback +HTTP backend through a disposable loopback proxy. It accepts the matching project +payload and refuses a different app, different checkout, or unsafe-origin verdict. +Separate request fixtures exercise the actual local-project guard with forwarding +headers and an unsafe local caller. These tests do not build or attach to an app, +open a public port, use a forwarding provider, or prove TLS/browser behavior. + +Current URL behavior in `scripts/playwright-base-url.ts` is: + +| Input | Acceptance and identity | +| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Explicit `http://localhost:` or `http://127.0.0.1:` with a non-default port | GET `/api/local-project-id`; require `appName=PsychSift`, checkout-derived `projectId`, and `localServer.safeLocalOrigin=true`. Real app identity marks ports outside 3100–4599 unsafe. | +| HTTPS origin with literal `ALLOW_PREVIEW_URL=true` | Accept any hostname except the exact parsed `localhost`, `127.0.0.1`, or `[::1]`; no remote identity request is performed. Custom HTTPS ports and non-loopback IPs are accepted. | +| HTTP remote host, HTTPS exact loopback, or missing literal preview opt-in | Reject. | +| Credentials, non-root path, query, fragment, invalid URL | Reject. | +| Ambient `PREVIEW_URL`, `PLAYWRIGHT_PREVIEW_URL`, or `VERCEL_URL` in shared config | Ignore; dedicated preview runner supplies the explicit target and opt-in. | + +The HTTPS branch has no provider allowlist, DNS ownership check, or authenticated +project identity check. A trailing-dot `https://localhost.` is accepted by that +branch because its parsed hostname is not the exact excluded spelling. HTTPS +opt-in therefore authorizes a target; it does not verify a PsychSift deployment. +`SKIP_REMOTE_IDENTITY_CHECK` from the preview runner does not establish identity. + +`localProjectRequestIdentityPayload` derives local-host/port classification from +`Request.url`, not `x-forwarded-host` or `x-forwarded-proto`. It retains this +checkout's project ID; a non-local request URL reports `currentUrl=null`, +`currentPort=null`, and a safe local-origin verdict unless an unsafe local +Origin/Referer is present. That verdict protects local port use; it cannot prove +ownership or authenticity of a forwarded remote host. + +## Real host forwarding and browser preview + +Reuse the existing `.github/workflows/preview-smoke.yml` and +`scripts/run-preview-smoke.mjs` for an explicitly authorized deployed preview. +There is no second remote browser workflow in this change. They run user journeys +against a supplied deployment and do not create a tunnel or prove its ownership. +Retain the repository's provider-confirmation boundary before dispatching them. + +For a real forwarding acceptance run, first discover an available supported host +port-forwarding tool. Start/verify the actual app with `npm run ensure`, retain its +printed URL and matching `/api/local-project-id`, request a forwarded URL through +that host tool, then use an available browser capability against that exact URL. +Record the observed host, URL, revision, identity response semantics, and actual +journey result. Do not substitute an HTTPS-shaped string, loopback proxy, mocked +browser, or a deployment URL from another environment as evidence of forwarding. + +When the host exposes no supported port-forwarding tool, real forwarded preview +acceptance remains **unavailable**. A browser tool alone cannot create forwarding; +shell access and a loopback server do not expose a public preview. If forwarding +exists but no browser capability does, browser acceptance remains unavailable +separately. Report the missing capability and retain this next acceptance step; +do not install a tunnel service, provision global tooling, use credentials, or +call paid/live providers to manufacture proof. diff --git a/docs/agents/codex-cloud-legacy-bootstrap.md b/docs/agents/codex-cloud-legacy-bootstrap.md index e468fb49ca..8dd49848d8 100644 --- a/docs/agents/codex-cloud-legacy-bootstrap.md +++ b/docs/agents/codex-cloud-legacy-bootstrap.md @@ -56,6 +56,45 @@ recover unknown earlier environment values. Use a fresh shell or a separate scoped process when switching projects. Host proxy and CA variables are preserved by the legacy profile; provider scrubbing keeps the existing inventory and policy. +## Run an explicitly selected command in an isolated scope + +Use the subprocess wrapper when the legacy bootstrap has been explicitly selected and a host is +shared. Choose a new scope directory beneath an existing real parent, then keep that same scope +for maintenance of this checkout: + +```bash +bash scripts/with-isolated-cloud-home.sh --root /absolute/private/psychsift-cloud -- bash scripts/setup-codex-cloud.sh +bash scripts/with-isolated-cloud-home.sh --root /absolute/private/psychsift-cloud -- bash scripts/maintain-codex-cloud.sh +``` + +Select `CODEX_CLOUD_ACCESS_PROFILE` explicitly as required by setup; the wrapper does not select +an access profile, provision anything by itself or grant provider authority. These examples are +operational commands, not routine startup checks. A provider-free way to inspect the scoped file +state after an authorized installation is: + +```bash +node scripts/cloud-activation-evidence.mjs --installed-home /absolute/private/psychsift-cloud/home --codex-home /absolute/private/psychsift-cloud/codex --claude-config-dir /absolute/private/psychsift-cloud/claude +``` + +The wrapper requires Bash, Python 3 and Git. It creates three private directories (`home`, `codex` +and `claude`) and sets `HOME`, `CODEX_HOME` and `CLAUDE_CONFIG_DIR` only in the selected child. It +preserves standard input, command arguments and exit status. The parent shell and personal homes +are unchanged. Proxy/CA and other inherited environment values remain inherited; no credentials, +configuration or personal files are copied. This is directory isolation, not an environment scrub +or process sandbox. Legacy setup's raw-shell admission and all provider boundaries still apply. + +An existing scope must have the wrapper's exact physical-checkout/user ownership record and private +directory permissions. Unowned or foreign directories, symlink path components, modified ownership +records and unsafe replacement home directories fail closed before launching the child. Scope +ownership is local provenance, not content integrity or session-loading proof. Use a different new +scope for another checkout; preserve an old scope for explicit review instead of rewriting its +record. The wrapper does not clean up scopes or adopt existing personal homes. + +This wrapper does not change the retained migration contract: an older generated legacy profile +without a checkout marker still migrates through explicit setup, and markerless maintenance still +requires an explicit access-profile choice. The wrapper never executes or copies that historical +profile automatically. + ## Checkout base cache `refresh-codex-cloud-base.sh` writes the fetched merge base under diff --git a/docs/agents/cross-repo-cloud-contracts.md b/docs/agents/cross-repo-cloud-contracts.md new file mode 100644 index 0000000000..92e10ae59b --- /dev/null +++ b/docs/agents/cross-repo-cloud-contracts.md @@ -0,0 +1,78 @@ +# Cross-repository Cloud source contracts + +The built-in-only checker `scripts/check-cross-repo-cloud-contracts.mjs` catches known +instruction, bootstrap, checkout/preview ownership and legacy migration regressions. +It reads source artifacts; it never runs another repository's scripts, installs dependencies, +starts services, reads application environment files or changes another checkout. + +The explicit `repositoryMatrix` in that script is the maintained adapter list. Change an +adapter with its owning repository's reviewed contract, and add a failing fixture before +changing a shared check. Project policies remain in their owning repositories; this tool +does not copy or compare the complete shared master or require identical client adapters. + +| Repository | Adapter and required surfaces | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `BigSimmo/PsychSift` | npm lockfile, root client entries and continuation, task receipt, current Cloud policy, retained setup/maintenance aliases; explicit legacy profile migration selection and checkout guard | +| `BigSimmo/Communication` | pnpm lockfile/workspace policy, root client entries and receipt, local run/guard aliases, existing agent-rule validator/policy surface | +| `BigSimmo/Medications` | pnpm lockfile/workspace policy, root client entries, portable workflow and fixture server, checkout/run/guard aliases, preview challenge/PID/root/port checks; Windows dispatch remains distinct | +| `BigSimmo/Ward-Flow` | npm lockfile, root client entries and receipt, Cloud ownership guidance and fail-closed editing admission, setup/server aliases and database guidance | +| `BigSimmo/Yarn` | pnpm lockfile/workspace policy, root client entries, portable workflow/server, checkout/run/guard aliases and preview challenge/PID/root/port checks | +| `BigSimmo/caring-contacts` | npm lockfile, root client entries and receipt, maintained test/browser wrappers, existing synthetic-data and migration-authority boundaries | +| `BigSimmo/ward-flow-backup` | Inactive historical backup: README points to Ward Flow and disclaims runnable setup; application/agent bootstrap surfaces require explicit adapter review | + +Claude's `@AGENTS.md` import and Gemini's local Markdown link are both accepted. Missing, +empty, unreadable or linked-outside-checkout required surfaces fail. These are static +tripwires around known code and wording; paraphrases, dead-code lookalikes and arbitrary +behavior changes can escape them. Focused owning-repository tests remain necessary. + +Run the current PsychSift working-tree check without setup: + +```sh +node scripts/check-cross-repo-cloud-contracts.mjs --root . +``` + +Inspect one exact commit from an existing verified clone: + +```sh +node scripts/check-cross-repo-cloud-contracts.mjs --repo Yarn --root /path/to/Yarn --revision --json +``` + +Audit all seven existing clones under one directory, named as in the table: + +```sh +node scripts/check-cross-repo-cloud-contracts.mjs --all --repos-root /path/to/clones +``` + +`--all` resolves each clone's existing `origin/main` to a full SHA before reading Git blobs; +it does not fetch, change branches or inspect the working copies as main. Fetch freshness +is the caller's responsibility. A missing clone/ref, wrong origin or unavailable commit +is `unverified` and makes the command fail. The single-repository default reads the working +tree and reports its HEAD and dirty state; it does not present that source as immutable. +Use `--list` for the adapter matrix and `--json` for structured evidence. Exit 0 means all +requested static checks passed, 1 means failed/unverified sources, and 2 means invalid CLI +usage. Each result includes the exact revision, source mode and specific failures. + +## CI scope and access + +`.github/workflows/cloud-contracts.yml` checks this repository on affected main PRs/pushes. +It uses pinned checkout/Node actions, `contents: read`, no persisted credentials and no +dependency install. Ordinary PRs never fetch or execute another repository's code. + +A manual run can explicitly select `audit_public_main`, corresponding to: + +```sh +node scripts/check-cross-repo-cloud-contracts.mjs --root . --fetch-maintained +``` + +That option fetches the five other maintained repositories' `main` refs into disposable +temporary Git stores using fixed HTTPS URLs without inherited personal credentials or Git +credential helpers. Each fetched SHA is pinned before source checks. Private, inaccessible +or failed fetches are reported as `unverified` and fail the requested audit; they are never +counted as passes. The inactive backup is excluded from this maintained-source fetch and +remains available through the explicit local all-repository audit. No token, secret or +cross-repository GitHub permission is configured by this workflow. + +A passing static check establishes none of native-client instruction loading, effective +host installation, provider access, runtime/server acceptance, migration approval, hosted +CI completion or observed deployment. Record those as separate evidence under each +project's existing safeguards and task receipt. diff --git a/docs/organisation/systems/delivery.json b/docs/organisation/systems/delivery.json index edca43595d..cd4d544846 100644 --- a/docs/organisation/systems/delivery.json +++ b/docs/organisation/systems/delivery.json @@ -29,6 +29,8 @@ "deno.lock", "diff-integrity.json", "docker-compose.yml", + "docs/agents/cloud-build-resources.md", + "docs/agents/cloud-platform-acceptance.md", "docs/ci-operations.md", "docs/continuous-integration.md", "docs/deployment-architecture.md", @@ -63,6 +65,8 @@ "scripts/check-base-freshness.mjs", "scripts/check-bundle-budget.mjs", "scripts/check-ci-env.mjs", + "scripts/check-cloud-build-resources.mjs", + "scripts/check-cloud-platform-smoke.mjs", "scripts/check-coverage-inventory.mjs", "scripts/check-dead-code-candidate.mjs", "scripts/check-dependency-drift.mjs", @@ -217,6 +221,8 @@ "tests/classify-visual-baseline-outcome.test.ts", "tests/claude-review-workflow.test.ts", "tests/clean-*", + "tests/cloud-build-resources.test.ts", + "tests/cloud-platform-smoke.test.ts", "tests/container-ci-contract.test.ts", "tests/coverage-inventory.test.ts", "tests/dead-code-candidate.test.ts", diff --git a/docs/organisation/systems/knowledge.json b/docs/organisation/systems/knowledge.json index c4680d4ece..42642993cd 100644 --- a/docs/organisation/systems/knowledge.json +++ b/docs/organisation/systems/knowledge.json @@ -75,6 +75,7 @@ "scripts/check-codebase-index-coverage.mjs", "scripts/check-codex-cloud-raw-env.sh", "scripts/check-codex-cloud-setup.mjs", + "scripts/check-cross-repo-cloud-contracts.mjs", "scripts/check-docs-links.mjs", "scripts/check-docs-script-refs.mjs", "scripts/check-github-shell-access.mjs", @@ -85,6 +86,7 @@ "scripts/check-stale-docs.mjs", "scripts/claude-session-start.mjs", "scripts/claude-session-start.sh", + "scripts/cloud-activation-evidence.mjs", "scripts/delete-codex-cloud-branch-with-pat.sh", "scripts/diagnose-codex-cloud.mjs", "scripts/ensure-codex-cloud-git-remote.mjs", @@ -119,14 +121,18 @@ "scripts/sweep-branch-ledger.mjs", "scripts/sync-skills.mjs", "scripts/update-docs-inventory.mjs", + "scripts/with-isolated-cloud-home.sh", "tests/agent-scopes.test.ts", "tests/bare-pr-publication-policy.test.ts", "tests/branch-review-index.test.ts", "tests/check-docs-links.test.ts", "tests/claude-*", + "tests/cloud-activation-evidence.test.ts", "tests/cloud-installed-guidance.test.ts", + "tests/cloud-isolated-home.test.ts", "tests/codebase-index-coverage.test.ts", "tests/codex-*", + "tests/cross-repo-cloud-contracts.test.ts", "tests/cursor-mcp-contract.test.ts", "tests/database-skills.test.ts", "tests/docs-*", diff --git a/scripts/check-cloud-build-resources.mjs b/scripts/check-cloud-build-resources.mjs new file mode 100644 index 0000000000..0e46412f2e --- /dev/null +++ b/scripts/check-cloud-build-resources.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { isDirectEntrypoint } from "./lib/is-entrypoint.mjs"; + +const GiB = 1024 ** 3; +const recommendedHeapBytes = 8 * GiB; +const reserveBytes = 4 * GiB; + +function readOptional(readFile, file) { + try { + return readFile(file).trim(); + } catch { + return null; + } +} + +function nonnegativeBytes(text) { + if (!/^\d+$/.test(text ?? "")) return null; + const value = Number(text); + return Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +function unescapeMount(text) { + return text.replace(/\\([0-7]{3})/g, (_, octal) => String.fromCharCode(parseInt(octal, 8))); +} + +/** Read-only Linux cgroup v1/v2 discovery; injected readers keep tests off the real host. */ +export function discoverBuildMemory({ + readFile = (file) => readFileSync(file, "utf8"), + totalMemoryBytes = os.totalmem(), + freeMemoryBytes = os.freemem(), + platform = process.platform, +} = {}) { + const issues = []; + const bounds = []; + const meminfo = platform === "linux" ? readOptional(readFile, "/proc/meminfo") : null; + const availableKiB = meminfo?.match(/^MemAvailable:\s+(\d+)\s+kB$/m)?.[1]; + const hostAvailable = availableKiB ? Number(availableKiB) * 1024 : freeMemoryBytes; + if (!( + Number.isFinite(totalMemoryBytes) && + totalMemoryBytes > 0 && + Number.isFinite(hostAvailable) && + hostAvailable >= 0 + )) { + issues.push("Host memory capacity or availability could not be determined."); + } else { + bounds.push({ + source: "host", + limitBytes: totalMemoryBytes, + availableBytes: Math.min(totalMemoryBytes, hostAvailable), + }); + } + + const membership = platform === "linux" ? readOptional(readFile, "/proc/self/cgroup") : null; + const mounts = platform === "linux" ? readOptional(readFile, "/proc/self/mountinfo") : null; + if (membership && !mounts) issues.push("Cgroup membership exists but its memory mount could not be inspected."); + for (const line of mounts?.split("\n") ?? []) { + const [left, right] = line.split(" - "); + if (!right) continue; + const [type, , options] = right.split(" "); + const v2 = type === "cgroup2"; + if (!v2 && !(type === "cgroup" && options?.split(",").includes("memory"))) continue; + const entry = membership?.split("\n").find((item) => { + const record = item.match(/^(\d+):([^:]*):(\/.*)$/); + if (!record) return false; + return v2 ? record[1] === "0" && record[2] === "" : record[2].split(",").includes("memory"); + }); + if (!entry) { + issues.push("Visible cgroup memory mount has unreadable or unresolved process membership."); + continue; + } + const groupPath = entry.slice(entry.indexOf(":", entry.indexOf(":") + 1) + 1); + const mountFields = left.split(" "); + const mountRoot = unescapeMount(mountFields[3]); + const mountPoint = unescapeMount(mountFields[4]); + // A cgroup namespace may expose / even when mountinfo's root is outside it. + const relative = groupPath === "/" ? "" : path.posix.relative(mountRoot, groupPath); + if (relative === ".." || relative.startsWith("../")) { + issues.push("Cgroup memory path cannot be resolved inside its visible mount."); + continue; + } + let directory = path.posix.join(mountPoint, relative); + for (;;) { + const limitFile = path.posix.join(directory, v2 ? "memory.max" : "memory.limit_in_bytes"); + const limitText = readOptional(readFile, limitFile); + if (limitText === null) { + issues.push(`Cgroup memory limit could not be read at ${limitFile}.`); + } + if (limitText !== null) { + // v1 unlimited uses a page-aligned LONG_MAX sentinel; compare as BigInt. + const unlimited = + limitText === "max" || (/^\d+$/.test(limitText) && BigInt(limitText) >= BigInt(Number.MAX_SAFE_INTEGER)); + const limit = unlimited ? null : nonnegativeBytes(limitText); + if (!unlimited && (limit === null || limit === 0)) issues.push(`Unreadable memory limit at ${limitFile}.`); + if (limit !== null && limit > 0) { + const usage = nonnegativeBytes( + readOptional(readFile, path.posix.join(directory, v2 ? "memory.current" : "memory.usage_in_bytes")), + ); + if (usage === null) { + issues.push(`Memory usage unavailable for finite limit at ${limitFile}.`); + } else { + const stat = readOptional(readFile, path.posix.join(directory, "memory.stat")); + const cacheText = stat?.match(v2 ? /^inactive_file (\d+)$/m : /^total_inactive_file (\d+)$/m)?.[1]; + const reclaimable = Math.min(usage, nonnegativeBytes(cacheText) ?? 0); + bounds.push({ + source: limitFile, + limitBytes: limit, + availableBytes: Math.max(0, limit - usage + reclaimable), + }); + } + } + } + if (directory === mountPoint) break; + directory = path.posix.dirname(directory); + } + } + return { + limitBytes: bounds.length ? Math.min(...bounds.map((bound) => bound.limitBytes)) : null, + availableBytes: bounds.length ? Math.min(...bounds.map((bound) => bound.availableBytes)) : null, + bounds, + issues, + }; +} + +function gib(bytes) { + return `${Number((bytes / GiB).toFixed(2))} GiB`; +} + +/** A planning envelope, not a guarantee that arbitrary builds fit this allocation. */ +export function assessCloudBuildResources({ memory, inheritedHeapBytes }) { + const issues = [...memory.issues]; + const validHeap = Number.isFinite(inheritedHeapBytes) && inheritedHeapBytes > 0; + if (!validHeap) { + issues.push("Inherited worker heap could not be measured; inspect NODE_OPTIONS and rerun."); + } else if (inheritedHeapBytes < recommendedHeapBytes) { + issues.push( + `Inherited worker heap is ${gib(inheritedHeapBytes)}; recommended at least 8 GiB via command-scoped NODE_OPTIONS=--max-old-space-size=8192. Preserve other options and review any explicit heap setting before changing it.`, + ); + } + // V8's reported limit includes overhead beyond old-space. Retain that actual + // value rather than rounding down an explicit larger allocation. + const heapBudget = validHeap ? Math.max(recommendedHeapBytes, inheritedHeapBytes) : recommendedHeapBytes; + const requiredMemoryBytes = heapBudget + reserveBytes; + if (memory.limitBytes !== null && memory.limitBytes < requiredMemoryBytes) { + issues.push( + `Effective memory limit is ${gib(memory.limitBytes)}; recommended envelope is ${gib(requiredMemoryBytes)} (${gib(heapBudget)} heap plus 4 GiB for native allocations, other workers and the OS). Choose a larger runner; an 8 GiB heap does not fit an 8 GiB runner.`, + ); + } + if (memory.availableBytes !== null && memory.availableBytes < requiredMemoryBytes) { + issues.push( + `Currently available memory is ${gib(memory.availableBytes)}, below the ${gib(requiredMemoryBytes)} planning envelope. Wait for other work or select a larger runner, then rerun this preflight.`, + ); + } + return { ok: issues.length === 0, inheritedHeapBytes, requiredMemoryBytes, ...memory, issues }; +} + +function inheritedHeap() { + // Deliberately omit parent execArgv: a flag applied only to this script proves + // nothing about the environment inherited by Next.js subprocesses. + const result = spawnSync( + process.execPath, + ["-e", 'process.stdout.write(String(require("node:v8").getHeapStatistics().heap_size_limit))'], + { + env: process.env, + encoding: "utf8", + timeout: 10_000, + maxBuffer: 64 * 1024, + }, + ); + return result.status === 0 ? nonnegativeBytes(result.stdout.trim()) : null; +} + +if (isDirectEntrypoint(import.meta.url)) { + const args = process.argv.slice(2); + if (args.length > 1 || (args.length === 1 && args[0] !== "--json")) { + console.error("Usage: node scripts/check-cloud-build-resources.mjs [--json]"); + process.exitCode = 2; + } else { + const report = assessCloudBuildResources({ memory: discoverBuildMemory(), inheritedHeapBytes: inheritedHeap() }); + if (args[0] === "--json") console.log(JSON.stringify(report, null, 2)); + else { + console.log( + `[cloud-build-resources] ${report.ok ? "PASS" : "NOT READY"}: inherited heap ${report.inheritedHeapBytes === null ? "unknown" : gib(report.inheritedHeapBytes)}; limit ${report.limitBytes === null ? "unknown" : gib(report.limitBytes)}; available ${report.availableBytes === null ? "unknown" : gib(report.availableBytes)}.`, + ); + for (const issue of report.issues) console.log(`- ${issue}`); + console.log( + "Read-only planning preflight; rerun immediately before the existing build/browser command. See docs/agents/cloud-build-resources.md.", + ); + } + process.exitCode = report.ok ? 0 : 1; + } +} diff --git a/scripts/check-cloud-platform-smoke.mjs b/scripts/check-cloud-platform-smoke.mjs new file mode 100644 index 0000000000..65f8326367 --- /dev/null +++ b/scripts/check-cloud-platform-smoke.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { writeInstalledTreeStamp, installedTreeParity } from "./check-installed-lock-parity.mjs"; +import { isDirectEntrypoint } from "./lib/is-entrypoint.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { encoding: "utf8", timeout: 20_000, ...options }); + assert.ifError(result.error); + assert.equal(result.status, 0, `${command} failed (${result.status}): ${result.stderr}\n${result.stdout}`); + return result.stdout.trim(); +} + +function gitBash() { + if (process.platform !== "win32") return "bash"; + const candidate = path.join(process.env.ProgramFiles || "C:\\Program Files", "Git", "bin", "bash.exe"); + assert.ok(existsSync(candidate), "Native Windows smoke requires Git for Windows bash.exe; WSL is unsupported"); + return candidate; +} + +/** Runs existing bootstrap code against tiny offline fixtures, never the checkout's dependency tree. */ +export function runBootstrapSmoke() { + const shell = gitBash(); + const temporaryRoot = mkdtempSync(path.join(os.tmpdir(), "psychsift-platform-")); + const donor = path.join(temporaryRoot, "donor checkout"); + const target = path.join(temporaryRoot, "worktree's space"); + const shellEnvironment = { ...process.env, SMOKE_WORKTREE: target, SMOKE_NODE: process.execPath }; + const shellScript = `set -euo pipefail +if [ -x /usr/bin/cygpath ]; then + cd "$(/usr/bin/cygpath -u "$SMOKE_WORKTREE")" + smoke_node=$(/usr/bin/cygpath -u "$SMOKE_NODE") +else + cd "$SMOKE_WORKTREE" + smoke_node=$SMOKE_NODE +fi +"$smoke_node" scripts/setup-codex-worktree.mjs "$@"`; + const setup = (...args) => + run(shell, ["-c", shellScript, "cloud-platform-smoke", ...args], { env: shellEnvironment }); + const git = (cwd, ...args) => run("git", args, { cwd }); + try { + if (process.platform === "win32") { + assert.match( + run(shell, ["-c", "/usr/bin/uname -s"]), + /^(MINGW|MSYS|UCRT|CLANG).*_NT/u, + "Expected native Git Bash, not WSL", + ); + } + mkdirSync(donor, { recursive: true }); + const scripts = [ + "setup-codex-worktree.mjs", + "check-installed-lock-parity.mjs", + "install-git-hooks.mjs", + "lib/is-entrypoint.mjs", + ]; + for (const script of scripts) { + const destination = path.join(donor, "scripts", script); + mkdirSync(path.dirname(destination), { recursive: true }); + cpSync(path.join(repositoryRoot, "scripts", script), destination); + } + const manifest = JSON.parse(readFileSync(path.join(repositoryRoot, "package.json"), "utf8")); + writeFileSync( + path.join(donor, "package.json"), + JSON.stringify({ type: "module", engines: manifest.engines, packageManager: manifest.packageManager }), + ); + cpSync(path.join(repositoryRoot, ".node-version"), path.join(donor, ".node-version")); + const packages = { "": { name: "offline-platform-fixture" }, "node_modules/next": { version: "1.0.0" } }; + const lock = JSON.stringify({ lockfileVersion: 3, packages }); + writeFileSync(path.join(donor, "package-lock.json"), lock); + mkdirSync(path.join(donor, ".githooks")); + for (const hook of ["pre-commit", "pre-push"]) + writeFileSync(path.join(donor, ".githooks", hook), "#!/bin/sh\nexit 0\n"); + git(donor, "init", "--quiet"); + git(donor, "add", "."); + git( + donor, + "-c", + "user.name=Offline fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "-c", + "core.hooksPath=.unused-hooks", + "commit", + "--quiet", + "-m", + "Synthetic platform fixture", + ); + git(donor, "worktree", "add", "--quiet", "--detach", target); + + assert.match(setup("--dry-run"), /no complete byte-identical donor/u); + assert.equal(existsSync(path.join(target, "node_modules")), false); + mkdirSync(path.join(donor, "node_modules", "next"), { recursive: true }); + writeFileSync(path.join(donor, "node_modules", "next", "package.json"), JSON.stringify({ version: "1.0.0" })); + writeFileSync(path.join(donor, "node_modules", "next", "fixture's file.d.ts"), "export {};\n"); + writeFileSync(path.join(donor, "node_modules", ".package-lock.json"), lock); + writeInstalledTreeStamp(donor); + assert.match(setup("--dry-run"), /would reuse byte-identical dependencies/u); + assert.match(setup(), /PASS: worktree dependencies/u); + assert.ok(installedTreeParity(target).ok, "Copied dependencies must pass the real installed-tree validator"); + assert.equal( + readFileSync(path.join(target, "node_modules", "next", "fixture's file.d.ts"), "utf8"), + "export {};\n", + ); + assert.equal(git(target, "config", "--get", "core.hooksPath"), ".githooks"); + assert.match(setup(), /PASS: existing dependencies/u); + writeFileSync(path.join(target, "package-lock.json"), lock.replace("1.0.0", "2.0.0")); + assert.match(setup("--dry-run"), /no complete byte-identical donor/u); + return { + platform: process.platform, + missingDependencies: true, + donorCopied: true, + hooksInstalled: true, + existingInstall: true, + mismatchedDonorRejected: true, + }; + } finally { + rmSync(temporaryRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +} + +if (isDirectEntrypoint(import.meta.url)) { + try { + const args = process.argv.slice(2); + assert.ok( + args.length === 0 || (args.length === 1 && args[0] === "--windows"), + "Usage: node scripts/check-cloud-platform-smoke.mjs [--windows]", + ); + if (args.includes("--windows")) + assert.equal(process.platform, "win32", "Native Windows acceptance requires an actual Windows runner"); + const result = runBootstrapSmoke(); + console.log( + `[cloud-platform-smoke] PASS: ${result.platform} bootstrap fixture (shell, paths, donor copy, hooks, mismatch refusal).`, + ); + console.log( + "[cloud-platform-smoke] Real host forwarding/browser preview: unavailable; this fixture is not platform proof.", + ); + } catch (error) { + console.error(`[cloud-platform-smoke] FAIL: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/scripts/check-cross-repo-cloud-contracts.mjs b/scripts/check-cross-repo-cloud-contracts.mjs new file mode 100644 index 0000000000..6e3032e356 --- /dev/null +++ b/scripts/check-cross-repo-cloud-contracts.mjs @@ -0,0 +1,502 @@ +#!/usr/bin/env node +// Static read-only contracts: never import/execute another repository's source. +import { execFileSync } from "node:child_process"; +import { lstatSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ownRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const portableAliases = { + "check:checkout": ["scripts/portable-workflow.mjs", "identity"], + "preview:cloud": ["scripts/portable-workflow.mjs", "run"], + "preview:guard": ["scripts/portable-workflow.mjs", "guard"], +}; + +// Deliberately explicit adapters, rather than applying PsychSift's policies everywhere. +export const repositoryMatrix = Object.freeze([ + { + name: "PsychSift", + active: true, + packageName: "prompt-for-codex-medical-knowledge-base", + lock: "package-lock.json", + sharedRules: true, + docs: [ + "PROJECT-INSTRUCTIONS-CONTINUATION.md", + "docs/task-receipts.md", + "docs/agents/codex-cloud-environment.md", + "docs/agents/codex-cloud-legacy-bootstrap.md", + ], + aliases: { "cloud:setup": ["scripts/setup-codex-cloud.sh"], "cloud:maintain": ["scripts/maintain-codex-cloud.sh"] }, + }, + { + name: "Communication", + active: true, + packageName: "workspace", + lock: "pnpm-lock.yaml", + sharedRules: true, + docs: ["docs/task-receipts.md", "scripts/agent-rule-policy.json"], + aliases: { + "app:run": ["scripts/local-app.mjs", "run"], + "app:guard": ["scripts/local-app.mjs", "guard"], + "agent-rules:check": ["scripts/validate-agent-rules.mjs"], + }, + }, + { + name: "Medications", + active: true, + packageName: "medications", + lock: "pnpm-lock.yaml", + portable: true, + docs: [ + "docs/portable-workflow.md", + ".project-identity.json", + "scripts/preview-route.mjs", + "artifacts/api-server/tests/serve-drug-guide.mjs", + ], + aliases: { + ...portableAliases, + "app:run": ["scripts/preview-route.mjs", "run"], + "app:guard": ["scripts/preview-route.mjs", "guard"], + }, + }, + { + name: "Ward-Flow", + active: true, + packageName: "ward-flow", + lock: "package-lock.json", + sharedRules: true, + docs: [ + "docs/task-receipts.md", + "docs/agents/cloud-ownership.md", + "scripts/ward-flow/sign-out-check.mjs", + "docs/ward-flow/DATABASE.md", + ], + aliases: { + "setup:codex-worktree": ["scripts/setup-codex-worktree.mjs"], + ensure: ["scripts/ensure-local-server.mjs"], + }, + }, + { + name: "Yarn", + active: true, + packageName: "workspace", + lock: "pnpm-lock.yaml", + portable: true, + docs: ["docs/portable-workflow.md", "scripts/portable-preview.mjs"], + aliases: portableAliases, + }, + { + name: "caring-contacts", + active: true, + packageName: "caring-contacts", + lock: "package-lock.json", + sharedRules: true, + docs: ["docs/task-receipts.md"], + aliases: { test: ["scripts/run-vitest.mjs"], "test:e2e": ["scripts/run-browser-tests.mjs"] }, + }, + { name: "ward-flow-backup", active: false, docs: ["README.md"] }, +]); + +function adapter(name) { + const entry = repositoryMatrix.find((item) => item.name === name); + if (!entry) throw new Error("Unknown repository; choose a name from --list."); + return entry; +} + +/** Known source tripwires, not a parser or proof of runtime/authorization semantics. */ +export function checkCloudContracts(name, read) { + const entry = adapter(name); + const failures = []; + const cache = new Map(); + const content = (file) => { + if (!cache.has(file)) { + let value = null; + try { + value = read(file); + } catch { + /* Missing/unreadable surfaces fail below. */ + } + cache.set(file, typeof value === "string" ? value : null); + } + return cache.get(file); + }; + const fail = (code, file, message) => failures.push({ code, file, message }); + const requireFile = (file) => { + const value = content(file); + if (!value?.trim()) fail("required-surface", file, "Required regular source file is missing, empty or unreadable."); + return value ?? ""; + }; + const probe = (code, file, patterns, message) => { + const value = content(file) ?? ""; + if (!patterns.every((pattern) => pattern.test(value))) fail(code, file, message); + }; + for (const file of entry.docs) requireFile(file); + if (!entry.active) { + probe( + "backup-boundary", + "README.md", + [/backup destination/i, /not a runnable application/i, /BigSimmo\/Ward-Flow/], + "Backup must point to the maintained repository and remain explicitly non-runnable.", + ); + for (const file of ["package.json", "AGENTS.md", "CLAUDE.md", "GEMINI.md", "scripts/setup-codex-cloud.sh"]) { + if (content(file) !== null) + fail( + "backup-activation", + file, + "Historical backup has acquired an active application/agent surface; review its adapter explicitly.", + ); + } + return failures; + } + requireFile("AGENTS.md"); + requireFile("README.md"); + for (const file of ["CLAUDE.md", "GEMINI.md"]) { + const value = requireFile(file); + if (!/(?:^|\n)\s*@AGENTS\.md\s*(?:\n|$)|\]\((?:\.\/)?AGENTS\.md(?:#[\w-]+)?\)/.test(value)) { + fail("native-entry", file, "Native adapter must import or link this checkout's root AGENTS.md."); + } + } + if (entry.sharedRules) { + probe( + "shared-rule-boundary", + "AGENTS.md", + [//, //], + "Maintained shared-rule boundaries are missing; policy prose is not compared or copied.", + ); + } + for (const file of entry.docs.filter( + (file) => + file.startsWith("docs/") && + (file.includes("task-receipts") || file.includes("portable-workflow") || file.includes("cloud-ownership")), + )) { + if (!(content("AGENTS.md") ?? "").includes(file)) + fail("rule-link", "AGENTS.md", `Required scoped guidance is no longer linked: ${file}.`); + } + let pkg; + try { + pkg = JSON.parse(requireFile("package.json")); + } catch { + fail("package-json", "package.json", "Package manifest is not valid JSON."); + } + requireFile(entry.lock); + if (pkg) { + if (pkg.name !== entry.packageName || typeof pkg.engines?.node !== "string") + fail("package-identity", "package.json", "Repository package identity or declared Node engine drifted."); + for (const [alias, [target, command]] of Object.entries(entry.aliases)) { + requireFile(target); + const value = pkg.scripts?.[alias] ?? ""; + const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + if ( + !new RegExp(`(?:^|[\\s])(?:\\./)?${escaped}(?:[\\s]|$)`).test(value) || + (command && !new RegExp(`\\b${command}\\b`).test(value)) + ) { + fail( + "bootstrap-alias", + "package.json", + `${alias} must retain its repository-owned ${target}${command ? ` ${command}` : ""} route.`, + ); + } + } + } + if (entry.lock === "pnpm-lock.yaml") { + requireFile("pnpm-workspace.yaml"); + if (!/^pnpm@\d+\.\d+\.\d+$/.test(pkg?.packageManager ?? "")) + fail("package-manager", "package.json", "pnpm repository must declare an exact packageManager version."); + probe( + "implicit-install", + "pnpm-workspace.yaml", + [/^verifyDepsBeforeRun:\s*["']?error["']?\s*$/m], + "Script invocation must retain the fail-closed dependency policy.", + ); + } + if (entry.portable) { + const file = "scripts/portable-workflow.mjs"; + probe( + "checkout-ownership", + file, + [new RegExp(`BigSimmo/${name}`), /--show-toplevel/, /realpathSync/], + "Portable bootstrap lost a checkout/origin identity tripwire.", + ); + probe( + "preview-ownership", + file, + [ + /reply\.challenge\s*!==\s*challenge/, + /reply\.pid\s*!==\s*state\.pid/, + /reply\.port\s*!==\s*state\.port/, + /reply\.rootId\s*!==\s*state\.rootId/, + ], + "Portable preview must retain fresh challenge, PID, root and port checks.", + ); + probe( + "implicit-install", + file, + [ + /npm_config_verify_deps_before_run:\s*["']error["']/, + /pnpm_config_verify_deps_before_run:\s*["']error["']/, + /--config\.verify-deps-before-run=error/, + ], + "Verification subprocesses must pin the dependency policy through both environments and CLI.", + ); + } + if (name === "PsychSift") { + probe( + "legacy-opt-in", + "docs/agents/codex-cloud-environment.md", + [/explicit selection only/i, /not a default requirement/i], + "Current host guidance must distinguish explicitly selected legacy setup.", + ); + probe( + "legacy-migration-selection", + "scripts/maintain-codex-cloud.sh", + [ + /\[\[\s*"?\$requested_cloud_profile_set"?\s*=\s*yes\s*\]\]\s*\|\|/, + /Legacy runtime profile migration requires an explicit/, + /PsychSift checkout:/, + ], + "Legacy migration must retain explicit profile selection and checkout ownership guards.", + ); + probe( + "migration-authority", + "AGENTS.md", + [/No database change without the owner's explicit permission/, /MERGING TO `main` DEPLOYS TO PRODUCTION/], + "PsychSift's live migration authorization and merge boundary tripwires are missing.", + ); + } + if (name === "Ward-Flow") { + probe( + "cloud-ownership", + "docs/agents/cloud-ownership.md", + [/WARD_SIGNOUT_FILE/, /missing\/unreadable log blocks/, /repo=BigSimmo\/Ward-Flow/], + "Cloud ownership guidance must retain scoped, readable coordination and repository claims.", + ); + probe( + "ownership-fail-closed", + "scripts/ward-flow/sign-out-check.mjs", + [/readSignOutText\(\)/, /ownership cannot be established/, /process\.exit\(1\)/], + "Editing sign-out admission must retain its fail-closed path; stale inventory remains separately allowed.", + ); + } + if (name === "caring-contacts") + probe( + "migration-authority", + "AGENTS.md", + [/migrations/, /separately applicable authority/, /synthetic prototype/], + "Caring Contacts must retain separate migration authority and synthetic prototype boundaries.", + ); + return failures; +} + +function git(root, args, extra = {}) { + return execFileSync("git", ["--no-optional-locks", "-C", root, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 60_000, + maxBuffer: 16 * 1024 * 1024, + ...extra, + }).trim(); +} + +function canonicalRemote(value) { + return /^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)(BigSimmo\/[\w.-]+?)(?:\.git)?\/?$/i + .exec(value)?.[1] + .toLowerCase(); +} + +export function inspectCheckout(name, root, revision) { + const result = { + repository: `BigSimmo/${name}`, + revision: revision ?? null, + source: revision ? "git-blobs" : "working-tree", + dirty: null, + status: "unverified", + failures: [], + }; + try { + adapter(name); + const physicalRoot = realpathSync(root); + if (realpathSync(git(physicalRoot, ["rev-parse", "--show-toplevel"])) !== physicalRoot) + throw Error("Select the exact Git checkout root."); + const origins = git(physicalRoot, ["config", "--local", "--get-all", "remote.origin.url"]).split(/\r?\n/); + if (!origins.length || origins.some((origin) => canonicalRemote(origin) !== `bigsimmo/${name.toLowerCase()}`)) + throw Error("Checkout origin does not match the selected repository."); + if (revision && !/^[a-f0-9]{40}$/.test(revision)) + throw Error("Pinned revision must be a full lowercase 40-character commit SHA."); + result.revision = git(physicalRoot, ["rev-parse", "--verify", `${revision ?? "HEAD"}^{commit}`]); + let read; + if (revision) { + const entries = new Map( + git(physicalRoot, ["ls-tree", "-r", result.revision]) + .split("\n") + .map((line) => { + const match = /^(100644|100755) blob [a-f0-9]+\t(.+)$/.exec(line); + return match ? [match[2], true] : ["", false]; + }), + ); + read = (file) => (entries.has(file) ? git(physicalRoot, ["show", `${result.revision}:${file}`]) : null); + } else { + result.dirty = Boolean(git(physicalRoot, ["status", "--porcelain", "--untracked-files=normal"])); + read = (file) => { + const target = path.join(physicalRoot, file); + try { + if (!lstatSync(target).isFile() || !realpathSync(target).startsWith(`${physicalRoot}${path.sep}`)) + return null; + return readFileSync(target, "utf8"); + } catch { + return null; + } + }; + } + result.failures = checkCloudContracts(name, read); + result.status = result.failures.length ? "fail" : "pass"; + } catch { + // Never print Git stderr (which can contain credential-bearing remote/config details). + result.failures.push({ + code: "source-unavailable", + file: null, + message: "Repository identity, root or requested revision could not be verified; no passing result is available.", + }); + } + return result; +} + +function fetchMaintained() { + const temporaryRoot = mkdtempSync(path.join(os.tmpdir(), "cloud-contract-audit-")); + const results = []; + // No personal credential/helper/config inheritance. Preserve only transport/runtime configuration. + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => + /^(?:PATH|SystemRoot|TMPDIR|TEMP|TMP|HTTPS?_PROXY|https?_proxy|ALL_PROXY|all_proxy|NO_PROXY|no_proxy|SSL_CERT_FILE|SSL_CERT_DIR|GIT_SSL_CAINFO|GIT_SSL_CAPATH)$/.test( + key, + ), + ), + ); + Object.assign(env, { GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: os.devNull }); + try { + for (const entry of repositoryMatrix.filter((item) => item.active && item.name !== "PsychSift")) { + const root = path.join(temporaryRoot, entry.name); + try { + execFileSync("git", ["init", "--quiet", root], { env, stdio: "pipe", timeout: 10_000 }); + git(root, ["remote", "add", "origin", `https://github.com/BigSimmo/${entry.name}.git`], { env }); + git( + root, + [ + "-c", + "credential.helper=", + "-c", + "core.askPass=", + "fetch", + "--no-tags", + "--depth=1", + "origin", + "refs/heads/main", + ], + { env }, + ); + const revision = git(root, ["rev-parse", "FETCH_HEAD"], { env }); + results.push(inspectCheckout(entry.name, root, revision)); + } catch { + results.push({ + repository: `BigSimmo/${entry.name}`, + revision: null, + source: "git-blobs", + dirty: null, + status: "unverified", + failures: [ + { + code: "fetch-unavailable", + file: null, + message: + "Unauthenticated main fetch failed; inaccessible repositories are unverified and fail this requested audit.", + }, + ], + }); + } + } + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } + return results; +} + +function main(args) { + const options = {}; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (["--json", "--list", "--all", "--fetch-maintained"].includes(arg)) options[arg] = true; + else if ( + ["--repo", "--root", "--revision", "--repos-root"].includes(arg) && + args[index + 1] && + !args[index + 1].startsWith("--") + ) + options[arg] = args[++index]; + else + throw Error( + "Usage: --repo NAME --root PATH [--revision FULL_SHA] [--json]; --all --repos-root PATH; --fetch-maintained --root PATH; --list", + ); + } + if (options["--list"]) { + console.log(JSON.stringify(repositoryMatrix, null, 2)); + return; + } + if ( + options["--all"] && + (!options["--repos-root"] || + options["--revision"] || + options["--repo"] || + options["--root"] || + options["--fetch-maintained"]) + ) + throw Error("--all requires --repos-root only (plus --json)."); + if (options["--fetch-maintained"] && (options["--repo"] || options["--revision"] || options["--repos-root"])) + throw Error("--fetch-maintained accepts only --root and --json; it audits maintained main revisions anonymously."); + if (!options["--all"] && !options["--fetch-maintained"] && options["--repos-root"]) + throw Error("--repos-root requires --all."); + let results; + if (options["--all"]) { + results = repositoryMatrix.map((entry) => { + const root = path.resolve(options["--repos-root"], entry.name); + let revision = "unavailable"; + try { + revision = git(root, ["rev-parse", "--verify", "refs/remotes/origin/main^{commit}"]); + } catch { + /* Fail closed below. */ + } + return inspectCheckout(entry.name, root, revision); + }); + } else { + results = [ + inspectCheckout( + options["--repo"] ?? "PsychSift", + path.resolve(options["--root"] ?? ownRoot), + options["--revision"], + ), + ]; + if (options["--fetch-maintained"]) results.push(...fetchMaintained()); + } + const report = { schemaVersion: 1, evidence: "static-source-contracts", results }; + if (options["--json"]) console.log(JSON.stringify(report, null, 2)); + else { + for (const result of results) { + console.log( + `${result.status.toUpperCase()} ${result.repository} revision=${result.revision ?? "unavailable"} source=${result.source}${result.dirty === null ? "" : ` dirty=${result.dirty}`}`, + ); + for (const failure of result.failures) + console.log(` ${failure.code} ${failure.file ?? "source"}: ${failure.message}`); + } + console.log( + `Cloud contracts: ${results.filter((result) => result.status === "pass").length}/${results.length} passed (static source only).`, + ); + } + if (results.some((result) => result.status !== "pass")) process.exitCode = 1; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(error.message); + process.exitCode = 2; + } +} diff --git a/scripts/cloud-activation-evidence.mjs b/scripts/cloud-activation-evidence.mjs new file mode 100755 index 0000000000..886f71a157 --- /dev/null +++ b/scripts/cloud-activation-evidence.mjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node +// Offline evidence only: never source installed files or inspect environment/config values. +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const options = {}; +const allowed = new Set(["--expected-revision", "--installed-home", "--codex-home", "--claude-config-dir"]); +for (let i = 2; i < process.argv.length; i += 2) { + const name = process.argv[i]; + if (!allowed.has(name) || !process.argv[i + 1] || options[name] !== undefined) { + console.error( + "Usage: node scripts/cloud-activation-evidence.mjs [--expected-revision FULL_SHA] [--installed-home DIR] [--codex-home DIR] [--claude-config-dir DIR]", + ); + process.exit(2); + } + options[name] = process.argv[i + 1]; +} +if (options["--expected-revision"] && !/^[a-f0-9]{40}$/.test(options["--expected-revision"])) { + console.error("Expected revision must be a full lowercase Git SHA."); + process.exit(2); +} +const checkout = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), "..")); +const issues = []; +const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const git = (...args) => + execFileSync("git", ["--no-optional-locks", "-c", "core.fsmonitor=false", "-C", checkout, ...args], { + encoding: "utf8", + maxBuffer: 2 * 1024 * 1024, + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + +// Inspect every component before reading, including ancestors of selected external homes. +function inspect(path) { + for (let part = resolve(path); ; part = dirname(part)) { + const info = lstatSync(part, { throwIfNoEntry: false }); + if (info?.isSymbolicLink()) return { state: "symlink" }; + if (part !== resolve(path) && info && !info.isDirectory()) return { state: "non-directory-parent" }; + if (part === dirname(part)) break; + } + const info = lstatSync(path, { throwIfNoEntry: false }); + if (!info) return { state: "missing" }; + if (!info.isFile()) return { state: "non-file" }; + if (info.size > 2 * 1024 * 1024) return { state: "oversize" }; + const bytes = readFileSync(path); + return { state: "regular", sha256: digest(bytes), bytes }; +} +function fileEvidence(path, label) { + const { state, sha256 } = inspect(path); + return sha256 ? { path: label, state, sha256 } : { path: label, state }; +} +function claudeEvidence(directory) { + const manifest = inspect(join(directory, ".cloud-profile-install.json")); + if (manifest.state !== "regular") { + if (manifest.state !== "missing") issues.push("Claude ownership manifest is unsafe or unreadable"); + return { status: "unverified", manifestState: manifest.state, files: [] }; + } + let value; + try { + value = JSON.parse(manifest.bytes.toString("utf8")); + } catch { + /* report without content */ + } + const plain = (item) => item !== null && typeof item === "object" && !Array.isArray(item); + const validPath = (path) => + path === "CLAUDE.md" || + (/^skills\/(?:[a-zA-Z0-9_.-]+\/)*[a-zA-Z0-9_.-]+$/.test(path) && + !path.split("/").some((part) => part === "." || part === "..")); + if ( + !plain(value) || + value.version !== 1 || + typeof value.source !== "string" || + !plain(value.files) || + !Array.isArray(value.settings) || + Object.keys(value.files).length > 2000 || + Object.entries(value.files).some(([path, hash]) => !validPath(path) || !/^[a-f0-9]{64}$/.test(hash)) + ) { + issues.push("Claude ownership manifest has an unsupported structure"); + return { status: "unverified", manifestState: "invalid", files: [] }; + } + const ownerMatches = value.source === checkout; + if (!ownerMatches) { + issues.push("Claude installation belongs to a different source checkout"); + return { status: "inspected", manifestSha256: manifest.sha256, ownerMatches, files: [] }; + } + const files = Object.entries(value.files) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([path, expected]) => { + const source = inspect(join(checkout, ".claude", "cloud-profile", path)); + // Retired/unrecognized manifest entries are recorded without opening host files. + if (source.state !== "regular") { + issues.push("Claude manifest contains an entry absent from current regular source"); + return { path, state: "source-unavailable", sourceMatches: false }; + } + const actual = fileEvidence(join(directory, path), path); + const manifestMatches = actual.sha256 === expected; + const sourceMatches = actual.sha256 === source.sha256; + if (!manifestMatches || !sourceMatches) issues.push("Claude artifact differs from manifest or current source"); + return { ...actual, manifestMatches, sourceMatches, sourceSha256: source.sha256 }; + }); + return { + status: "inspected", + manifestSha256: manifest.sha256, + ownerMatches, + files, + settings: "unverified; config values are not inspected", + provenance: "local manifest and byte comparison; not runtime loading proof", + }; +} +function codexEvidence(home, configHome) { + const names = [ + ["runtime-profile", join(home, ".clinical-kb-codex-cloud.sh"), "# Generated by scripts/setup-codex-cloud.sh."], + ...["node", "npm", "npx"].map((name) => [ + `shim-${name}`, + join(home, ".local", "bin", name), + "# Generated by scripts/install-codex-cloud-command-shims.sh.", + ]), + ]; + const files = names.map(([path, destination, marker]) => { + const { bytes, ...actual } = inspect(destination); + let owner = "unknown"; + let generatedMarker = false; + if (bytes) { + const lines = bytes.toString("utf8").split("\n"); + generatedMarker = lines.includes(marker); + const owners = lines.filter((line) => line.startsWith("# PsychSift checkout: ")); + if (owners.length) + owner = + owners.length === 1 && owners[0] === `# PsychSift checkout: ${checkout}` + ? "selected-checkout" + : "other-or-ambiguous"; + if (owner === "other-or-ambiguous") + issues.push("Codex legacy artifact belongs to a different or ambiguous checkout"); + } + if (!["missing", "regular"].includes(actual.state)) issues.push("Codex legacy artifact is unsafe or unreadable"); + return { path, ...actual, generatedMarker, owner, contentVerification: "unverified" }; + }); + // Do not read config.toml: even a hash of the whole config would inspect unrelated credentials. + return { + status: "inspected", + home: resolve(home), + configHome: resolve(configHome), + files, + shellPolicy: "unverified; config values are not inspected", + provenance: "markers are ownership clues only; legacy artifacts have no trusted content manifest", + }; +} +try { + if (realpathSync(git("rev-parse", "--show-toplevel")) !== checkout) throw new Error("checkout identity"); + const revision = git("rev-parse", "HEAD"); + const revisionMatches = options["--expected-revision"] ? revision === options["--expected-revision"] : null; + if (revisionMatches === false) issues.push("Source revision does not match the expected revision"); + const sourcePaths = [ + "AGENTS.md", + "PROJECT-INSTRUCTIONS-CONTINUATION.md", + "CLAUDE.md", + "GEMINI.md", + "docs/agents/codex-cloud-environment.md", + "docs/agents/cloud-instruction-activation.md", + "scripts/setup-codex-cloud.sh", + "scripts/install-codex-cloud-command-shims.sh", + "scripts/apply-claude-cloud-profile.mjs", + ".claude/cloud-profile/CLAUDE.md", + ]; + // Include the source guidance tree, excluding settings and memory seeds. + function guidancePaths(directory, relative, depth = 0) { + if (depth > 12 || sourcePaths.length > 2000) throw new Error("source guidance exceeds bounds"); + if (inspect(directory).state === "symlink") { + sourcePaths.push(relative); + return; + } + const info = lstatSync(directory, { throwIfNoEntry: false }); + if (!info?.isDirectory()) return; + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + if (sourcePaths.length >= 2000) throw new Error("source guidance exceeds bounds"); + const next = `${relative}/${entry.name}`; + if (entry.isDirectory()) guidancePaths(join(directory, entry.name), next, depth + 1); + else sourcePaths.push(next); + } + } + guidancePaths(join(checkout, ".claude/cloud-profile/skills"), ".claude/cloud-profile/skills"); + const source = { + checkout, + revision, + branch: git("branch", "--show-current"), + dirty: git("status", "--porcelain", "--untracked-files=normal") !== "", + revisionMatches, + files: sourcePaths.map((path) => fileEvidence(join(checkout, path), path)), + }; + if (source.files.some((file) => !["regular", "missing"].includes(file.state))) + issues.push("Source guidance contains an unsafe artifact"); + const installed = { status: "unverified" }; + if (options["--installed-home"]) { + installed.status = "inspected"; + installed.codex = codexEvidence( + resolve(options["--installed-home"]), + resolve(options["--codex-home"] || join(options["--installed-home"], ".codex")), + ); + } else if (options["--codex-home"]) throw new Error("--codex-home requires --installed-home"); + if (options["--claude-config-dir"]) { + installed.status = "inspected"; + installed.claude = claudeEvidence(resolve(options["--claude-config-dir"])); + } + console.log( + JSON.stringify( + { + version: 1, + source, + installed, + activeSession: { + status: "unverified", + reason: + "Requires supported native context inspection; source files, manifests, environment variables and this report do not prove loaded instructions", + }, + issues: [...new Set(issues)], + }, + null, + 2, + ), + ); + process.exitCode = issues.length ? 1 : 0; +} catch { + console.error( + "Cloud activation evidence could not be collected safely; verify checkout, arguments and readable regular artifacts. No files changed.", + ); + process.exitCode = 2; +} diff --git a/scripts/with-isolated-cloud-home.sh b/scripts/with-isolated-cloud-home.sh new file mode 100755 index 0000000000..5c84eb73d4 --- /dev/null +++ b/scripts/with-isolated-cloud-home.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Explicit subprocess scope only; this does not select/provision a legacy profile or scrub credentials. +set -Eeuo pipefail +if [[ $# -lt 4 || "$1" != --root || "$3" != -- || -z "$2" ]]; then + printf 'Usage: bash scripts/with-isolated-cloud-home.sh --root NEW_OR_OWNED_DIRECTORY -- COMMAND [ARGS...]\n' >&2 + exit 2 +fi +scope_root="$2" +shift 3 +checkout="$(git -C "$(dirname "${BASH_SOURCE[0]}")/.." rev-parse --show-toplevel)" +# Python performs bounded path/ownership checks and replaces itself with the selected command. +# No shell startup, authentication file or parent config is read/copied. +exec python3 /dev/fd/3 "$scope_root" "$checkout" "$@" 3<<'PY' +import json +import os +from pathlib import Path +import stat +import sys + + +def refuse(message): + print("[cloud-isolated-home] " + message, file=sys.stderr) + sys.exit(2) + + +def info(path): + try: + return path.lstat() + except FileNotFoundError: + return None + + +def safe_components(path): + for component in [path, *path.parents]: + details = info(component) + if details and (stat.S_ISLNK(details.st_mode) or not stat.S_ISDIR(details.st_mode)): + refuse("scope path components must be real directories") + + +def owned_directory(path): + details = info(path) + if not details or not stat.S_ISDIR(details.st_mode) or details.st_uid != os.getuid() or details.st_mode & 0o077: + refuse("scope directories must be owned by this user with private permissions") + + +try: + root = Path(os.path.abspath(sys.argv[1])) + checkout = str(Path(sys.argv[2]).resolve(strict=True)) + safe_components(root) + marker = root / ".psychsift-cloud-scope.json" + expected = {"version": 1, "checkout": checkout, "uid": os.getuid()} + if info(root): + owned_directory(root) + details = info(marker) + if not details or not stat.S_ISREG(details.st_mode) or details.st_uid != os.getuid() or details.st_size > 8192 or details.st_mode & 0o077: + refuse("existing scope is unowned or has an unsafe ownership record") + with marker.open(encoding="utf-8") as stream: + if json.load(stream) != expected: + refuse("scope belongs to another checkout or user") + else: + # Require an existing parent; never create an unchecked ancestor chain. + if not root.parent.is_dir(): + refuse("scope parent must already exist") + root.mkdir(mode=0o700) + with marker.open("x", encoding="utf-8") as stream: + os.chmod(marker, 0o600) + json.dump(expected, stream, sort_keys=True) + homes = {"HOME": root / "home", "CODEX_HOME": root / "codex", "CLAUDE_CONFIG_DIR": root / "claude"} + # Validate all destinations before creating any missing home. + for home in homes.values(): + safe_components(home) + if info(home): + owned_directory(home) + for home in homes.values(): + home.mkdir(mode=0o700, exist_ok=True) + environment = os.environ.copy() + environment.update({name: str(home) for name, home in homes.items()}) + os.execvpe(sys.argv[3], sys.argv[3:], environment) +except (OSError, ValueError, TypeError): + refuse("could not safely prepare scope or execute command; inspect local ownership and arguments") +PY diff --git a/tests/cloud-activation-evidence.test.ts b/tests/cloud-activation-evidence.test.ts new file mode 100644 index 0000000000..aace056a4d --- /dev/null +++ b/tests/cloud-activation-evidence.test.ts @@ -0,0 +1,148 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const script = join(import.meta.dirname, "..", "scripts", "cloud-activation-evidence.mjs"); +const temporary: string[] = []; +function fixture() { + const root = mkdtempSync(join(tmpdir(), "activation-evidence-")); + temporary.push(root); + mkdirSync(join(root, "scripts")); + cpSync(script, join(root, "scripts", "cloud-activation-evidence.mjs")); + writeFileSync(join(root, "AGENTS.md"), "Synthetic safeguards\n"); + mkdirSync(join(root, ".claude", "cloud-profile"), { recursive: true }); + writeFileSync(join(root, ".claude", "cloud-profile", "CLAUDE.md"), "Synthetic profile\n"); + for (const args of [ + ["init", "-q"], + ["add", "."], + ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-qm", "fixture"], + ]) { + expect(spawnSync("git", args, { cwd: root, encoding: "utf8" }).status).toBe(0); + } + const revision = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).stdout.trim(); + return { root, revision }; +} +function run(root: string, args: string[] = []) { + return spawnSync(process.execPath, [join(root, "scripts", "cloud-activation-evidence.mjs"), ...args], { + cwd: root, + encoding: "utf8", + timeout: 10_000, + env: { ...process.env, ACTIVATION_SECRET: "synthetic-secret-must-not-print" }, + }); +} +afterEach(() => { + for (const root of temporary.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("read-only Cloud activation evidence", () => { + it("pins exact source bytes and revision while leaving installation and session unverified", () => { + const { root, revision } = fixture(); + const result = run(root, ["--expected-revision", revision]); + expect(result.status, result.stderr).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.source).toMatchObject({ checkout: root, revision, dirty: false, revisionMatches: true }); + expect(report.source.files.find((file: { path: string }) => file.path === "AGENTS.md")).toMatchObject({ + sha256: createHash("sha256").update("Synthetic safeguards\n").digest("hex"), + }); + expect(report.installed.status).toBe("unverified"); + expect(report.activeSession.status).toBe("unverified"); + expect(result.stdout).not.toContain("synthetic-secret-must-not-print"); + }); + it("fails an exact revision mismatch and reports dirty source without claiming committed bytes", () => { + const { root } = fixture(); + writeFileSync(join(root, "AGENTS.md"), "Changed safeguards\n"); + const result = run(root, ["--expected-revision", "0".repeat(40)]); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).source).toMatchObject({ dirty: true, revisionMatches: false }); + }); + it("compares owned Claude artifacts to both manifest and current source without opening settings or credentials", () => { + const { root } = fixture(); + const config = join(root, "installed"); + mkdirSync(config); + const bytes = readFileSync(join(root, ".claude", "cloud-profile", "CLAUDE.md")); + writeFileSync(join(config, "CLAUDE.md"), bytes); + writeFileSync(join(config, "settings.json"), "synthetic-private-config-must-not-print"); + writeFileSync( + join(config, ".cloud-profile-install.json"), + JSON.stringify({ + version: 1, + source: root, + files: { "CLAUDE.md": createHash("sha256").update(bytes).digest("hex") }, + settings: [], + }), + ); + const good = run(root, ["--claude-config-dir", config]); + expect(good.status, good.stderr).toBe(0); + const report = JSON.parse(good.stdout); + expect(report.installed.claude).toMatchObject({ ownerMatches: true }); + expect(report.installed.claude.files[0]).toMatchObject({ + path: "CLAUDE.md", + manifestMatches: true, + sourceMatches: true, + }); + expect(report.activeSession.status).toBe("unverified"); + expect(good.stdout).not.toContain("synthetic-private-config-must-not-print"); + writeFileSync(join(config, "CLAUDE.md"), "modified host guidance"); + const changed = run(root, ["--claude-config-dir", config]); + expect(changed.status).toBe(1); + expect(JSON.parse(changed.stdout).installed.claude.files[0]).toMatchObject({ + manifestMatches: false, + sourceMatches: false, + }); + }); + it("rejects another checkout's ownership, path traversal and symlinked artifacts", () => { + const { root } = fixture(); + const config = join(root, "installed"); + mkdirSync(config); + const manifest = join(config, ".cloud-profile-install.json"); + for (const value of [ + { version: 1, source: "/another-checkout", files: {}, settings: [] }, + { version: 1, source: root, files: { "../outside": "a".repeat(64) }, settings: [] }, + ]) { + writeFileSync(manifest, JSON.stringify(value)); + expect(run(root, ["--claude-config-dir", config]).status).toBe(1); + } + symlinkSync(join(root, "AGENTS.md"), join(config, "CLAUDE.md")); + writeFileSync( + manifest, + JSON.stringify({ version: 1, source: root, files: { "CLAUDE.md": "a".repeat(64) }, settings: [] }), + ); + const result = run(root, ["--claude-config-dir", config]); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).installed.claude.files[0].state).toBe("symlink"); + }); + it("rejects symlinked source and installed parent directories without following them", () => { + const { root } = fixture(); + const privateFile = join(root, "private-file"); + writeFileSync(privateFile, "synthetic-private-bytes-must-not-print"); + rmSync(join(root, "AGENTS.md")); + symlinkSync(privateFile, join(root, "AGENTS.md")); + const result = run(root); + expect(result.status).toBe(1); + expect( + JSON.parse(result.stdout).source.files.find((file: { path: string }) => file.path === "AGENTS.md").state, + ).toBe("symlink"); + expect(result.stdout).not.toContain("synthetic-private-bytes-must-not-print"); + const installed = join(root, "installed"); + symlinkSync(join(root, ".claude", "cloud-profile"), installed); + expect(run(root, ["--claude-config-dir", installed]).status).toBe(1); + }); + + it("labels legacy markerless files as unknown ownership and never treats markers as source proof", () => { + const { root } = fixture(); + const home = join(root, "installed"); + mkdirSync(home); + const profile = join(home, ".clinical-kb-codex-cloud.sh"); + writeFileSync(profile, "# Generated by scripts/setup-codex-cloud.sh.\nexit 99\n"); + const result = run(root, ["--installed-home", home]); + expect(result.status, result.stderr).toBe(0); + const artifact = JSON.parse(result.stdout).installed.codex.files.find( + (file: { path: string }) => file.path === "runtime-profile", + ); + expect(artifact).toMatchObject({ owner: "unknown", contentVerification: "unverified" }); + expect(readFileSync(profile, "utf8")).toContain("exit 99"); + }); +}); diff --git a/tests/cloud-build-resources.test.ts b/tests/cloud-build-resources.test.ts new file mode 100644 index 0000000000..6d7da59837 --- /dev/null +++ b/tests/cloud-build-resources.test.ts @@ -0,0 +1,186 @@ +import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +import { assessCloudBuildResources, discoverBuildMemory } from "../scripts/check-cloud-build-resources.mjs"; + +const GiB = 1024 ** 3; +function memory(files: Record, total = 64 * GiB, free = 60 * GiB) { + return discoverBuildMemory({ + readFile: (file: string) => { + if (!(file in files)) throw new Error("ENOENT"); + return files[file]; + }, + totalMemoryBytes: total, + freeMemoryBytes: free, + platform: "linux", + }); +} + +function v2(limit: string, current = String(GiB), extra: Record = {}) { + return { + "/proc/self/cgroup": "0::/job", + "/proc/self/mountinfo": "1 0 0:1 / /sys/fs/cgroup rw - cgroup2 cgroup rw", + "/sys/fs/cgroup/job/memory.max": limit, + "/sys/fs/cgroup/job/memory.current": current, + "/sys/fs/cgroup/memory.max": "max", + ...extra, + }; +} + +describe("cloud build resource preflight", () => { + it("uses cgroup v2 instead of the larger host and retains finite headroom", () => { + const discovered = memory(v2(String(8 * GiB))); + expect(discovered.limitBytes).toBe(8 * GiB); + const report = assessCloudBuildResources({ memory: discovered, inheritedHeapBytes: 8 * GiB }); + expect(report.ok).toBe(false); + expect(report.issues.join(" ")).toContain("12 GiB"); + }); + + it("accepts the recommended envelope when a 16 GiB cgroup has enough room", () => { + const report = assessCloudBuildResources({ + memory: memory(v2(String(16 * GiB))), + inheritedHeapBytes: 8 * GiB, + }); + expect(report.ok).toBe(true); + expect(report.requiredMemoryBytes).toBe(12 * GiB); + }); + + it("checks ancestor v2 limits and their usage independently", () => { + const discovered = memory( + v2("max", String(GiB), { + "/sys/fs/cgroup/memory.max": String(16 * GiB), + "/sys/fs/cgroup/memory.current": String(8 * GiB), + }), + ); + expect(discovered.limitBytes).toBe(16 * GiB); + expect(discovered.availableBytes).toBe(8 * GiB); + expect(assessCloudBuildResources({ memory: discovered, inheritedHeapBytes: 8 * GiB }).ok).toBe(false); + }); + + it("fails closed when a visible memory mount has unreadable process membership", () => { + const files: Record = v2("max"); + delete files["/proc/self/cgroup"]; + const report = assessCloudBuildResources({ memory: memory(files), inheritedHeapBytes: 8 * GiB }); + expect(report.ok).toBe(false); + expect(report.issues.join(" ")).toContain("membership"); + }); + + it("fails closed when visible memory mount membership is malformed", () => { + const report = assessCloudBuildResources({ + memory: memory(v2("max", String(GiB), { "/proc/self/cgroup": "not-a-membership-record" })), + inheritedHeapBytes: 8 * GiB, + }); + expect(report.ok).toBe(false); + expect(report.issues.join(" ")).toContain("membership"); + }); + + it("fails closed when an unlimited leaf has an unreadable visible ancestor limit", () => { + const files: Record = v2("max"); + delete files["/sys/fs/cgroup/memory.max"]; + const report = assessCloudBuildResources({ memory: memory(files), inheritedHeapBytes: 8 * GiB }); + expect(report.ok).toBe(false); + expect(report.issues.join(" ")).toContain("/sys/fs/cgroup/memory.max"); + }); + + it("rejects a missing leaf limit even when the visible parent is readable", () => { + const files = v2(String(16 * GiB), String(GiB), { + "/sys/fs/cgroup/memory.max": "max", + }); + delete (files as Record)["/sys/fs/cgroup/job/memory.max"]; + expect(assessCloudBuildResources({ memory: memory(files), inheritedHeapBytes: 8 * GiB }).ok).toBe(false); + }); + + it("uses inactive file cache as reclaimable cgroup memory, bounded by its current usage", () => { + const discovered = memory( + v2(String(16 * GiB), String(8 * GiB), { + "/sys/fs/cgroup/job/memory.stat": `inactive_file ${5 * GiB}\n`, + }), + ); + expect(discovered.availableBytes).toBe(13 * GiB); + }); + + it("handles cgroup v1 memory controllers at a non-root mount", () => { + const discovered = memory({ + "/proc/self/cgroup": "4:cpu,cpuacct:/other\n5:memory:/tenant/job", + "/proc/self/mountinfo": "1 0 0:1 /tenant /sys/fs/cgroup/memory rw - cgroup cgroup rw,memory", + "/sys/fs/cgroup/memory/job/memory.limit_in_bytes": String(16 * GiB), + "/sys/fs/cgroup/memory/job/memory.usage_in_bytes": String(2 * GiB), + "/sys/fs/cgroup/memory/memory.limit_in_bytes": "9223372036854771712", + }); + expect(discovered.limitBytes).toBe(16 * GiB); + expect(discovered.availableBytes).toBe(14 * GiB); + }); + + it.each(["max", "9223372036854771712"])("falls back to finite host memory for unlimited cgroups (%s)", (limit) => { + const discovered = memory(v2(limit), 16 * GiB, 13 * GiB); + expect(discovered.limitBytes).toBe(16 * GiB); + expect(discovered.availableBytes).toBe(13 * GiB); + expect(assessCloudBuildResources({ memory: discovered, inheritedHeapBytes: 8 * GiB }).ok).toBe(true); + }); + + it("uses host MemAvailable when available and caps it at physical memory", () => { + expect(memory({ "/proc/meminfo": `MemAvailable: ${14 * 1024 ** 2} kB\n` }, 16 * GiB, GiB).availableBytes).toBe( + 14 * GiB, + ); + }); + + it("fails closed when a finite cgroup limit has missing usage", () => { + const files = v2(String(16 * GiB)); + delete (files as Record)["/sys/fs/cgroup/job/memory.current"]; + const report = assessCloudBuildResources({ memory: memory(files), inheritedHeapBytes: 8 * GiB }); + expect(report.ok).toBe(false); + expect(report.issues.join(" ")).toContain("usage"); + }); + + it("fails closed on malformed cgroup limits rather than trusting host capacity", () => { + const report = assessCloudBuildResources({ memory: memory(v2("broken")), inheritedHeapBytes: 8 * GiB }); + expect(report.ok).toBe(false); + expect(report.issues.join(" ")).toContain("limit"); + }); + + it("reports low inherited heap without overwriting an explicit user setting", () => { + const report = assessCloudBuildResources({ memory: memory(v2(String(16 * GiB))), inheritedHeapBytes: 4 * GiB }); + expect(report.ok).toBe(false); + expect(report.issues.join(" ")).toContain("NODE_OPTIONS"); + }); + + it("reserves more room when an explicit inherited heap is larger than the recommendation", () => { + const report = assessCloudBuildResources({ memory: memory(v2(String(16 * GiB))), inheritedHeapBytes: 14 * GiB }); + expect(report.ok).toBe(false); + expect(report.requiredMemoryBytes).toBe(18 * GiB); + }); + + it("retains fractional heap sizes when calculating headroom", () => { + const report = assessCloudBuildResources({ memory: memory(v2(String(16 * GiB))), inheritedHeapBytes: 12.5 * GiB }); + expect(report.ok).toBe(false); + expect(report.requiredMemoryBytes).toBe(16.5 * GiB); + }); + + it("probes inherited NODE_OPTIONS instead of assuming parent-only flags reach workers", () => { + const options = "--stack-trace-limit=77 --max_old_space_size=4096"; + const originalOptions = process.env.NODE_OPTIONS; + const result = spawnSync( + process.execPath, + ["--max-old-space-size=8192", "scripts/check-cloud-build-resources.mjs", "--json"], + { + cwd: process.cwd(), + env: { ...process.env, NODE_OPTIONS: options }, + encoding: "utf8", + }, + ); + const report = JSON.parse(result.stdout); + expect(result.status).toBe(1); + expect(report.inheritedHeapBytes).toBeGreaterThanOrEqual(4 * GiB); + expect(report.inheritedHeapBytes).toBeLessThan(5 * GiB); + expect(report.issues.join(" ")).toContain("NODE_OPTIONS"); + expect(process.env.NODE_OPTIONS).toBe(originalOptions); + }); + + it("rejects unknown flags without starting a build", () => { + const result = spawnSync(process.execPath, ["scripts/check-cloud-build-resources.mjs", "--skip-checks"], { + encoding: "utf8", + }); + expect(result.status).toBe(2); + expect(result.stderr).toContain("Usage:"); + }); +}); diff --git a/tests/cloud-isolated-home.test.ts b/tests/cloud-isolated-home.test.ts new file mode 100644 index 0000000000..cb215c4ff5 --- /dev/null +++ b/tests/cloud-isolated-home.test.ts @@ -0,0 +1,109 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const repo = join(import.meta.dirname, ".."); +const wrapper = join(repo, "scripts", "with-isolated-cloud-home.sh"); +const temporary: string[] = []; +function fixture() { + const path = mkdtempSync(join(tmpdir(), "cloud-isolation-")); + temporary.push(path); + return path; +} +function run( + root: string, + command: string[] = [ + process.execPath, + "-e", + "console.log(JSON.stringify({home:process.env.HOME,codex:process.env.CODEX_HOME,claude:process.env.CLAUDE_CONFIG_DIR,proxy:process.env.HTTPS_PROXY,ca:process.env.SSL_CERT_FILE}))", + ], +) { + return spawnSync("bash", [wrapper, "--root", root, "--", ...command], { + cwd: repo, + encoding: "utf8", + timeout: 10_000, + env: { + ...process.env, + HTTPS_PROXY: "http://synthetic.invalid:8080", + NODE_EXTRA_CA_CERTS: undefined, + SSL_CERT_FILE: "/synthetic/host-ca.pem", + GH_TOKEN: "synthetic-inherited-token", + }, + }); +} +afterEach(() => { + for (const root of temporary.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("explicit isolated Cloud subprocess", () => { + it("scopes three homes, preserves proxy/CA policy, and leaves parent values unchanged", () => { + const root = join(fixture(), "scope"); + const oldHome = process.env.HOME; + const result = run(root); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + home: join(root, "home"), + codex: join(root, "codex"), + claude: join(root, "claude"), + proxy: "http://synthetic.invalid:8080", + ca: "/synthetic/host-ca.pem", + }); + expect(process.env.HOME).toBe(oldHome); + expect(readdirSync(join(root, "home"))).toEqual([]); + expect(run(root, ["bash", "-c", "exit 17"]).status).toBe(17); + expect( + run(root, [process.execPath, "-e", "process.exit(process.env.GH_TOKEN === 'synthetic-inherited-token' ? 0 : 1)"]) + .status, + ).toBe(0); + }); + it("preserves child standard input", () => { + const root = join(fixture(), "scope"); + const result = spawnSync("bash", [wrapper, "--root", root, "--", "cat"], { + cwd: repo, + encoding: "utf8", + timeout: 10_000, + input: "synthetic child input\n", + }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe("synthetic child input\n"); + }); + it("refuses unowned existing directories and never runs the child on failure", () => { + const parent = fixture(); + const root = join(parent, "unowned"); + mkdirSync(root); + writeFileSync(join(root, "personal"), "preserve"); + const sentinel = join(parent, "child-ran"); + expect(run(root, ["touch", sentinel]).status).not.toBe(0); + expect(readdirSync(root)).toEqual(["personal"]); + expect(readFileSync(join(root, "personal"), "utf8")).toBe("preserve"); + expect(readdirSync(parent)).not.toContain("child-ran"); + }); + it("refuses symlink roots, parent components, and replacement config homes", () => { + const parent = fixture(); + const real = join(parent, "real"); + mkdirSync(real); + const link = join(parent, "link"); + symlinkSync(real, link); + expect(run(link).status).not.toBe(0); + expect(run(join(link, "scope")).status).not.toBe(0); + expect(readdirSync(real)).toEqual([]); + const root = join(parent, "owned"); + expect(run(root).status).toBe(0); + rmSync(join(root, "codex"), { recursive: true }); + symlinkSync(real, join(root, "codex")); + expect(run(root).status).not.toBe(0); + expect(readdirSync(real)).toEqual([]); + }); + it("binds directory ownership to the physical checkout", () => { + const parent = fixture(); + const root = join(parent, "owned"); + expect(run(root).status).toBe(0); + const marker = join(root, ".psychsift-cloud-scope.json"); + const ownership = JSON.parse(readFileSync(marker, "utf8")); + ownership.checkout = "/another-checkout"; + writeFileSync(marker, JSON.stringify(ownership)); + expect(run(root).status).not.toBe(0); + }); +}); diff --git a/tests/cloud-platform-smoke.test.ts b/tests/cloud-platform-smoke.test.ts new file mode 100644 index 0000000000..a49db7c9ab --- /dev/null +++ b/tests/cloud-platform-smoke.test.ts @@ -0,0 +1,150 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runBootstrapSmoke } from "../scripts/check-cloud-platform-smoke.mjs"; +import { getPlaywrightBaseUrl } from "../scripts/playwright-base-url"; +import { localProjectRequestIdentityPayload } from "../src/lib/local-project-guard"; +import { appName, localProjectId } from "../src/lib/local-server-utils.mjs"; + +afterEach(() => vi.unstubAllEnvs()); + +describe("cloud platform acceptance fixtures", () => { + it("executes the bootstrap in a real shell and isolated Git worktree containing spaces and apostrophes", () => { + const result = runBootstrapSmoke(); + expect(result).toMatchObject({ + platform: process.platform, + missingDependencies: true, + donorCopied: true, + hooksInstalled: true, + existingInstall: true, + mismatchedDonorRejected: true, + }); + }, 30_000); + + it.each([ + { + name: "matching project", + app: appName, + project: localProjectId(path.resolve(__dirname, "..")), + safe: true, + accepted: true, + }, + { + name: "other application", + app: "Other app", + project: localProjectId(path.resolve(__dirname, "..")), + safe: true, + accepted: false, + }, + { name: "other checkout", app: appName, project: "clinical-kb:another-checkout", safe: true, accepted: false }, + { + name: "unsafe local origin", + app: appName, + project: localProjectId(path.resolve(__dirname, "..")), + safe: false, + accepted: false, + }, + ])( + "checks actual HTTP identity through a synthetic forwarding proxy: $name", + async ({ app, project, safe, accepted }) => { + // A separate process serves the fixture because the real URL helper uses a synchronous HTTP child. + const child = spawn( + process.execPath, + [ + "--input-type=module", + "-e", + ` + import http from "node:http"; + const payload = JSON.parse(process.env.SMOKE_IDENTITY); + const backend = http.createServer((request, response) => { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify(request.url === "/api/local-project-id" ? payload : {})); + }); + backend.listen(0, "127.0.0.1", () => { + const proxy = http.createServer((request, response) => { + const upstream = http.get({hostname:"127.0.0.1", port:backend.address().port, path:request.url}, remote => { + response.writeHead(remote.statusCode, remote.headers); remote.pipe(response); + }); + upstream.on("error", () => { response.writeHead(502); response.end(); }); + }); + proxy.listen(0, "127.0.0.1", () => console.log(proxy.address().port)); + }); + `, + ], + { + env: { + ...process.env, + SMOKE_IDENTITY: JSON.stringify({ + appName: app, + projectId: project, + localServer: { safeLocalOrigin: safe }, + }), + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + try { + const port = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Synthetic forwarding fixture did not start")), 5_000); + child.once("error", reject); + child.stdout.once("data", (value: Buffer) => { + clearTimeout(timer); + resolve(value.toString().trim()); + }); + }); + vi.stubEnv("PLAYWRIGHT_BASE_URL", `http://127.0.0.1:${port}`); + if (accepted) expect(getPlaywrightBaseUrl({ allowEnsure: false })).toBe(`http://127.0.0.1:${port}`); + else expect(() => getPlaywrightBaseUrl({ allowEnsure: false })).toThrow(/local-project-id guard/); + } finally { + child.kill(); + await new Promise((resolve) => child.once("exit", () => resolve())); + } + }, + ); + + it("keeps forwarded request headers separate from the identity URL and rejects an unsafe local caller", () => { + const forwarded = { "x-forwarded-host": "preview.invalid", "x-forwarded-proto": "https" }; + const managed = localProjectRequestIdentityPayload( + new Request("http://127.0.0.1:4383/api/local-project-id", { headers: forwarded }), + ); + expect(managed.localServer).toMatchObject({ + currentUrl: "http://127.0.0.1:4383", + currentPort: 4383, + safeLocalOrigin: true, + }); + const unsafe = localProjectRequestIdentityPayload( + new Request("http://127.0.0.1:4383/api/local-project-id", { + headers: { ...forwarded, origin: "http://localhost:3000" }, + }), + ); + expect(unsafe.localServer).toMatchObject({ unsafeLocalCaller: "http://localhost:3000", safeLocalOrigin: false }); + expect( + localProjectRequestIdentityPayload( + new Request("http://127.0.0.1:3000/api/local-project-id", { headers: forwarded }), + ).localServer.safeLocalOrigin, + ).toBe(false); + }); + + it.each(["https://preview.invalid", "https://preview.invalid:8443", "https://192.0.2.1", "https://localhost."])( + "documents HTTPS opt-in acceptance without claiming identity verification: %s", + (url) => { + vi.stubEnv("PLAYWRIGHT_BASE_URL", url); + vi.stubEnv("ALLOW_PREVIEW_URL", "true"); + expect(getPlaywrightBaseUrl({ allowEnsure: false })).toBe(url); + }, + ); + + it.each([ + "https://localhost:4383", + "https://127.0.0.1:4383", + "https://[::1]:4383", + "http://preview.invalid", + "https://preview.invalid/path", + "https://user:pass@preview.invalid", + "https://preview.invalid/?token=1", + ])("refuses unsupported preview origins: %s", (url) => { + vi.stubEnv("PLAYWRIGHT_BASE_URL", url); + vi.stubEnv("ALLOW_PREVIEW_URL", "true"); + expect(() => getPlaywrightBaseUrl({ allowEnsure: false })).toThrow(); + }); +}); diff --git a/tests/cross-repo-cloud-contracts.test.ts b/tests/cross-repo-cloud-contracts.test.ts new file mode 100644 index 0000000000..5d9529edd7 --- /dev/null +++ b/tests/cross-repo-cloud-contracts.test.ts @@ -0,0 +1,214 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + checkCloudContracts, + inspectCheckout, + repositoryMatrix, +} from "../scripts/check-cross-repo-cloud-contracts.mjs"; + +const temporaryRoots: string[] = []; +afterEach(() => temporaryRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))); +const sourceRoot = path.resolve(import.meta.dirname, ".."); +const script = path.join(sourceRoot, "scripts/check-cross-repo-cloud-contracts.mjs"); + +function fixture() { + // Small synthetic surfaces exercise the actual shared contracts; no copied global policy. + return { + "AGENTS.md": + "# Yarn\n[portable workflow](docs/portable-workflow.md)\nNo external memory namespace is approved for Yarn.", + "CLAUDE.md": "@AGENTS.md\nA client-specific orientation can differ.", + "GEMINI.md": "Read [project instructions](AGENTS.md) before work.", + "README.md": "# Yarn\n[portable workflow](docs/portable-workflow.md)", + "docs/portable-workflow.md": "Use direct Node entry before setup. Native loading requires a fresh session.", + "package.json": JSON.stringify({ + name: "workspace", + packageManager: "pnpm@11.1.1", + engines: { node: ">=24.16.0 <25" }, + scripts: { + "check:checkout": "node scripts/portable-workflow.mjs identity", + "preview:cloud": "node scripts/portable-workflow.mjs run", + "preview:guard": "node scripts/portable-workflow.mjs guard", + }, + }), + "pnpm-lock.yaml": "lockfileVersion: '9.0'", + "pnpm-workspace.yaml": "verifyDepsBeforeRun: error", + "scripts/portable-workflow.mjs": `export const config = { repository: "BigSimmo/Yarn", port: 24443 }; + export function assertCheckout() { return realpathSync(git(["rev-parse", "--show-toplevel"])); } + export function validateReply(reply, state, challenge) { + if (reply.challenge !== challenge || reply.pid !== state.pid || reply.port !== state.port || reply.rootId !== state.rootId) throw Error("identity"); + } + const policy = { npm_config_verify_deps_before_run: "error", pnpm_config_verify_deps_before_run: "error" }; + const option = "--config.verify-deps-before-run=error";`, + "scripts/portable-preview.mjs": "// Synthetic server surface; never executed by the checker.", + }; +} + +function failures(files: Record, repo = "Yarn") { + return checkCloudContracts(repo, (file: string) => files[file] ?? null); +} + +function checkout(files: Record) { + const root = mkdtempSync(path.join(os.tmpdir(), "cloud-contracts-")); + temporaryRoots.push(root); + for (const [file, content] of Object.entries(files)) { + mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + writeFileSync(path.join(root, file), content); + } + const env = { + NODE_ENV: "test" as const, + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot, + GIT_CONFIG_GLOBAL: os.devNull, + GIT_CONFIG_NOSYSTEM: "1", + }; + const git = (...args: string[]) => { + const result = spawnSync("git", args, { cwd: root, env, encoding: "utf8" }); + expect(result.status, result.stderr).toBe(0); + return result.stdout.trim(); + }; + git("init", "--quiet"); + git("config", "user.name", "Synthetic fixture"); + git("config", "user.email", "fixture@example.invalid"); + git("remote", "add", "origin", "https://github.com/BigSimmo/Yarn.git"); + git("add", "."); + git("-c", "core.hooksPath=/dev/null", "commit", "--quiet", "-m", "Synthetic fixture"); + return { root, git, revision: git("rev-parse", "HEAD") }; +} + +describe("cross-repository Cloud contracts", () => { + it("maintains six active repository adapters and an explicitly inactive backup", () => { + expect(repositoryMatrix.map((entry: { name: string }) => entry.name)).toEqual([ + "PsychSift", + "Communication", + "Medications", + "Ward-Flow", + "Yarn", + "caring-contacts", + "ward-flow-backup", + ]); + expect(repositoryMatrix.filter((entry: { active: boolean }) => entry.active)).toHaveLength(6); + }); + + it("accepts different native adapter prose and markdown Gemini entry links", () => { + expect(failures(fixture())).toEqual([]); + }); + + it.each(["AGENTS.md", "CLAUDE.md", "GEMINI.md", "pnpm-lock.yaml", "scripts/portable-preview.mjs"])( + "fails closed when %s is missing", + (file) => { + const files: Record = fixture(); + delete files[file]; + expect(failures(files).some((failure: { file: string }) => failure.file === file)).toBe(true); + }, + ); + + it("catches broken rule imports without requiring adapter equality", () => { + const files = fixture(); + files["GEMINI.md"] = "@../PsychSift/AGENTS.md"; + expect(failures(files)).toContainEqual(expect.objectContaining({ code: "native-entry", file: "GEMINI.md" })); + }); + + it("catches bootstrap alias drift, missing targets and implicit-install regression", () => { + const files = fixture(); + files["package.json"] = files["package.json"].replace("portable-workflow.mjs guard", "missing-workflow.mjs guard"); + files["pnpm-workspace.yaml"] = "verifyDepsBeforeRun: install"; + expect(failures(files).map((failure: { code: string }) => failure.code)).toEqual( + expect.arrayContaining(["bootstrap-alias", "implicit-install"]), + ); + }); + + it("detects loss of preview challenge ownership checks", () => { + const files = fixture(); + files["scripts/portable-workflow.mjs"] = files["scripts/portable-workflow.mjs"].replace( + "reply.pid !== state.pid", + "true", + ); + expect(failures(files)).toContainEqual(expect.objectContaining({ code: "preview-ownership" })); + }); + + it("keeps the backup historical and refuses to treat it as a runnable app", () => { + const files = { + "README.md": + "Backup destination, not a runnable application or Cloud setup target. See https://github.com/BigSimmo/Ward-Flow. Historical snapshot.", + }; + expect(failures(files, "ward-flow-backup")).toEqual([]); + expect(failures({ ...files, "package.json": "{}" }, "ward-flow-backup")).toContainEqual( + expect.objectContaining({ code: "backup-activation" }), + ); + }); + + it("catches legacy profile migration losing its explicit-selection guard", () => { + const read = (file: string) => { + try { + return readFileSync(path.join(sourceRoot, file), "utf8"); + } catch { + return null; + } + }; + expect(checkCloudContracts("PsychSift", read)).toEqual([]); + expect( + checkCloudContracts("PsychSift", (file: string) => + file === "scripts/maintain-codex-cloud.sh" + ? read(file)?.replace('[[ "$requested_cloud_profile_set" = yes ]] ||', "true ||") + : read(file), + ), + ).toContainEqual(expect.objectContaining({ code: "legacy-migration-selection" })); + }); + + it("reads immutable selected git blobs while identifying dirty working-tree evidence separately", () => { + const { root, revision } = checkout(fixture()); + writeFileSync(path.join(root, "GEMINI.md"), "No project import"); + expect(inspectCheckout("Yarn", root, revision)).toMatchObject({ + revision, + source: "git-blobs", + status: "pass", + failures: [], + }); + expect(inspectCheckout("Yarn", root)).toMatchObject({ + revision, + source: "working-tree", + dirty: true, + status: "fail", + }); + }); + + it("rejects a wrong checkout origin and a missing pinned revision", () => { + const { root, git } = checkout(fixture()); + expect(inspectCheckout("Yarn", root, "0".repeat(40))).toMatchObject({ status: "unverified" }); + git("remote", "set-url", "origin", "https://github.com/BigSimmo/Medications.git"); + expect(inspectCheckout("Yarn", root)).toMatchObject({ status: "unverified" }); + }); + + it("CLI returns exact pinned revision and nonzero for an unavailable requested repo", () => { + const { root, revision } = checkout(fixture()); + const result = spawnSync( + process.execPath, + [script, "--repo", "Yarn", "--root", root, "--revision", revision, "--json"], + { encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout).results[0]).toMatchObject({ revision, status: "pass" }); + const unavailable = spawnSync( + process.execPath, + [script, "--repo", "Yarn", "--root", path.join(root, "absent"), "--json"], + { encoding: "utf8" }, + ); + expect(unavailable.status).toBe(1); + expect(JSON.parse(unavailable.stdout).results[0].status).toBe("unverified"); + }); + + it("CI scopes PR checks locally and makes unauthenticated multi-repo fetch opt-in", () => { + const workflow = readFileSync(path.join(sourceRoot, ".github/workflows/cloud-contracts.yml"), "utf8"); + expect(workflow).toContain("contents: read"); + expect(workflow).toContain("persist-credentials: false"); + expect(workflow).toContain("github.event_name == 'workflow_dispatch'"); + expect(workflow).toContain("--fetch-maintained"); + expect(workflow).toContain("--root ."); + expect(workflow).not.toMatch(/pull_request_target|secrets\.|npm (?:ci|install)|continue-on-error/); + expect(workflow).toContain("scripts/check-cross-repo-cloud-contracts.mjs"); + }); +}); From 980b5cb5b77e05a15e14dcbd2cf6bcde9ea94347 Mon Sep 17 00:00:00 2001 From: "qodo-code-review[bot]" <151058649+qodo-code-review[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:47:02 +0000 Subject: [PATCH 2/7] =?UTF-8?q?fix:=203=20findings=20=E2=80=94=20Fail=20cl?= =?UTF-8?q?osed=20when=20cgroup=20discovery=20is=20unknown;=20Skip=20con?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fail closed when cgroup discovery is unknown - Skip controller-less hybrid cgroup mounts - Parse marked heap probe output --- scripts/check-cloud-build-resources.mjs | 30 +++++++++++++++++++------ tests/cloud-build-resources.test.ts | 23 +++++++++++++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/scripts/check-cloud-build-resources.mjs b/scripts/check-cloud-build-resources.mjs index 0e46412f2e..4f900fe343 100644 --- a/scripts/check-cloud-build-resources.mjs +++ b/scripts/check-cloud-build-resources.mjs @@ -56,13 +56,28 @@ export function discoverBuildMemory({ const membership = platform === "linux" ? readOptional(readFile, "/proc/self/cgroup") : null; const mounts = platform === "linux" ? readOptional(readFile, "/proc/self/mountinfo") : null; - if (membership && !mounts) issues.push("Cgroup membership exists but its memory mount could not be inspected."); + if (platform === "linux" && !membership) issues.push("Cgroup membership could not be determined."); + if (platform === "linux" && !mounts) issues.push("Cgroup mount information could not be determined."); for (const line of mounts?.split("\n") ?? []) { + if (!line.trim()) continue; const [left, right] = line.split(" - "); - if (!right) continue; + if (!right) { + issues.push("Cgroup mount information contains an unparseable record."); + continue; + } const [type, , options] = right.split(" "); const v2 = type === "cgroup2"; if (!v2 && !(type === "cgroup" && options?.split(",").includes("memory"))) continue; + const mountFields = left.split(" "); + const mountPoint = unescapeMount(mountFields[4] ?? ""); + if (v2) { + const controllers = readOptional(readFile, path.posix.join(mountPoint, "cgroup.controllers")); + if (controllers === null) { + issues.push(`Cgroup v2 controllers could not be read at ${path.posix.join(mountPoint, "cgroup.controllers")}.`); + continue; + } + if (!controllers.split(/\s+/).includes("memory")) continue; + } const entry = membership?.split("\n").find((item) => { const record = item.match(/^(\d+):([^:]*):(\/.*)$/); if (!record) return false; @@ -73,9 +88,7 @@ export function discoverBuildMemory({ continue; } const groupPath = entry.slice(entry.indexOf(":", entry.indexOf(":") + 1) + 1); - const mountFields = left.split(" "); - const mountRoot = unescapeMount(mountFields[3]); - const mountPoint = unescapeMount(mountFields[4]); + const mountRoot = unescapeMount(mountFields[3] ?? ""); // A cgroup namespace may expose / even when mountinfo's root is outside it. const relative = groupPath === "/" ? "" : path.posix.relative(mountRoot, groupPath); if (relative === ".." || relative.startsWith("../")) { @@ -160,9 +173,10 @@ export function assessCloudBuildResources({ memory, inheritedHeapBytes }) { function inheritedHeap() { // Deliberately omit parent execArgv: a flag applied only to this script proves // nothing about the environment inherited by Next.js subprocesses. + const marker = "__PSYCHSIFT_HEAP_LIMIT__:"; const result = spawnSync( process.execPath, - ["-e", 'process.stdout.write(String(require("node:v8").getHeapStatistics().heap_size_limit))'], + ["-e", `process.stdout.write(${JSON.stringify(marker)} + String(require("node:v8").getHeapStatistics().heap_size_limit))`], { env: process.env, encoding: "utf8", @@ -170,7 +184,9 @@ function inheritedHeap() { maxBuffer: 64 * 1024, }, ); - return result.status === 0 ? nonnegativeBytes(result.stdout.trim()) : null; + const measurement = + typeof result.stdout === "string" ? result.stdout.match(new RegExp(`${marker}(\\d+)`))?.[1] : undefined; + return result.status === 0 ? nonnegativeBytes(measurement) : null; } if (isDirectEntrypoint(import.meta.url)) { diff --git a/tests/cloud-build-resources.test.ts b/tests/cloud-build-resources.test.ts index 6d7da59837..93ea4ca15b 100644 --- a/tests/cloud-build-resources.test.ts +++ b/tests/cloud-build-resources.test.ts @@ -20,6 +20,7 @@ function v2(limit: string, current = String(GiB), extra: Record return { "/proc/self/cgroup": "0::/job", "/proc/self/mountinfo": "1 0 0:1 / /sys/fs/cgroup rw - cgroup2 cgroup rw", + "/sys/fs/cgroup/cgroup.controllers": "memory pids", "/sys/fs/cgroup/job/memory.max": limit, "/sys/fs/cgroup/job/memory.current": current, "/sys/fs/cgroup/memory.max": "max", @@ -74,6 +75,28 @@ describe("cloud build resource preflight", () => { expect(report.issues.join(" ")).toContain("membership"); }); + it("fails closed when cgroup discovery is unavailable despite sufficient host memory", () => { + const report = assessCloudBuildResources({ + memory: memory({ "/proc/meminfo": `MemAvailable: ${60 * 1024 ** 2} kB\n` }), + inheritedHeapBytes: 8 * GiB, + }); + expect(report.ok).toBe(false); + expect(report.issues.join(" ")).toContain("could not be determined"); + }); + + it("skips a controller-less cgroup v2 mount on a hybrid host", () => { + const discovered = memory({ + "/proc/self/cgroup": "5:memory:/job\n0::/", + "/proc/self/mountinfo": + "1 0 0:1 / /sys/fs/cgroup/memory rw - cgroup cgroup rw,memory\n2 0 0:2 / /sys/fs/cgroup/unified rw - cgroup2 cgroup rw", + "/sys/fs/cgroup/unified/cgroup.controllers": "pids", + "/sys/fs/cgroup/memory/job/memory.limit_in_bytes": String(16 * GiB), + "/sys/fs/cgroup/memory/job/memory.usage_in_bytes": String(2 * GiB), + "/sys/fs/cgroup/memory/memory.limit_in_bytes": "max", + }); + expect(assessCloudBuildResources({ memory: discovered, inheritedHeapBytes: 8 * GiB }).ok).toBe(true); + }); + it("fails closed when an unlimited leaf has an unreadable visible ancestor limit", () => { const files: Record = v2("max"); delete files["/sys/fs/cgroup/memory.max"]; From 0a6c7364a77d6b9be044901692245f7f33fbe363 Mon Sep 17 00:00:00 2001 From: "qodo-code-review[bot]" <151058649+qodo-code-review[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:47:05 +0000 Subject: [PATCH 3/7] fix: Handle already-exited proxy children --- tests/cloud-platform-smoke.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/cloud-platform-smoke.test.ts b/tests/cloud-platform-smoke.test.ts index a49db7c9ab..193523e1c1 100644 --- a/tests/cloud-platform-smoke.test.ts +++ b/tests/cloud-platform-smoke.test.ts @@ -96,8 +96,14 @@ describe("cloud platform acceptance fixtures", () => { if (accepted) expect(getPlaywrightBaseUrl({ allowEnsure: false })).toBe(`http://127.0.0.1:${port}`); else expect(() => getPlaywrightBaseUrl({ allowEnsure: false })).toThrow(/local-project-id guard/); } finally { - child.kill(); - await new Promise((resolve) => child.once("exit", () => resolve())); + await new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + child.once("exit", () => resolve()); + child.kill(); + }); } }, ); From 6540f6a20aa470c80d3194b2825d6b2e79564220 Mon Sep 17 00:00:00 2001 From: "qodo-code-review[bot]" <151058649+qodo-code-review[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:47:10 +0000 Subject: [PATCH 4/7] fix: Recognize installer marker prefixes --- scripts/cloud-activation-evidence.mjs | 2 +- tests/cloud-activation-evidence.test.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/cloud-activation-evidence.mjs b/scripts/cloud-activation-evidence.mjs index 886f71a157..012e4ca5b3 100755 --- a/scripts/cloud-activation-evidence.mjs +++ b/scripts/cloud-activation-evidence.mjs @@ -124,7 +124,7 @@ function codexEvidence(home, configHome) { let generatedMarker = false; if (bytes) { const lines = bytes.toString("utf8").split("\n"); - generatedMarker = lines.includes(marker); + generatedMarker = lines.some((line) => line.startsWith(marker)); const owners = lines.filter((line) => line.startsWith("# PsychSift checkout: ")); if (owners.length) owner = diff --git a/tests/cloud-activation-evidence.test.ts b/tests/cloud-activation-evidence.test.ts index aace056a4d..d0b3035107 100644 --- a/tests/cloud-activation-evidence.test.ts +++ b/tests/cloud-activation-evidence.test.ts @@ -136,13 +136,17 @@ describe("read-only Cloud activation evidence", () => { const home = join(root, "installed"); mkdirSync(home); const profile = join(home, ".clinical-kb-codex-cloud.sh"); - writeFileSync(profile, "# Generated by scripts/setup-codex-cloud.sh.\nexit 99\n"); + writeFileSync(profile, "# Generated by scripts/setup-codex-cloud.sh. Re-running setup replaces this file.\nexit 99\n"); const result = run(root, ["--installed-home", home]); expect(result.status, result.stderr).toBe(0); const artifact = JSON.parse(result.stdout).installed.codex.files.find( (file: { path: string }) => file.path === "runtime-profile", ); - expect(artifact).toMatchObject({ owner: "unknown", contentVerification: "unverified" }); + expect(artifact).toMatchObject({ + generatedMarker: true, + owner: "unknown", + contentVerification: "unverified", + }); expect(readFileSync(profile, "utf8")).toContain("exit 99"); }); }); From 60d209759541a47bebd31e742aa6e456f1e2e0c2 Mon Sep 17 00:00:00 2001 From: Joshua Simpson Date: Sat, 17 Oct 2026 14:52:48 +0000 Subject: [PATCH 5/7] Refresh Cloud guide snapshot and format external fixes --- data/repo-awareness-snapshot.json | 15 +++++++++++++++ scripts/check-cloud-build-resources.mjs | 5 ++++- tests/cloud-activation-evidence.test.ts | 5 ++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index a2bed8ed72..8a3f3227d7 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -3943,6 +3943,11 @@ "section": "root", "catalogued": true }, + { + "path": "docs/agents/cross-repo-cloud-contracts.md", + "section": "agents", + "catalogued": true + }, { "path": "docs/medication-lexicon-review-worklist.md", "section": "root", @@ -4208,6 +4213,11 @@ "section": "prompts", "catalogued": true }, + { + "path": "docs/agents/cloud-platform-acceptance.md", + "section": "agents", + "catalogued": true + }, { "path": "docs/agents/claude-hook-scripts.md", "section": "agents", @@ -5233,6 +5243,11 @@ "section": "decisions", "catalogued": true }, + { + "path": "docs/agents/cloud-build-resources.md", + "section": "agents", + "catalogued": true + }, { "path": "docs/superpowers/rag-upgrade/cloud/plans/2026-08-20-rag-adaptive-answer.md", "section": "superpowers", diff --git a/scripts/check-cloud-build-resources.mjs b/scripts/check-cloud-build-resources.mjs index 4f900fe343..d98d2187a9 100644 --- a/scripts/check-cloud-build-resources.mjs +++ b/scripts/check-cloud-build-resources.mjs @@ -176,7 +176,10 @@ function inheritedHeap() { const marker = "__PSYCHSIFT_HEAP_LIMIT__:"; const result = spawnSync( process.execPath, - ["-e", `process.stdout.write(${JSON.stringify(marker)} + String(require("node:v8").getHeapStatistics().heap_size_limit))`], + [ + "-e", + `process.stdout.write(${JSON.stringify(marker)} + String(require("node:v8").getHeapStatistics().heap_size_limit))`, + ], { env: process.env, encoding: "utf8", diff --git a/tests/cloud-activation-evidence.test.ts b/tests/cloud-activation-evidence.test.ts index d0b3035107..7c6a88f45c 100644 --- a/tests/cloud-activation-evidence.test.ts +++ b/tests/cloud-activation-evidence.test.ts @@ -136,7 +136,10 @@ describe("read-only Cloud activation evidence", () => { const home = join(root, "installed"); mkdirSync(home); const profile = join(home, ".clinical-kb-codex-cloud.sh"); - writeFileSync(profile, "# Generated by scripts/setup-codex-cloud.sh. Re-running setup replaces this file.\nexit 99\n"); + writeFileSync( + profile, + "# Generated by scripts/setup-codex-cloud.sh. Re-running setup replaces this file.\nexit 99\n", + ); const result = run(root, ["--installed-home", home]); expect(result.status, result.stderr).toBe(0); const artifact = JSON.parse(result.stdout).installed.codex.files.find( From b8e5dd336f9da4a3c5e4ef7f43916c1eebbab337 Mon Sep 17 00:00:00 2001 From: Joshua Simpson Date: Sat, 17 Oct 2026 15:12:04 +0000 Subject: [PATCH 6/7] Make Cloud acceptance fixtures deterministic in CI --- .github/workflows/ci.yml | 16 ++++++ .github/workflows/cloud-contracts.yml | 1 + .github/workflows/cloud-platform-smoke.yml | 1 + docs/agents/cloud-platform-acceptance.md | 9 ++++ scripts/check-cloud-platform-smoke.mjs | 16 +++++- tests/cloud-activation-evidence.test.ts | 3 +- tests/cloud-isolated-home.test.ts | 5 +- tests/cloud-platform-smoke.test.ts | 59 +++++++++++++++++++++- tests/cross-repo-cloud-contracts.test.ts | 6 ++- 9 files changed, 110 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 250fb2abee..398cc412e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -733,6 +733,20 @@ jobs: - name: Setup Node and dependencies uses: ./.github/actions/setup-node-cached + - name: Stage declared npm for Cloud bootstrap acceptance fixtures + id: cloud-platform-npm + shell: bash + run: | + package_manager=$(node -p 'require("./package.json").packageManager') + if [[ ! "$package_manager" =~ ^npm@([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "Cloud bootstrap fixtures require an exact npm packageManager version" >&2 + exit 1 + fi + bootstrap_npm_version="${BASH_REMATCH[1]}" + bootstrap_npm_home="$RUNNER_TEMP/cloud-platform-npm" + npm install --prefix "$bootstrap_npm_home/npm" --ignore-scripts --no-save --package-lock=false --no-audit --no-fund "npm@$bootstrap_npm_version" + echo "home=$bootstrap_npm_home" >> "$GITHUB_OUTPUT" + - name: Install deterministic Python PDF test prerequisite run: python3 -m pip install --disable-pip-version-check "PyMuPDF==1.28.0" @@ -743,6 +757,8 @@ jobs: # worker that never exits) is what this catches: the step now fails at 13 minutes instead, # 7 minutes sooner, with the same red result. Nothing is skipped or retried. timeout-minutes: 13 + env: + CLOUD_PLATFORM_NPM_HOME: ${{ steps.cloud-platform-npm.outputs.home }} run: >- node scripts/run-vitest.mjs run --config=vitest.coverage-shard.config.mts --coverage --shard=${{ matrix.shard }}/2 --reporter=default --reporter=blob diff --git a/.github/workflows/cloud-contracts.yml b/.github/workflows/cloud-contracts.yml index b537fde603..aa905d52b2 100644 --- a/.github/workflows/cloud-contracts.yml +++ b/.github/workflows/cloud-contracts.yml @@ -62,6 +62,7 @@ jobs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version-file: ".nvmrc" + package-manager-cache: false - name: Check this checkout's static Cloud contracts run: node scripts/check-cross-repo-cloud-contracts.mjs --root . # No dependencies, foreign scripts, tokens, provider clients or remote workflow execution. diff --git a/.github/workflows/cloud-platform-smoke.yml b/.github/workflows/cloud-platform-smoke.yml index 404fde5400..b91eae25fd 100644 --- a/.github/workflows/cloud-platform-smoke.yml +++ b/.github/workflows/cloud-platform-smoke.yml @@ -38,6 +38,7 @@ jobs: uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version-file: ".node-version" + package-manager-cache: false # Match the real bootstrap's npm lookup with an isolated job-local APPDATA. # No global install and no application dependency installation are needed. diff --git a/docs/agents/cloud-platform-acceptance.md b/docs/agents/cloud-platform-acceptance.md index 0267d332f6..e684276f9a 100644 --- a/docs/agents/cloud-platform-acceptance.md +++ b/docs/agents/cloud-platform-acceptance.md @@ -49,6 +49,15 @@ Separate request fixtures exercise the actual local-project guard with forwardin headers and an unsafe local caller. These tests do not build or attach to an app, open a public port, use a forwarding provider, or prove TLS/browser behavior. +The unit CI job stages the declared real npm below its runner temporary directory +and supplies `CLOUD_PLATFORM_NPM_HOME` only to the test step. The fixture maps that +home to `APPDATA` only for its bootstrap child, so the existing setup script finds +`npm/node_modules/npm/bin/npm-cli.js` before ambient npm. An explicitly missing or +invalid home fails; the setup script still enforces the repository's exact npm +version. `packageManager` metadata alone does not install that version. Local +callers can use an already installed matching npm on PATH or provide the same +home layout; the fixture installs no toolchain and changes no caller environment. + Current URL behavior in `scripts/playwright-base-url.ts` is: | Input | Acceptance and identity | diff --git a/scripts/check-cloud-platform-smoke.mjs b/scripts/check-cloud-platform-smoke.mjs index 65f8326367..8252c359cf 100644 --- a/scripts/check-cloud-platform-smoke.mjs +++ b/scripts/check-cloud-platform-smoke.mjs @@ -27,10 +27,24 @@ function gitBash() { /** Runs existing bootstrap code against tiny offline fixtures, never the checkout's dependency tree. */ export function runBootstrapSmoke() { const shell = gitBash(); + const npmHome = process.env.CLOUD_PLATFORM_NPM_HOME; + if (npmHome !== undefined) { + assert.ok( + npmHome && + path.isAbsolute(npmHome) && + existsSync(path.join(npmHome, "npm", "node_modules", "npm", "bin", "npm-cli.js")), + "CLOUD_PLATFORM_NPM_HOME must name an existing absolute home containing npm/node_modules/npm/bin/npm-cli.js", + ); + } const temporaryRoot = mkdtempSync(path.join(os.tmpdir(), "psychsift-platform-")); const donor = path.join(temporaryRoot, "donor checkout"); const target = path.join(temporaryRoot, "worktree's space"); - const shellEnvironment = { ...process.env, SMOKE_WORKTREE: target, SMOKE_NODE: process.execPath }; + const shellEnvironment = { + ...process.env, + ...(npmHome !== undefined ? { APPDATA: npmHome } : {}), + SMOKE_WORKTREE: target, + SMOKE_NODE: process.execPath, + }; const shellScript = `set -euo pipefail if [ -x /usr/bin/cygpath ]; then cd "$(/usr/bin/cygpath -u "$SMOKE_WORKTREE")" diff --git a/tests/cloud-activation-evidence.test.ts b/tests/cloud-activation-evidence.test.ts index 7c6a88f45c..80afaa77bd 100644 --- a/tests/cloud-activation-evidence.test.ts +++ b/tests/cloud-activation-evidence.test.ts @@ -34,7 +34,8 @@ function run(root: string, args: string[] = []) { }); } afterEach(() => { - for (const root of temporary.splice(0)) rmSync(root, { recursive: true, force: true }); + for (const root of temporary.splice(0)) + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); describe("read-only Cloud activation evidence", () => { diff --git a/tests/cloud-isolated-home.test.ts b/tests/cloud-isolated-home.test.ts index cb215c4ff5..094b01e0fa 100644 --- a/tests/cloud-isolated-home.test.ts +++ b/tests/cloud-isolated-home.test.ts @@ -34,7 +34,8 @@ function run( }); } afterEach(() => { - for (const root of temporary.splice(0)) rmSync(root, { recursive: true, force: true }); + for (const root of temporary.splice(0)) + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); describe("explicit isolated Cloud subprocess", () => { @@ -91,7 +92,7 @@ describe("explicit isolated Cloud subprocess", () => { expect(readdirSync(real)).toEqual([]); const root = join(parent, "owned"); expect(run(root).status).toBe(0); - rmSync(join(root, "codex"), { recursive: true }); + rmSync(join(root, "codex"), { recursive: true, maxRetries: 5, retryDelay: 100 }); symlinkSync(real, join(root, "codex")); expect(run(root).status).not.toBe(0); expect(readdirSync(real)).toEqual([]); diff --git a/tests/cloud-platform-smoke.test.ts b/tests/cloud-platform-smoke.test.ts index 193523e1c1..dd0dcde04d 100644 --- a/tests/cloud-platform-smoke.test.ts +++ b/tests/cloud-platform-smoke.test.ts @@ -1,7 +1,10 @@ -import { spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { runBootstrapSmoke } from "../scripts/check-cloud-platform-smoke.mjs"; +import { resolveNpmCli } from "../scripts/setup-codex-worktree.mjs"; import { getPlaywrightBaseUrl } from "../scripts/playwright-base-url"; import { localProjectRequestIdentityPayload } from "../src/lib/local-project-guard"; import { appName, localProjectId } from "../src/lib/local-server-utils.mjs"; @@ -9,6 +12,60 @@ import { appName, localProjectId } from "../src/lib/local-server-utils.mjs"; afterEach(() => vi.unstubAllEnvs()); describe("cloud platform acceptance fixtures", () => { + it("selects an explicitly staged real npm only for the bootstrap child", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "cloud-platform-npm-")); + try { + const stagedHome = process.env.CLOUD_PLATFORM_NPM_HOME; + const npmCli = stagedHome + ? path.join(stagedHome, "npm", "node_modules", "npm", "bin", "npm-cli.js") + : (resolveNpmCli() ?? + realpathSync(execFileSync("bash", ["-c", "command -v npm"], { encoding: "utf8" }).trim())); + const fixtureHome = path.join(root, "npm home's space"); + const fixturePackages = path.join(fixtureHome, "npm", "node_modules"); + mkdirSync(fixturePackages, { recursive: true }); + symlinkSync( + path.dirname(path.dirname(npmCli)), + path.join(fixturePackages, "npm"), + process.platform === "win32" ? "junction" : "dir", + ); + const callerAppData = path.join(root, "unrelated caller home"); + const rejectedBin = path.join(root, "unintended npm"); + mkdirSync(rejectedBin); + writeFileSync(path.join(rejectedBin, "npm"), "#!/bin/sh\nexit 97\n", { mode: 0o755 }); + vi.stubEnv("APPDATA", callerAppData); + vi.stubEnv("PATH", `${rejectedBin}${path.delimiter}${process.env.PATH}`); + vi.stubEnv("CLOUD_PLATFORM_NPM_HOME", fixtureHome); + expect(runBootstrapSmoke().donorCopied).toBe(true); + expect(process.env.APPDATA).toBe(callerAppData); + } finally { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + }, 30_000); + + it("refuses an explicitly unavailable npm home instead of using ambient npm", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "cloud-platform-missing-npm-")); + try { + vi.stubEnv("CLOUD_PLATFORM_NPM_HOME", root); + expect(() => runBootstrapSmoke()).toThrow(/CLOUD_PLATFORM_NPM_HOME.*npm-cli/u); + } finally { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + }); + + it("retains the exact npm runtime check for an explicit home with the wrong version", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "cloud-platform-wrong-npm-")); + try { + const bin = path.join(root, "npm", "node_modules", "npm", "bin"); + mkdirSync(bin, { recursive: true }); + // Negative fixture only: a real matching npm supplies every successful bootstrap. + writeFileSync(path.join(bin, "npm-cli.js"), 'process.stdout.write("0.0.0\\n");\n'); + vi.stubEnv("CLOUD_PLATFORM_NPM_HOME", root); + expect(() => runBootstrapSmoke()).toThrow(/npm .* is required; detected 0\.0\.0/u); + } finally { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + }); + it("executes the bootstrap in a real shell and isolated Git worktree containing spaces and apostrophes", () => { const result = runBootstrapSmoke(); expect(result).toMatchObject({ diff --git a/tests/cross-repo-cloud-contracts.test.ts b/tests/cross-repo-cloud-contracts.test.ts index 5d9529edd7..b437affc23 100644 --- a/tests/cross-repo-cloud-contracts.test.ts +++ b/tests/cross-repo-cloud-contracts.test.ts @@ -11,7 +11,11 @@ import { } from "../scripts/check-cross-repo-cloud-contracts.mjs"; const temporaryRoots: string[] = []; -afterEach(() => temporaryRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))); +afterEach(() => + temporaryRoots + .splice(0) + .forEach((root) => rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })), +); const sourceRoot = path.resolve(import.meta.dirname, ".."); const script = path.join(sourceRoot, "scripts/check-cross-repo-cloud-contracts.mjs"); From 9dbce2bb874f13a2ea246d077ad0106870f6daf7 Mon Sep 17 00:00:00 2001 From: Joshua Simpson Date: Sat, 17 Oct 2026 15:29:05 +0000 Subject: [PATCH 7/7] Close Cloud review portability and resource edge cases --- .github/workflows/ci.yml | 1 + .github/workflows/cloud-platform-smoke.yml | 1 + docs/agents/cloud-build-resources.md | 12 +- docs/agents/cloud-platform-acceptance.md | 19 ++-- scripts/check-cloud-build-resources.mjs | 45 +++++++- scripts/check-cross-repo-cloud-contracts.mjs | 23 ++-- scripts/playwright-base-url.ts | 4 +- tests/cloud-activation-evidence.test.ts | 114 ++++++++++++++++--- tests/cloud-build-resources.test.ts | 86 +++++++++++++- tests/cloud-platform-smoke.test.ts | 4 +- tests/cross-repo-cloud-contracts.test.ts | 85 ++++++++++++++ tests/playwright-base-url.test.ts | 3 + 12 files changed, 351 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 398cc412e4..72593754d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -744,6 +744,7 @@ jobs: fi bootstrap_npm_version="${BASH_REMATCH[1]}" bootstrap_npm_home="$RUNNER_TEMP/cloud-platform-npm" + # Exact packageManager pin; intentionally lockfile-free in a disposable runner-only prefix. npm install --prefix "$bootstrap_npm_home/npm" --ignore-scripts --no-save --package-lock=false --no-audit --no-fund "npm@$bootstrap_npm_version" echo "home=$bootstrap_npm_home" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/cloud-platform-smoke.yml b/.github/workflows/cloud-platform-smoke.yml index b91eae25fd..788d2c0b14 100644 --- a/.github/workflows/cloud-platform-smoke.yml +++ b/.github/workflows/cloud-platform-smoke.yml @@ -50,6 +50,7 @@ jobs: $version = $Matches[1] $toolAppData = Join-Path $env:RUNNER_TEMP 'cloud-platform-appdata' $toolPrefix = Join-Path $toolAppData 'npm' + # Exact packageManager pin; intentionally lockfile-free in a disposable runner-only prefix. npm install --prefix $toolPrefix --ignore-scripts --no-save --package-lock=false --no-audit --no-fund "npm@$version" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } "APPDATA=$toolAppData" >> $env:GITHUB_ENV diff --git a/docs/agents/cloud-build-resources.md b/docs/agents/cloud-build-resources.md index 10e6974f0e..988c4175eb 100644 --- a/docs/agents/cloud-build-resources.md +++ b/docs/agents/cloud-build-resources.md @@ -65,10 +65,14 @@ otherwise free memory. Cgroup availability subtracts current usage and credits bounded inactive file cache as reclaimable. A visible memory mount with unresolved process membership, any unreadable visible ancestor limit, invalid limits, or missing usage for a finite limit fails the preflight instead of trusting the -larger host. Unlimited fallback requires explicit readable `max` or sentinel -bounds through the visible hierarchy. Hierarchy roots without a readable memory -limit are reported as not ready; the preflight cannot prove their capacity from -the missing file alone. +larger host. Mount paths must be absolute and normalized, and ancestor traversal +is bounded. Unlimited fallback requires explicit readable `max` or sentinel +bounds through the visible hierarchy, except that the global cgroup v2 root +(mount root `/`) legitimately has no `memory.max`. Only `ENOENT` at that root is +accepted; permission errors and unreadable intermediate bounds still fail. All +finite ancestors below the root remain checked. A namespace-hidden root (including +the kernel's `/..` mount-root marker) without a readable bound is reported as not +ready. See the kernel's [memory controller reference](https://docs.kernel.org/admin-guide/cgroup-v2.html#memory-interface-files). This is a snapshot: another process can consume memory after it passes, and hidden ancestors cannot be inspected. Keep the existing heavy-run admission mechanism, diff --git a/docs/agents/cloud-platform-acceptance.md b/docs/agents/cloud-platform-acceptance.md index e684276f9a..45f1d6cab8 100644 --- a/docs/agents/cloud-platform-acceptance.md +++ b/docs/agents/cloud-platform-acceptance.md @@ -60,18 +60,17 @@ home layout; the fixture installs no toolchain and changes no caller environment Current URL behavior in `scripts/playwright-base-url.ts` is: -| Input | Acceptance and identity | -| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Explicit `http://localhost:` or `http://127.0.0.1:` with a non-default port | GET `/api/local-project-id`; require `appName=PsychSift`, checkout-derived `projectId`, and `localServer.safeLocalOrigin=true`. Real app identity marks ports outside 3100–4599 unsafe. | -| HTTPS origin with literal `ALLOW_PREVIEW_URL=true` | Accept any hostname except the exact parsed `localhost`, `127.0.0.1`, or `[::1]`; no remote identity request is performed. Custom HTTPS ports and non-loopback IPs are accepted. | -| HTTP remote host, HTTPS exact loopback, or missing literal preview opt-in | Reject. | -| Credentials, non-root path, query, fragment, invalid URL | Reject. | -| Ambient `PREVIEW_URL`, `PLAYWRIGHT_PREVIEW_URL`, or `VERCEL_URL` in shared config | Ignore; dedicated preview runner supplies the explicit target and opt-in. | +| Input | Acceptance and identity | +| ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Explicit `http://localhost:` or `http://127.0.0.1:` with a non-default port | GET `/api/local-project-id`; require `appName=PsychSift`, checkout-derived `projectId`, and `localServer.safeLocalOrigin=true`. Real app identity marks ports outside 3100–4599 unsafe. | +| HTTPS origin with literal `ALLOW_PREVIEW_URL=true` | Accept any hostname except parsed `localhost`, `127.0.0.1`, or `[::1]` after removing a terminal DNS dot; no remote identity request is performed. Custom HTTPS ports and non-loopback IPs are accepted. | +| HTTP remote host, HTTPS loopback (including trailing-dot localhost), or missing literal preview opt-in | Reject. | +| Credentials, non-root path, query, fragment, invalid URL | Reject. | +| Ambient `PREVIEW_URL`, `PLAYWRIGHT_PREVIEW_URL`, or `VERCEL_URL` in shared config | Ignore; dedicated preview runner supplies the explicit target and opt-in. | The HTTPS branch has no provider allowlist, DNS ownership check, or authenticated -project identity check. A trailing-dot `https://localhost.` is accepted by that -branch because its parsed hostname is not the exact excluded spelling. HTTPS -opt-in therefore authorizes a target; it does not verify a PsychSift deployment. +project identity check. A trailing-dot `https://localhost.` is rejected as the same local host. HTTPS +opt-in authorizes a target; it does not verify a PsychSift deployment. `SKIP_REMOTE_IDENTITY_CHECK` from the preview runner does not establish identity. `localProjectRequestIdentityPayload` derives local-host/port classification from diff --git a/scripts/check-cloud-build-resources.mjs b/scripts/check-cloud-build-resources.mjs index d98d2187a9..d3b9052ddd 100644 --- a/scripts/check-cloud-build-resources.mjs +++ b/scripts/check-cloud-build-resources.mjs @@ -27,6 +27,10 @@ function unescapeMount(text) { return text.replace(/\\([0-7]{3})/g, (_, octal) => String.fromCharCode(parseInt(octal, 8))); } +function canonicalAbsolutePath(value) { + return value.startsWith("/") && !value.includes("\0") && path.posix.normalize(value) === value; +} + /** Read-only Linux cgroup v1/v2 discovery; injected readers keep tests off the real host. */ export function discoverBuildMemory({ readFile = (file) => readFileSync(file, "utf8"), @@ -70,6 +74,18 @@ export function discoverBuildMemory({ if (!v2 && !(type === "cgroup" && options?.split(",").includes("memory"))) continue; const mountFields = left.split(" "); const mountPoint = unescapeMount(mountFields[4] ?? ""); + const mountRoot = unescapeMount(mountFields[3] ?? ""); + // The kernel can expose /.. for a mount inherited across cgroup namespaces. + // It is a hidden-root marker, not a path to normalize into the global root. + const hiddenNamespaceRoot = mountRoot === "/.."; + if ( + mountFields.length < 6 || + !canonicalAbsolutePath(mountPoint) || + (!canonicalAbsolutePath(mountRoot) && !hiddenNamespaceRoot) + ) { + issues.push("Malformed cgroup memory mount paths; expected absolute normalized root and mountpoint."); + continue; + } if (v2) { const controllers = readOptional(readFile, path.posix.join(mountPoint, "cgroup.controllers")); if (controllers === null) { @@ -88,7 +104,10 @@ export function discoverBuildMemory({ continue; } const groupPath = entry.slice(entry.indexOf(":", entry.indexOf(":") + 1) + 1); - const mountRoot = unescapeMount(mountFields[3] ?? ""); + if (!canonicalAbsolutePath(groupPath) || (hiddenNamespaceRoot && groupPath !== "/")) { + issues.push("Cgroup memory membership path cannot be resolved inside its visible mount."); + continue; + } // A cgroup namespace may expose / even when mountinfo's root is outside it. const relative = groupPath === "/" ? "" : path.posix.relative(mountRoot, groupPath); if (relative === ".." || relative.startsWith("../")) { @@ -96,10 +115,21 @@ export function discoverBuildMemory({ continue; } let directory = path.posix.join(mountPoint, relative); - for (;;) { + const maximumAncestors = relative ? relative.split("/").length + 1 : 1; + for (let depth = 0; depth < maximumAncestors; depth++) { const limitFile = path.posix.join(directory, v2 ? "memory.max" : "memory.limit_in_bytes"); - const limitText = readOptional(readFile, limitFile); - if (limitText === null) { + let limitText = null; + let absent = false; + try { + limitText = readFile(limitFile).trim(); + } catch (error) { + absent = error?.code === "ENOENT"; + } + // memory.max is defined only for non-root v2 cgroups. Only an absent file + // at the global hierarchy root has that meaning; errors, intermediate + // ancestors and hidden namespace roots must still refuse unknown bounds. + const globalV2Root = v2 && mountRoot === "/" && directory === mountPoint; + if (limitText === null && !(globalV2Root && absent)) { issues.push(`Cgroup memory limit could not be read at ${limitFile}.`); } if (limitText !== null) { @@ -127,7 +157,12 @@ export function discoverBuildMemory({ } } if (directory === mountPoint) break; - directory = path.posix.dirname(directory); + const parent = path.posix.dirname(directory); + if (parent === directory || depth + 1 === maximumAncestors) { + issues.push("Cgroup memory ancestor traversal could not reach its visible mount."); + break; + } + directory = parent; } } return { diff --git a/scripts/check-cross-repo-cloud-contracts.mjs b/scripts/check-cross-repo-cloud-contracts.mjs index 6e3032e356..17eebdfca9 100644 --- a/scripts/check-cross-repo-cloud-contracts.mjs +++ b/scripts/check-cross-repo-cloud-contracts.mjs @@ -361,18 +361,23 @@ export function inspectCheckout(name, root, revision) { return result; } -function fetchMaintained() { - const temporaryRoot = mkdtempSync(path.join(os.tmpdir(), "cloud-contract-audit-")); - const results = []; +/** @param {Record} [source] @param {string} [platform] */ +export function anonymousFetchEnvironment(source = process.env, platform = process.platform) { // No personal credential/helper/config inheritance. Preserve only transport/runtime configuration. - const env = Object.fromEntries( - Object.entries(process.env).filter(([key]) => - /^(?:PATH|SystemRoot|TMPDIR|TEMP|TMP|HTTPS?_PROXY|https?_proxy|ALL_PROXY|all_proxy|NO_PROXY|no_proxy|SSL_CERT_FILE|SSL_CERT_DIR|GIT_SSL_CAINFO|GIT_SSL_CAPATH)$/.test( - key, - ), - ), + // Windows environment names are case-insensitive; retain POSIX's existing exact spellings. + const allowedNames = new RegExp( + "^(?:PATH|SystemRoot|TMPDIR|TEMP|TMP|HTTPS?_PROXY|https?_proxy|ALL_PROXY|all_proxy|NO_PROXY|no_proxy|SSL_CERT_FILE|SSL_CERT_DIR|GIT_SSL_CAINFO|GIT_SSL_CAPATH)$", + platform === "win32" ? "i" : "", ); + const env = Object.fromEntries(Object.entries(source).filter(([key]) => allowedNames.test(key))); Object.assign(env, { GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: os.devNull }); + return env; +} + +function fetchMaintained() { + const temporaryRoot = mkdtempSync(path.join(os.tmpdir(), "cloud-contract-audit-")); + const results = []; + const env = anonymousFetchEnvironment(); try { for (const entry of repositoryMatrix.filter((item) => item.active && item.name !== "PsychSift")) { const root = path.join(temporaryRoot, entry.name); diff --git a/scripts/playwright-base-url.ts b/scripts/playwright-base-url.ts index 80a31b7bdc..d4975506a8 100644 --- a/scripts/playwright-base-url.ts +++ b/scripts/playwright-base-url.ts @@ -98,7 +98,9 @@ export function getPlaywrightBaseUrl({ allowEnsure = true }: { allowEnsure?: boo throw new Error("PLAYWRIGHT_BASE_URL must contain only an origin, without credentials, path, query or fragment."); } const isLocal = localUrlPattern.test(parsed.origin); - const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname); + // A terminal DNS dot denotes the same host and must not bypass the local guard. + const previewHostname = parsed.hostname.replace(/\.$/u, ""); + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(previewHostname); const isPreview = process.env.ALLOW_PREVIEW_URL === "true" && parsed.protocol === "https:" && !loopback; if (!isLocal && !isPreview) { throw new Error( diff --git a/tests/cloud-activation-evidence.test.ts b/tests/cloud-activation-evidence.test.ts index 80afaa77bd..66d2fed592 100644 --- a/tests/cloud-activation-evidence.test.ts +++ b/tests/cloud-activation-evidence.test.ts @@ -1,28 +1,61 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { devNull, tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; const script = join(import.meta.dirname, "..", "scripts", "cloud-activation-evidence.mjs"); const temporary: string[] = []; -function fixture() { - const root = mkdtempSync(join(tmpdir(), "activation-evidence-")); +// Synthetic checkouts must not inherit host Git configuration, injected config or repository paths. +function fixtureEnvironment(): NodeJS.ProcessEnv { + return { + NODE_ENV: "test", + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot, + ComSpec: process.env.ComSpec, + WINDIR: process.env.WINDIR, + PATHEXT: process.env.PATHEXT, + TEMP: process.env.TEMP, + TMP: process.env.TMP, + GIT_CONFIG_GLOBAL: devNull, + GIT_CONFIG_SYSTEM: devNull, + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_COUNT: "0", + GIT_TERMINAL_PROMPT: "0", + }; +} +function fixture(parent = tmpdir()) { + const root = realpathSync(mkdtempSync(join(parent, "activation-evidence-"))); temporary.push(root); mkdirSync(join(root, "scripts")); cpSync(script, join(root, "scripts", "cloud-activation-evidence.mjs")); writeFileSync(join(root, "AGENTS.md"), "Synthetic safeguards\n"); mkdirSync(join(root, ".claude", "cloud-profile"), { recursive: true }); writeFileSync(join(root, ".claude", "cloud-profile", "CLAUDE.md"), "Synthetic profile\n"); - for (const args of [ - ["init", "-q"], - ["add", "."], - ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-qm", "fixture"], - ]) { - expect(spawnSync("git", args, { cwd: root, encoding: "utf8" }).status).toBe(0); - } - const revision = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).stdout.trim(); + const git = (...args: string[]) => { + const result = spawnSync( + "git", + ["-c", `core.hooksPath=${devNull}`, "-c", "commit.gpgsign=false", "-c", "init.templateDir=", ...args], + { cwd: root, encoding: "utf8", env: fixtureEnvironment(), timeout: 10_000 }, + ); + expect(result.status, result.stderr || result.error?.message).toBe(0); + return result.stdout.trim(); + }; + git("init", "-q"); + git("add", "."); + git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-qm", "fixture"); + const revision = git("rev-parse", "HEAD"); return { root, revision }; } function run(root: string, args: string[] = []) { @@ -30,15 +63,68 @@ function run(root: string, args: string[] = []) { cwd: root, encoding: "utf8", timeout: 10_000, - env: { ...process.env, ACTIVATION_SECRET: "synthetic-secret-must-not-print" }, + env: { ...fixtureEnvironment(), ACTIVATION_SECRET: "synthetic-secret-must-not-print" }, }); } afterEach(() => { + vi.unstubAllEnvs(); for (const root of temporary.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); describe("read-only Cloud activation evidence", () => { + it.each(["global-hook", "global-signing", "global-template", "system-config", "injected-config"])( + "isolates fixture Git from synthetic %s host configuration", + (kind) => { + const host = realpathSync(mkdtempSync(join(tmpdir(), "activation-host-config-"))); + temporary.push(host); + const sentinel = join(host, "host-hook-ran"); + const hooks = join(host, "hooks"); + const templates = join(host, "templates"); + mkdirSync(hooks); + mkdirSync(join(templates, "hooks"), { recursive: true }); + // The hook would make fixture commits fail if host settings or templates leaked in. + const hook = `#!/bin/sh\nprintf 'ran' > '${sentinel.replaceAll("'", "'\\''")}'\nexit 73\n`; + writeFileSync(join(hooks, "pre-commit"), hook, { mode: 0o755 }); + writeFileSync(join(templates, "hooks", "pre-commit"), hook, { mode: 0o755 }); + const globalConfig = join(host, "gitconfig"); + const quote = (value: string) => JSON.stringify(value.replaceAll("\\", "/")); + const config = + kind === "global-signing" + ? `[commit]\n gpgsign = true\n[gpg]\n program = ${quote(join(host, "nonexistent-signing-program"))}\n` + : kind === "global-template" + ? `[init]\n templateDir = ${quote(templates)}\n` + : `[core]\n hooksPath = ${quote(hooks)}\n`; + writeFileSync(globalConfig, config); + vi.stubEnv(kind === "system-config" ? "GIT_CONFIG_SYSTEM" : "GIT_CONFIG_GLOBAL", globalConfig); + if (kind === "system-config") vi.stubEnv("GIT_CONFIG_NOSYSTEM", "0"); + if (kind === "injected-config") { + vi.stubEnv("GIT_CONFIG_COUNT", "1"); + vi.stubEnv("GIT_CONFIG_KEY_0", "core.hooksPath"); + vi.stubEnv("GIT_CONFIG_VALUE_0", hooks); + } + const { root, revision } = fixture(); + expect(existsSync(sentinel)).toBe(false); + expect(existsSync(join(root, ".git", "hooks", "pre-commit"))).toBe(false); + const result = run(root, ["--expected-revision", revision]); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout).source.revision).toBe(revision); + expect(process.env[kind === "system-config" ? "GIT_CONFIG_SYSTEM" : "GIT_CONFIG_GLOBAL"]).toBe(globalConfig); + }, + ); + it("uses the physical fixture checkout when its temporary parent is a symlink", () => { + const parent = realpathSync(mkdtempSync(join(tmpdir(), "activation-symlink-parent-"))); + temporary.push(parent); + const physical = join(parent, "physical"); + mkdirSync(physical); + const linked = join(parent, "linked"); + symlinkSync(physical, linked, "junction"); + const { root, revision } = fixture(linked); + expect(root).toBe(realpathSync(root)); + const result = run(root, ["--expected-revision", revision]); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout).source.checkout).toBe(root); + }); it("pins exact source bytes and revision while leaving installation and session unverified", () => { const { root, revision } = fixture(); const result = run(root, ["--expected-revision", revision]); diff --git a/tests/cloud-build-resources.test.ts b/tests/cloud-build-resources.test.ts index 93ea4ca15b..356c2e18fa 100644 --- a/tests/cloud-build-resources.test.ts +++ b/tests/cloud-build-resources.test.ts @@ -4,10 +4,10 @@ import { describe, expect, it } from "vitest"; import { assessCloudBuildResources, discoverBuildMemory } from "../scripts/check-cloud-build-resources.mjs"; const GiB = 1024 ** 3; -function memory(files: Record, total = 64 * GiB, free = 60 * GiB) { +function memory(files: Record, total = 64 * GiB, free = 60 * GiB, missingCode?: string) { return discoverBuildMemory({ readFile: (file: string) => { - if (!(file in files)) throw new Error("ENOENT"); + if (!(file in files)) throw Object.assign(new Error("unreadable fixture"), { code: missingCode }); return files[file]; }, totalMemoryBytes: total, @@ -105,6 +105,88 @@ describe("cloud build resource preflight", () => { expect(report.issues.join(" ")).toContain("/sys/fs/cgroup/memory.max"); }); + it("accepts an absent memory.max at the real v2 hierarchy root", () => { + const files: Record = v2("max"); + delete files["/sys/fs/cgroup/memory.max"]; + const report = assessCloudBuildResources({ + memory: memory(files, 64 * GiB, 60 * GiB, "ENOENT"), + inheritedHeapBytes: 8 * GiB, + }); + expect(report.ok).toBe(true); + expect(report.limitBytes).toBe(64 * GiB); + }); + + it("retains finite ancestors below an unconstrained v2 hierarchy root", () => { + const files: Record = v2("max", String(GiB), { + "/proc/self/cgroup": "0::/team/job", + "/sys/fs/cgroup/team/job/memory.max": "max", + "/sys/fs/cgroup/team/memory.max": String(8 * GiB), + "/sys/fs/cgroup/team/memory.current": String(GiB), + }); + delete files["/sys/fs/cgroup/memory.max"]; + const report = assessCloudBuildResources({ + memory: memory(files, 64 * GiB, 60 * GiB, "ENOENT"), + inheritedHeapBytes: 8 * GiB, + }); + expect(report.ok).toBe(false); + expect(report.limitBytes).toBe(8 * GiB); + expect(report.issues.join(" ")).not.toContain("could not be read"); + }); + + it.each(["/tenant", "/.."])("refuses a missing limit at a hidden v2 hierarchy root (%s)", (root) => { + const files: Record = v2("max", String(GiB), { + "/proc/self/cgroup": "0::/", + "/proc/self/mountinfo": `1 0 0:1 ${root} /sys/fs/cgroup rw - cgroup2 cgroup rw`, + }); + delete files["/sys/fs/cgroup/memory.max"]; + const report = assessCloudBuildResources({ + memory: memory(files, 64 * GiB, 60 * GiB, "ENOENT"), + inheritedHeapBytes: 8 * GiB, + }); + expect(report.ok).toBe(false); + expect(report.issues.join(" ")).toContain("/sys/fs/cgroup/memory.max"); + }); + + it("does not turn a permission failure at a real v2 hierarchy root into unlimited capacity", () => { + const files: Record = v2("max"); + delete files["/sys/fs/cgroup/memory.max"]; + const report = assessCloudBuildResources({ + memory: memory(files, 64 * GiB, 60 * GiB, "EACCES"), + inheritedHeapBytes: 8 * GiB, + }); + expect(report.ok).toBe(false); + }); + + it.each([ + "1 0 0:1 / rw", + "1 0 0:1 / rw", + "1 0 0:1 / relative rw", + "1 0 0:1 / /sys/../cgroup rw", + "1 0 0:1 relative /sys/fs/cgroup rw", + "1 0 0:1 /sys/fs/cgroup rw", + ])("rejects malformed memory mount paths without hanging (%s)", (mountFields) => { + const source = ` + import { discoverBuildMemory } from ${JSON.stringify(new URL("../scripts/check-cloud-build-resources.mjs", import.meta.url).href)}; + const files = { + '/proc/self/cgroup': '0::/job', + '/proc/self/mountinfo': ${JSON.stringify(`${mountFields} - cgroup2 cgroup rw`)} + }; + const memory = discoverBuildMemory({ + readFile: (file) => files[file] ?? (file.endsWith('cgroup.controllers') ? 'memory' : 'max'), + platform: 'linux', totalMemoryBytes: ${64 * GiB}, freeMemoryBytes: ${60 * GiB} + }); + process.stdout.write(JSON.stringify(memory)); + `; + const result = spawnSync(process.execPath, ["--input-type=module", "-e", source], { + encoding: "utf8", + timeout: 1000, + maxBuffer: 64 * 1024, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).issues.join(" ")).toContain("Malformed cgroup memory mount paths"); + }); + it("rejects a missing leaf limit even when the visible parent is readable", () => { const files = v2(String(16 * GiB), String(GiB), { "/sys/fs/cgroup/memory.max": "max", diff --git a/tests/cloud-platform-smoke.test.ts b/tests/cloud-platform-smoke.test.ts index dd0dcde04d..bd0b611396 100644 --- a/tests/cloud-platform-smoke.test.ts +++ b/tests/cloud-platform-smoke.test.ts @@ -188,7 +188,7 @@ describe("cloud platform acceptance fixtures", () => { ).toBe(false); }); - it.each(["https://preview.invalid", "https://preview.invalid:8443", "https://192.0.2.1", "https://localhost."])( + it.each(["https://preview.invalid", "https://preview.invalid:8443", "https://192.0.2.1", "https://preview.invalid."])( "documents HTTPS opt-in acceptance without claiming identity verification: %s", (url) => { vi.stubEnv("PLAYWRIGHT_BASE_URL", url); @@ -199,6 +199,8 @@ describe("cloud platform acceptance fixtures", () => { it.each([ "https://localhost:4383", + "https://localhost.", + "https://LOCALHOST.:4383", "https://127.0.0.1:4383", "https://[::1]:4383", "http://preview.invalid", diff --git a/tests/cross-repo-cloud-contracts.test.ts b/tests/cross-repo-cloud-contracts.test.ts index b437affc23..6591004ea2 100644 --- a/tests/cross-repo-cloud-contracts.test.ts +++ b/tests/cross-repo-cloud-contracts.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + anonymousFetchEnvironment, checkCloudContracts, inspectCheckout, repositoryMatrix, @@ -84,6 +85,90 @@ function checkout(files: Record) { } describe("cross-repository Cloud contracts", () => { + it("preserves Windows transport/runtime environment names regardless of their casing", () => { + const source = { + Path: "C:\\Synthetic\\bin", + SYSTEMROOT: "C:\\Windows", + TmpDir: "C:\\Synthetic\\temp", + Temp: "C:\\Synthetic\\temp", + Tmp: "C:\\Synthetic\\temp", + Https_Proxy: "https://proxy.example.invalid", + Http_Proxy: "http://proxy.example.invalid", + All_Proxy: "http://proxy.example.invalid", + No_Proxy: "localhost", + Ssl_Cert_File: "C:\\Synthetic\\ca.pem", + Ssl_Cert_Dir: "C:\\Synthetic\\certs", + Git_Ssl_CaInfo: "C:\\Synthetic\\git-ca.pem", + Git_Ssl_CaPath: "C:\\Synthetic\\git-certs", + }; + expect(anonymousFetchEnvironment(source, "win32")).toEqual({ + ...source, + GIT_TERMINAL_PROMPT: "0", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: os.devNull, + }); + }); + + it.each(["linux", "darwin"])("retains existing case-sensitive environment filtering on %s", (platform) => { + const allowed = { + PATH: "/synthetic/bin", + SystemRoot: "synthetic-system-root", + TMPDIR: "/synthetic/temp", + HTTPS_PROXY: "https://upper-proxy.example.invalid", + https_proxy: "https://lower-proxy.example.invalid", + ALL_PROXY: "http://upper-proxy.example.invalid", + all_proxy: "http://lower-proxy.example.invalid", + NO_PROXY: "localhost", + no_proxy: "127.0.0.1", + SSL_CERT_FILE: "/synthetic/ca.pem", + GIT_SSL_CAINFO: "/synthetic/git-ca.pem", + }; + expect( + anonymousFetchEnvironment( + { ...allowed, Path: "excluded", SYSTEMROOT: "excluded", Https_Proxy: "excluded", Git_Ssl_CaInfo: "excluded" }, + platform, + ), + ).toEqual({ + ...allowed, + GIT_TERMINAL_PROMPT: "0", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: os.devNull, + }); + }); + + it.each(["win32", "linux", "darwin"])("blocks credentials and inherited Git config on %s", (platform) => { + const source = Object.fromEntries( + [ + "GH_TOKEN", + "Github_Token", + "OPENAI_API_KEY", + "SUPABASE_ACCESS_TOKEN", + "DATABASE_URL", + "HOME", + "USERPROFILE", + "CODEX_HOME", + "NODE_OPTIONS", + "GIT_ASKPASS", + "SSH_ASKPASS", + "GIT_CONFIG", + "GIT_CONFIG_SYSTEM", + "Git_Config_Global", + "Git_Config_NoSystem", + "Git_Terminal_Prompt", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_KEY_0", + "GIT_CONFIG_VALUE_0", + "GIT_SSH_COMMAND", + "GIT_SSL_NO_VERIFY", + ].map((key) => [key, "synthetic-blocked-value"]), + ); + expect(anonymousFetchEnvironment(source, platform)).toEqual({ + GIT_TERMINAL_PROMPT: "0", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: os.devNull, + }); + }); + it("maintains six active repository adapters and an explicitly inactive backup", () => { expect(repositoryMatrix.map((entry: { name: string }) => entry.name)).toEqual([ "PsychSift", diff --git a/tests/playwright-base-url.test.ts b/tests/playwright-base-url.test.ts index cb9ad5ed9a..163ff87c03 100644 --- a/tests/playwright-base-url.test.ts +++ b/tests/playwright-base-url.test.ts @@ -62,6 +62,9 @@ describe("explicit Playwright target approval", () => { "https://remote.invalid/#fragment", "not a URL", "https://localhost:4383", + "https://localhost.", + "https://localhost.:4383", + "https://LOCALHOST.:4383", "https://127.0.0.1:4383", "https://[::1]:4383", ])("rejects unsafe or non-origin targets even with opt-in: %s", (url) => {