From f74a3153cfae1322af0fff93540fbd21975f60bd Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 17 Oct 2026 17:12:00 +0200 Subject: [PATCH 01/27] feat: launch isolated source dev stack with full lifecycle proof --- .github/workflows/dev-stack.yml | 147 +++++++++++++++++++++ .gitignore | 3 + CONTRIBUTING.md | 5 + apps/web/vite.config.ts | 13 +- docs/development-stack.md | 142 +++++++++++++++++++++ package.json | 4 +- scripts/dev-stack.mjs | 3 + scripts/dev-stack/acceptance.mjs | 95 ++++++++++++++ scripts/dev-stack/browser-proof.mjs | 53 ++++++++ scripts/dev-stack/lifecycle-proof.mjs | 158 +++++++++++++++++++++++ scripts/dev-stack/opencode-wrapper.mjs | 26 ++++ scripts/dev-stack/processes.mjs | 64 ++++++++++ scripts/dev-stack/resources.mjs | 54 ++++++++ scripts/dev-stack/state.mjs | 106 +++++++++++++++ scripts/dev-stack/supervisor.mjs | 170 +++++++++++++++++++++++++ scripts/public-export.mjs | 11 ++ scripts/tests/dev-stack.test.mjs | 165 ++++++++++++++++++++++++ scripts/tests/public-suite.test.mjs | 2 + 18 files changed, 1215 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/dev-stack.yml create mode 100644 docs/development-stack.md create mode 100644 scripts/dev-stack.mjs create mode 100644 scripts/dev-stack/acceptance.mjs create mode 100644 scripts/dev-stack/browser-proof.mjs create mode 100644 scripts/dev-stack/lifecycle-proof.mjs create mode 100644 scripts/dev-stack/opencode-wrapper.mjs create mode 100644 scripts/dev-stack/processes.mjs create mode 100644 scripts/dev-stack/resources.mjs create mode 100644 scripts/dev-stack/state.mjs create mode 100644 scripts/dev-stack/supervisor.mjs create mode 100644 scripts/tests/dev-stack.test.mjs diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml new file mode 100644 index 000000000..125cf5ea6 --- /dev/null +++ b/.github/workflows/dev-stack.yml @@ -0,0 +1,147 @@ +name: Source development stack + +on: + pull_request: + paths: + - "scripts/dev-stack*" + - "scripts/dev-stack/**" + - "scripts/tests/dev-stack.test.mjs" + - "apps/web/vite.config.ts" + - "packages/**" + - "apps/api/**" + - "apps/worker/**" + - "package*.json" + - "Dockerfile" + - ".github/workflows/dev-stack.yml" + push: + branches: + - "codex/spaceapp65-*" + workflow_dispatch: + inputs: + expected_source: + description: "Optional exact40hex reviewed source SHA; mismatch fails before executing project code" + type: string + required: false + +permissions: + contents: read + +concurrency: + group: dev-stack-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + full-stack: + # Standard public runners only. No private, self-hosted, larger or paid fallback. + if: github.event.repository.private == false + name: Real source stack / ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-15-intel] + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + defaults: + run: + shell: bash + env: + CI: "true" + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + SPACE_DEV_REDACT_SETUP: "true" + SPACE_DEV_CANDIDATE: ${{ github.event.pull_request.head.sha || github.sha }} + SPACE_DEV_PROOF_DIR: ${{ runner.temp }}/spaceapp-dev-stack-proof + EXPECTED_SOURCE: ${{ inputs.expected_source }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc + cache: npm + + - name: Verify exact public candidate + run: | + node --input-type=module <<'NODE' + import assert from 'node:assert/strict'; + import { execFileSync } from 'node:child_process'; + assert.match(process.env.SPACE_DEV_CANDIDATE, /^[a-f0-9]{40}$/); + const actual = execFileSync('git', ['rev-parse', 'HEAD'], {encoding:'utf8'}).trim(); + assert.equal(actual, process.env.SPACE_DEV_CANDIDATE); + if (process.env.EXPECTED_SOURCE) { + assert.match(process.env.EXPECTED_SOURCE, /^[a-f0-9]{40}$/); + assert.equal(actual, process.env.EXPECTED_SOURCE); + } + console.log(`Exact source ${actual}; Node ${process.version}; ${process.platform}/${process.arch}`); + NODE + git diff --exit-code + + - name: Prepare ephemeral Intel-macOS Docker + if: runner.os == 'macOS' + run: | + test "$(uname -m)" = x86_64 + brew install colima docker docker-compose ripgrep + mkdir -p "$HOME/.docker/cli-plugins" + ln -sfn "$(brew --prefix)/opt/docker-compose/bin/docker-compose" "$HOME/.docker/cli-plugins/docker-compose" + colima start --profile spaceapp-dev-proof --cpu 3 --memory 6 --disk 30 + docker context use colima-spaceapp-dev-proof + + - name: Verify existing Docker, Compose, browser and search tools + run: | + docker version + docker compose version + command -v rg + if [[ "$RUNNER_OS" == macOS ]]; then + test -x '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' + else + test -x /usr/bin/google-chrome + fi + + - name: Install locked workspace dependencies + run: npm ci --no-audit --no-fund + + - name: Daemon-free isolation and cleanup regressions + run: npm run test:dev-stack + + - name: Full stock check + run: npm run check + + - name: Full stock tests + run: npm test + + - name: Full stock build + run: npm run build + + - name: Full stock hygiene + run: npm run hygiene:preflight + + - name: Real HTTP, owner, browser, PTY, HMR, isolation and failure cleanup + run: node scripts/dev-stack/lifecycle-proof.mjs + + - name: Final clean exact-source guard + if: always() + run: | + test "$(git rev-parse HEAD)" = "$SPACE_DEV_CANDIDATE" + git diff --exit-code + test -z "$(git status --porcelain --untracked-files=all)" + + - name: Remove ephemeral hosted Colima profile + if: always() && runner.os == 'macOS' + run: | + if colima status --profile spaceapp-dev-proof >/dev/null 2>&1; then + colima stop --profile spaceapp-dev-proof + fi + if colima list --json | grep -q 'spaceapp-dev-proof'; then + colima delete --profile spaceapp-dev-proof --force + fi + + - name: Preserve redacted evidence only + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dev-stack-${{ matrix.os }}-${{ github.event.pull_request.head.sha || github.sha }} + path: ${{ runner.temp }}/spaceapp-dev-stack-proof + if-no-files-found: warn + retention-days: 7 diff --git a/.gitignore b/.gitignore index 81e68b26a..6b21e72d1 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,6 @@ projects/ .local/ run-spaceapp-*.tgz + +# Isolated from-source contributor runtime (contains secrets). +/.dev-stack*/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03bcdad05..a62ca855c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,11 @@ packaging requires its normal toolchain and separate platform acceptance. Dependency installation may use the network; the smoke command below does not need a provider account, API key, Docker, PostgreSQL or Temporal. +For the real source API/web/worker/CLI stack with isolated Docker dependencies, +normal owner setup and hot reload, use `npm run dev:stack`. See the +[development stack guide](docs/development-stack.md) for prerequisites, ports, +private state, down/reset, and the complete Linux/Intel-macOS browser/lifecycle proof. + ## First executed task, without a provider ```sh diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index cd7612d98..63c82ff6b 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -4,6 +4,8 @@ import { retainOpenTabAssets } from "./vite.asset-retention.js"; import react from "@vitejs/plugin-react"; import { demoBundleBoundaryPlugin } from "./vite.demo-boundary.js"; +const devApiOrigin = process.env.SPACE_API_ORIGIN || "http://127.0.0.1:4910"; + export function manualChunkForModule(moduleId: string): string | undefined { const normalizedId = moduleId.replaceAll("\\", "/"); if ( @@ -33,12 +35,13 @@ export default defineConfig({ server: { host: process.env.SPACE_WEB_HOST ?? "0.0.0.0", port: Number.parseInt(process.env.SPACE_WEB_PORT ?? "4911", 10), + strictPort: process.env.SPACE_DEV_STACK === "true", proxy: { - "/api": "http://127.0.0.1:4910", - "/plugins": "http://127.0.0.1:4910", - "/healthz": "http://127.0.0.1:4910", - "/readyz": "http://127.0.0.1:4910", - "/version": "http://127.0.0.1:4910" + "/api": { target: devApiOrigin, ws: true }, + "/plugins": devApiOrigin, + "/healthz": devApiOrigin, + "/readyz": devApiOrigin, + "/version": devApiOrigin } }, test: { diff --git a/docs/development-stack.md b/docs/development-stack.md new file mode 100644 index 000000000..b694f661c --- /dev/null +++ b/docs/development-stack.md @@ -0,0 +1,142 @@ +# Source development stack + +`npm run dev:stack` runs a contributor checkout with the real source API, Vite +web app, Temporal worker and CLI host. PostgreSQL/pgvector and Temporal run in +Docker; their image digests come from the installer’s canonical Compose template. +OpenCode’s version comes from the root Dockerfile. This is a development launcher, +not an installed operator instance or a desktop packaging command. + +## Prerequisites and startup + +Use Node22 from `.nvmrc`, npm, Docker Engine/Desktop with ComposeV2, and the normal +native build tools for `node-pty` if its prebuilt package is unavailable. On macOS, +an existing Docker Desktop or Colima context must already work. On Windows11 use +an existing WSL2 Linux environment with Docker integration; native Windows is not +a Unix-socket runtime. The launcher never installs Docker/WSL, requests elevation, +changes OS features or reboots. WSL2 instructions describe the supported route; +the hosted platform proof below targets Linux and Intel macOS. + +From this checkout’s root: + +```sh +ELECTRON_SKIP_BINARY_DOWNLOAD=1 npm ci +npm run dev:stack +``` + +Startup checks all requested ports, creates private state, starts the two pinned +containers, builds the workspace, installs the pinned OpenCode package under that +state, applies database migrations and starts the four source services. It prints +the web/API URLs and the one-time owner setup token. Open the web URL, claim the +owner with that token and your own new local credentials, then sign in through +the normal owner flow. After setup, create/open the Getting Started room and an +OpenCode terminal pane. No development-login bypass is enabled. + +This OpenCode wrapper opens its actual terminal UI with an empty private provider +profile. It does not copy an operator’s credentials or perform the production +first-launch model smoke test. Authentication/inference commands are blocked; +the acceptance proof sends no model prompt and needs no provider API key/account. +Other provider wrappers, companion services, root CLI/file administration, +background integrations and optional modules are disabled in this isolated stack. + +Changes in `apps/api/src` restart the API. Worker or shared-package source changes +stop the dependent source services, run the normal complete build, and restart +them from this checkout. Vite handles web HMR directly, including WebSocket proxying +to the chosen API port. A required child exiting unexpectedly produces a named +error and stops the stack. Sustained readiness loss also fails instead of leaving +an apparently healthy partial stack. + +## State, ports and privacy + +Defaults are API4810, web4811, PostgreSQL54329 and Temporal47233. All listeners bind +to127.0.0.1. Installed defaults4910/4911 are rejected. To run a second checkout or +separate instance, choose four distinct unused ports and a different direct child +state directory: + +```sh +export SPACE_DEV_STATE_DIR=.dev-stack-alternate +export SPACE_DEV_API_PORT=5810 SPACE_DEV_WEB_PORT=5811 +export SPACE_DEV_POSTGRES_PORT=55439 SPACE_DEV_TEMPORAL_PORT=57233 +npm run dev:stack +``` + +The same state preserves its ports and database across restarts. It gets a random +Compose project and ownership nonce, separate database/Temporal namespace/task +queue, Unix socket, writable directories and CLI/provider homes. The subprocess +environment is allowlisted; inherited provider keys, session secrets, operator +homes and SSH agents are not forwarded to application children. Docker control +connectivity still uses your selected Docker context, including the normal Docker +configuration/agent if that context requires it. + +Generated state defaults to `.dev-stack/` and is ignored by Git. Only recognizable +direct children `.dev-stack` or `.dev-stack-` are allowed; linked state/control +paths are rejected. The state directory is0700 and generated secrets/manifests/ +Compose configuration are0600. The PostgreSQL password is in the private generated +Compose file as container environment, avoiding weaker world-readable bind-mounted +secrets. A Docker administrator can inspect container environment; this does not +isolate a secret from the host/Docker administrator. Never print `docker compose +config`, upload the state directory, or share the setup token. CI suppresses the +token; subprocess logs redact generated secrets, including split output chunks. + +## Stop, reset and recover + +Keep the same `SPACE_DEV_STATE_DIR` selection in the shell: + +```sh +npm run dev:stack -- down +npm run dev:stack -- reset +``` + +Ctrl-C and `down` stop only the tracked process tree and that state’s containers/ +network; they retain database/state for reuse. `reset` additionally removes that +state’s labeled volume and private directory. Neither runs prune, broad process +name matching, port-based kills, installer uninstall or operator cleanup. Process +identities are checked again before signals, and every project resource must have +the matching generated ownership label. A foreign resource, unavailable daemon +or failed process/container removal fails the command and keeps recovery state. +Restore the original Docker context and retry `down`/`reset`; do not delete the +manifest as a shortcut. Stop the supervisor before manually repairing owned state. + +## Reproducible complete proof + +`npm run test:dev-stack` runs daemon-free regressions for ownership, private paths, +permissions, pins, environment isolation, redaction, PID reuse and failed commands. +These tests do not establish a working Docker stack or browser. + +`.github/workflows/dev-stack.yml` prepares only standard public `ubuntu-latest` +and `macos-15-intel` runners, checks the exact source, and executes unmodified +`npm run check`, `npm test`, `npm run build` and `npm run hygiene:preflight`. +The macOS job uses an ephemeral named Colima profile, removed afterward. It does +not use a larger runner, paid fallback, provider credentials or an operator profile. + +After those gates, `scripts/dev-stack/lifecycle-proof.mjs` starts a real stack with +synthetic installed ports4910/4911 occupied by an independent process. It exercises +HTTP owner claim and sign-in, invalidates the consumed token, creates the actual +Getting Started/OpenCode pane, observes the real PTY UI, and renders that pane in +the runner’s existing headless Chrome. It changes a CSS canary and observes real +HMR without a navigation, then changes API/shared-package source and observes +rebuilt output plus new API/worker processes and restored readiness. All changed +source bytes are restored. + +The same proof checks loopback sockets/permissions, down retaining the database, +a deliberately unavailable Docker endpoint retaining recovery state, normal reset, +a fresh state with alternate ports/new secrets, and an actual forced worker death. +An independent labeled container, volume and process must survive each launcher +cleanup. The proof finally removes its own separately labeled sentinels, and checks +that no owned containers/volumes/networks/processes/state or source changes remain. +Only redacted logs, check summaries and browser screenshots outside the checkout +are uploaded. A failed criterion remains failure; a green unit/build job is not a +substitute for a completed same-source Linux/macOS run. + +To run that real proof on a disposable host whose Docker/browser already work: + +```sh +export SPACE_DEV_CANDIDATE=$(git rev-parse HEAD) +export SPACE_DEV_PROOF_DIR=/absolute/private/path/outside/checkout +node scripts/dev-stack/lifecycle-proof.mjs +``` + +Only use a disposable development host: the proof deliberately edits/restores +three source files and binds4910/4911. It refuses pre-existing proof state and never +adopts an installed instance. Hosted transcripts certify their actual platform and +commit only; no unperformed Windows, provider-inference or desktop-install result +is implied. diff --git a/package.json b/package.json index b488fbbcb..af270f67e 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,9 @@ "test:architecture": "node --test scripts/tests/domain-boundaries.test.mjs scripts/tests/composition-root-size.test.mjs scripts/tests/quality-known-limitations.test.mjs && node scripts/domain-boundaries.mjs", "smoke:offline": "npm run build -w @space/cli-host && node scripts/contributor-smoke.mjs", "test:contributor": "node --test scripts/tests/contributor-smoke.test.mjs", - "lint": "eslint --max-warnings=-1 apps packages scripts" + "lint": "eslint --max-warnings=-1 apps packages scripts", + "dev:stack": "node scripts/dev-stack.mjs", + "test:dev-stack": "node --test scripts/tests/dev-stack.test.mjs" }, "devDependencies": { "@eslint/js": "10.0.1", diff --git a/scripts/dev-stack.mjs b/scripts/dev-stack.mjs new file mode 100644 index 000000000..0b3ef36c6 --- /dev/null +++ b/scripts/dev-stack.mjs @@ -0,0 +1,3 @@ +#!/usr/bin/env node +import { main } from './dev-stack/supervisor.mjs'; +await main().catch(error => { console.error(`[dev-stack] ${error.message}`); process.exitCode = 1; }); diff --git a/scripts/dev-stack/acceptance.mjs b/scripts/dev-stack/acceptance.mjs new file mode 100644 index 000000000..447dd7d4a --- /dev/null +++ b/scripts/dev-stack/acceptance.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { stripVTControlCharacters } from 'node:util'; +import WebSocket from 'ws'; +import { locations, loadOwner, readJson } from './state.mjs'; +import { sameProcess, delay } from './processes.mjs'; +import { browserProof, eventually } from './browser-proof.mjs'; +import { compose, run, dockerEnvironment } from './resources.mjs'; + +const root = resolve(fileURLToPath(new URL('../..', import.meta.url))), config = locations(root, process.env), owner = await loadOwner(config); +const artifacts = process.env.SPACE_DEV_PROOF_DIR; +if (!artifacts) throw new Error('Set SPACE_DEV_PROOF_DIR to a private proof output directory outside the checkout/state.'); +const proofRelative = relative(root, resolve(artifacts)); +if (!proofRelative || (!proofRelative.startsWith('..') && !isAbsolute(proofRelative))) throw new Error('Proof output must be outside the checkout/state.'); +await mkdir(artifacts, { recursive: true }); +const origin = `http://127.0.0.1:${owner.ports.web}`, api = `http://127.0.0.1:${owner.ports.api}`; +let cookie = '', csrf = null, browser, terminal; +const checks = []; +async function request(path, body, expected = 200) { + const response = await fetch(`${api}${path}`, { method: body === undefined ? 'GET' : 'POST', headers: { ...(cookie ? { cookie } : {}), ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...(csrf ? { [csrf.headerName]: csrf.csrfToken } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000) }); + assert.equal(response.status, expected, `${path} status (no credentials/response body included)`); + if (response.headers.get('set-cookie')) cookie = response.headers.get('set-cookie').split(';')[0]; + return response.json(); +} +const marker = name => { checks.push(name); console.log(`[dev-stack-proof] PASS ${name}`); }; +async function pid(name) { return (await readJson(config.supervisor)).children.find(x => x.name === name); } +try { + await eventually(async () => { const response = await fetch(`${api}/readyz`, { signal: AbortSignal.timeout(3000) }); const value = await response.json(); return response.ok && value.ok === true; }, 'Workflows-enabled readyz'); marker('real readiness'); + assert.equal((await (await fetch(`${origin}/readyz`)).json()).ok, true); marker('Vite forwards to the isolated API port'); + for (const name of ['postgres-password', 'session-secret', 'setup-token']) assert.equal((await stat(join(config.state, 'secrets', name))).mode & 0o777, 0o600); + assert.equal((await stat(config.compose)).mode & 0o777, 0o600); marker('all generated secret/config files0600'); + const token = await readFile(join(config.state, 'secrets/setup-token'), 'utf8'); + const password = randomBytes(24).toString('hex'), email = 'dev-stack-owner@example.invalid'; + const claimed = await request('/api/setup/claim', { token, email, password }); assert.equal(claimed.isAuthenticated, true); + await request('/api/setup/claim', { token, email, password }, 409); marker('normal one-use owner claim'); + await request('/api/auth/login', { email, password }); csrf = await request('/api/auth/csrf'); marker('normal owner sign-in'); + const starter = await request('/api/setup/starter-room', {}); assert.equal(starter.room.name, 'Getting Started'); + await request('/api/setup/finish', {}); + const catalog = await request(`/api/panes?roomId=${encodeURIComponent(starter.room.id)}`); + let pane = catalog.data.find(x => x.terminalRuntimeId === 'cli:opencode'); + if (!pane) pane = await request('/api/panes', { roomId: starter.room.id, title: 'OpenCode CLI', mode: 'TERMINAL', terminalRuntimeId: 'cli:opencode', cwd: join(config.state, 'workspaces') }); + const started = await request(`/api/panes/${encodeURIComponent(pane.id)}/cli/session`, { runtimeId: 'cli:opencode' }); + const ticket = started.websocket; assert(ticket?.token && ticket.sessionId); + const url = new URL(`${api.replace('http:', 'ws:')}/api/panes/${encodeURIComponent(pane.id)}/cli/terminal`); + for (const [k, v] of Object.entries({ sessionId: ticket.sessionId, token: ticket.token, clientId: randomUUID(), initialCols: '120', initialRows: '32' })) url.searchParams.set(k, v); + terminal = new WebSocket(url, { headers: { cookie, origin } }); + let ui = '', running = false, terminalError; + terminal.on('error', error => { terminalError = error; }); + terminal.on('message', chunk => { const event = JSON.parse(chunk.toString()); if (event.type === 'output') ui += event.data; if (event.type === 'status' && event.status === 'RUNNING') running = true; }); + await eventually(async () => { + const current = await request(`/api/panes/${encodeURIComponent(pane.id)}/cli/session`); + running ||= current?.session?.status === 'RUNNING'; + if (terminalError) throw new Error('Actual CLI terminal WebSocket failed.'); + return running && /opencode/i.test(stripVTControlCharacters(ui)); + }, 'Real pinned OpenCode PTY terminal UI'); marker('source CLI host/PTY and actual OpenCode UI; no model prompt sent'); + const docker = (await compose(config, owner, ['ps', '--format', 'json'])).trim(); + const services = docker.startsWith('[') ? JSON.parse(docker) : docker.split('\n').filter(Boolean).map(line => JSON.parse(line)); + assert.equal(services.length, 2); + for (const service of services) for (const binding of service.Publishers || []) assert.equal(binding.URL, '127.0.0.1'); marker('Docker service ports bind only loopback'); + // Read-only host socket listings: assert all relevant Node listeners use loopback as well. + const sockets = await run(process.platform === 'darwin' ? 'lsof' : 'ss', process.platform === 'darwin' ? ['-nP', '-iTCP', '-sTCP:LISTEN'] : ['-ltn'], { env: dockerEnvironment() }); + for (const port of [owner.ports.api, owner.ports.web]) { + const lines = sockets.split('\n').filter(x => new RegExp(`:${port}(?:\\s|$)`).test(x)); assert(lines.length > 0); assert(lines.every(x => x.includes(`127.0.0.1:${port}`))); + } marker('API and Vite bind only loopback'); + browser = await browserProof({ state: config.state, origin, cookie, artifacts }); marker('actual browser Getting Started/terminal rendering'); + const css = join(root, 'apps/web/src/styles.css'), originalCss = await readFile(css), canary = `proof-${randomUUID()}`; + const timeOrigin = await browser.evaluate('performance.timeOrigin'); + try { + await writeFile(css, Buffer.concat([originalCss, Buffer.from(`\nhtml { --space-dev-proof: "${canary}"; }\n`)])); + await eventually(() => browser.evaluate(`getComputedStyle(document.documentElement).getPropertyValue('--space-dev-proof').includes(${JSON.stringify(canary)})`), 'Browser CSS HMR'); + assert.equal(await browser.evaluate('performance.timeOrigin'), timeOrigin, 'CSS hot reload must not require navigation/reload'); + await browser.screenshot('web-hmr.png'); marker('real web CSS HMR without page navigation'); + } finally { await writeFile(css, originalCss); } + const apiSource = join(root, 'apps/api/src/server.ts'), originalApi = await readFile(apiSource), oldApi = await pid('api'); + try { + await writeFile(apiSource, Buffer.concat([originalApi, Buffer.from(`\n// dev-stack reload proof ${canary}\n`)])); + await eventually(async () => { const next = await pid('api'); return next && next.pid !== oldApi.pid && await sameProcess(next); }, 'API source restart'); + await eventually(async () => (await (await fetch(`${api}/readyz`)).json()).ok === true, 'Ready after API restart'); marker('API source restart and readiness'); + } finally { await writeFile(apiSource, originalApi); } + // Wait for the restoration-triggered API restart before testing a package rebuild. + await delay(1500); + const workerBefore = await pid('worker'), apiBefore = await pid('api'); + const packageFile = join(root, 'packages/contracts/src/index.ts'), originalPackage = await readFile(packageFile); + try { + await writeFile(packageFile, Buffer.concat([originalPackage, Buffer.from(`\nexport const spaceDevStackProofCanary = ${JSON.stringify(canary)};\n`)])); + await eventually(async () => { const w = await pid('worker'), p = await pid('api'); return w && p && w.pid !== workerBefore.pid && p.pid !== apiBefore.pid && await sameProcess(w) && await sameProcess(p); }, 'Rebuilt package picked up by API/worker', 300000); + await eventually(async () => (await (await fetch(`${api}/readyz`)).json()).ok === true, 'Ready after package rebuild'); marker('shared package rebuilt and API/worker restarted'); + assert((await readFile(join(root, 'packages/contracts/dist/index.js'), 'utf8')).includes(canary), 'Changed shared package source must actually reach built output'); + } finally { await writeFile(packageFile, originalPackage); } + await writeFile(join(artifacts, 'acceptance.json'), JSON.stringify({ source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, checks, model_inference: false, real_operator_data: false }, null, 2) + '\n'); +} finally { terminal?.close(); await browser?.close(); } diff --git a/scripts/dev-stack/browser-proof.mjs b/scripts/dev-stack/browser-proof.mjs new file mode 100644 index 000000000..861443d9c --- /dev/null +++ b/scripts/dev-stack/browser-proof.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import WebSocket from 'ws'; +import { delay, descendants, processIdentity, sameProcess, stopRecorded } from './processes.mjs'; + +export async function eventually(fn, label, timeout = 120000) { + const until = Date.now() + timeout; let last; + while (Date.now() < until) { try { const value = await fn(); if (value) return value; } catch (error) { last = error; } await delay(400); } + throw new Error(`${label} did not succeed${last ? ` (${last.message})` : ''}.`); +} +export async function browserProof({ state, origin, cookie, artifacts }) { + const binary = process.env.SPACE_DEV_BROWSER || (process.platform === 'darwin' ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : '/usr/bin/google-chrome'); + if (!existsSync(binary)) throw new Error('Hosted Chrome is missing. Set SPACE_DEV_BROWSER to an existing reviewed browser; no hidden download/fallback.'); + const profile = join(state, 'browser-proof'); await mkdir(profile, { mode: 0o700 }); + const child = spawn(binary, ['--headless=new', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check', '--disable-background-networking', '--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: 'ignore', env: { PATH: process.env.PATH, HOME: join(state, 'home'), TMPDIR: join(state, 'tmp') } }); + let startError; + child.once('error', error => { startError = error; }); + const identity = new Promise(done => { child.once('spawn', () => { void processIdentity(child.pid).then(value => done({ pid: child.pid, identity: value }), () => done(null)); }); child.once('error', () => done(null)); }); + let socket, next = 1; const pending = new Map(); + async function close() { + if (socket?.readyState === WebSocket.OPEN) socket.close(); + const original = await identity; + if (original && await sameProcess(original)) await stopRecorded(await descendants(original)); + } + try { + const [port, path] = (await eventually(async () => { if (startError || child.exitCode !== null) throw new Error('Hosted Chrome failed to start.'); return (await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).trim(); }, 'Chrome CDP startup')).split('\n'); + socket = new WebSocket(`ws://127.0.0.1:${port}${path}`); + await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); }); + socket.on('message', data => { const response = JSON.parse(data.toString()); const item = pending.get(response.id); if (item) { pending.delete(response.id); clearTimeout(item.timer); if (response.error) item.reject(new Error(response.error.message)); else item.resolve(response.result); } }); + const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { + const id = next++, timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP ${method} timed out.`)); }, 30000); + pending.set(id, { resolve, reject, timer }); socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); + }); + const target = await send('Target.createTarget', { url: 'about:blank' }); + const { sessionId } = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); + const call = (method, params) => send(method, params, sessionId); + const evaluate = async expression => { const result = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (result.exceptionDetails) throw new Error('Browser evaluation failed.'); return result.result.value; }; + await call('Page.enable'); await call('Network.enable'); + const index = cookie.indexOf('='); + assert(index > 0); await call('Network.setCookie', { url: origin, name: cookie.slice(0, index), value: cookie.slice(index + 1), httpOnly: true, sameSite: 'Lax' }); + await call('Emulation.setDeviceMetricsOverride', { width: 1440, height: 1000, deviceScaleFactor: 1, mobile: false }); + await call('Page.navigate', { url: `${origin}/` }); + await eventually(() => evaluate('document.body.innerText.toLowerCase().includes("getting started")'), 'Actual Getting Started web room'); + await evaluate(`(() => { const room = [...document.querySelectorAll('button,[role="button"]')].find(x => /getting started/i.test(x.textContent || x.getAttribute('title') || '')); room?.click(); return Boolean(room); })()`); + await eventually(() => evaluate('Boolean(document.querySelector(".xterm-screen")?.getBoundingClientRect().width)'), 'Rendered terminal pane in the actual browser'); + await mkdir(artifacts, { recursive: true }); + await writeFile(join(artifacts, 'opencode-pane.png'), Buffer.from((await call('Page.captureScreenshot', { format: 'png' })).data, 'base64')); + return { evaluate, async screenshot(name) { await writeFile(join(artifacts, name), Buffer.from((await call('Page.captureScreenshot', { format: 'png' })).data, 'base64')); }, close }; + } catch (error) { await close(); throw error; } +} diff --git a/scripts/dev-stack/lifecycle-proof.mjs b/scripts/dev-stack/lifecycle-proof.mjs new file mode 100644 index 000000000..585bb2552 --- /dev/null +++ b/scripts/dev-stack/lifecycle-proof.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node +// Real hosted acceptance. This is deliberately separate from the daemon-free unit suite. +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { createServer } from 'node:net'; +import { appendFile, lstat, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { extractPins, loadOwner, locations, ownerLabel, readJson } from './state.mjs'; +import { delay, descendants, processIdentity, sameProcess, stopRecorded } from './processes.mjs'; +import { assertOwnedDocker, dockerEnvironment, run } from './resources.mjs'; + +if (!['linux', 'darwin'].includes(process.platform)) throw new Error('The real stack proof requires Linux or Intel macOS with an existing Docker daemon.'); +const root = resolve(fileURLToPath(new URL('../..', import.meta.url))); +const artifacts = resolve(process.env.SPACE_DEV_PROOF_DIR || ''); +const proofRelative = relative(root, artifacts); +if (!process.env.SPACE_DEV_PROOF_DIR || !proofRelative || (!proofRelative.startsWith('..') && !isAbsolute(proofRelative))) throw new Error('SPACE_DEV_PROOF_DIR must be outside the checkout. Never upload the private state tree.'); +await mkdir(artifacts, { recursive: true, mode: 0o700 }); +const candidate = process.env.SPACE_DEV_CANDIDATE; +assert.match(candidate || '', /^[a-f0-9]{40}$/, 'An exact reviewed candidate SHA is required.'); +assert.equal((await run('git', ['rev-parse', 'HEAD'], { cwd: root, env: dockerEnvironment() })).trim(), candidate); +const env = { ...dockerEnvironment(), CI: 'true', SPACE_DEV_REDACT_SETUP: 'true', SPACE_DEV_PROOF_DIR: artifacts, SPACE_DEV_CANDIDATE: candidate }; +const nonce = randomBytes(12).toString('hex'), proofLabel = 'io.spaceapp.dev-stack.proof'; +const sentinelName = `spaceapp-independent-${nonce}`, sentinelVolume = `${sentinelName}-data`; +const configs = [], checks = [], usedPorts = new Set([4910, 4911]); +let sentinel, sentinelRecord, sentinelContainerCreated = false, sentinelVolumeCreated = false, failure; +const mark = name => { checks.push(name); console.log(`[dev-stack-lifecycle] PASS ${name}`); }; +const docker = (args, options = {}) => run('docker', args, { env: dockerEnvironment(), ...options }); +const exists = async path => Boolean(await lstat(path).catch(e => { if (e.code === 'ENOENT') return null; throw e; })); +async function port() { + for (;;) { + const selected = await new Promise((done, reject) => { const server = createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const selectedPort = server.address().port; server.close(() => done(selectedPort)); }); }); + if (!usedPorts.has(selected)) { usedPorts.add(selected); return selected; } + } +} +async function configuration(suffix) { + const settings = { ...env, SPACE_DEV_STATE_DIR: `.dev-stack-proof-${suffix}` }; + for (const key of ['API', 'WEB', 'POSTGRES', 'TEMPORAL']) settings[`SPACE_DEV_${key}_PORT`] = String(await port()); + const config = locations(root, settings); + assert.equal(await exists(config.state), false, 'A proof must never adopt existing state.'); + configs.push({ config, env: settings }); + return { config, env: settings }; +} +async function capture(settings, name) { + const child = spawn(process.execPath, ['scripts/dev-stack.mjs'], { cwd: root, env: settings.env, stdio: ['ignore', 'pipe', 'pipe'] }); + let output = '', writes = Promise.resolve(), error; + const file = join(artifacts, `${name}.log`); + const log = chunk => { const text = chunk.toString(); output += text; writes = writes.then(() => appendFile(file, text, { mode: 0o600 })); }; + child.stdout.on('data', log); child.stderr.on('data', log); + child.once('error', value => { error = value; }); + const closed = new Promise(done => child.once('close', async (code, signal) => { await writes; done({ code, signal }); })); + const stack = { child, closed, output: () => output, error: () => error, ...settings }; + return stack; +} +async function waitReady(stack) { + const deadline = Date.now() + 900000; + while (Date.now() < deadline) { + if (stack.error() || stack.child.exitCode !== null || stack.child.signalCode !== null) throw new Error('Stack exited before real readiness; see its retained redacted log.'); + try { + const response = await fetch(`http://127.0.0.1:${stack.config.ports.api}/readyz`, { signal: AbortSignal.timeout(6000) }); + if (response.ok && (await response.json()).ok === true) return; + } catch { /* Startup can legitimately take time before the API binds. */ } + await delay(1000); + } + throw new Error('Stack readiness exceeded15minutes; no acceptance is claimed.'); +} +async function processSnapshot(config) { + const record = await readJson(config.supervisor), result = []; + for (const item of record.children) if (await sameProcess(item)) result.push(...await descendants(item)); + return result; +} +async function assertStopped(records) { assert.equal((await Promise.all(records.map(record => sameProcess(record)))).some(Boolean), false, 'An owned source/PTY process survived cleanup.'); } +async function resources(owner, kind) { + await assertOwnedDocker(owner); + return (await docker([kind, 'ls', ...(kind === 'container' ? ['-a'] : []), '-q', '--filter', `label=com.docker.compose.project=${owner.project}`])).trim().split(/\s+/).filter(Boolean); +} +async function assertReset(config, owner, records) { + await assertStopped(records); + for (const kind of ['container', 'volume', 'network']) { + assert.equal((await resources(owner, kind)).length, 0, `Owned ${kind} survived reset.`); + assert.equal((await docker([kind, 'ls', ...(kind === 'container' ? ['-a'] : []), '-q', '--filter', `label=${ownerLabel}=${owner.nonce}`])).trim(), ''); + } + assert.equal(await exists(config.state), false, 'Reset left the private state directory.'); +} +async function assertSentinels() { + assert(await sameProcess(sentinelRecord), 'Unrelated sentinel process was stopped.'); + for (const p of [4910, 4911]) assert.equal(await (await fetch(`http://127.0.0.1:${p}`, { signal: AbortSignal.timeout(3000) })).text(), nonce); + assert.equal((await docker(['container', 'inspect', '--format', '{{.State.Running}}', sentinelName])).trim(), 'true'); + assert.equal((await docker(['volume', 'inspect', '--format', `{{index .Labels "${proofLabel}"}}`, sentinelVolume])).trim(), nonce); +} +async function action(settings, verb, options = {}) { + return run(process.execPath, ['scripts/dev-stack.mjs', verb], { cwd: root, env: settings.env, output: text => process.stdout.write(text), timeout: 240000, ...options }); +} +try { + const sentinelSource = `const http=require('node:http'); const servers=[4910,4911].map(port=>http.createServer((_q,r)=>r.end(${JSON.stringify(nonce)})).listen(port,'127.0.0.1')); let ready=0; for(const s of servers){s.on('listening',()=>{if(++ready===2)process.send('ready')});s.on('error',()=>process.exit(1))}`; + sentinel = spawn(process.execPath, ['-e', sentinelSource], { env: { PATH: process.env.PATH }, stdio: ['ignore', 'ignore', 'inherit', 'ipc'] }); + await new Promise((done, reject) => { sentinel.once('message', done); sentinel.once('error', reject); sentinel.once('exit', () => reject(new Error('Could not reserve synthetic installed ports4910/4911.'))); }); + sentinelRecord = { pid: sentinel.pid, identity: await processIdentity(sentinel.pid) }; + const pins = extractPins(await readFile(join(root, 'packages/run-spaceapp/templates/compose.yml'), 'utf8'), await readFile(join(root, 'Dockerfile'), 'utf8')); + await docker(['pull', pins.postgres], { timeout: 300000 }); + await docker(['volume', 'create', '--label', `${proofLabel}=${nonce}`, sentinelVolume]); sentinelVolumeCreated = true; + await docker(['run', '-d', '--name', sentinelName, '--label', `${proofLabel}=${nonce}`, '--network', 'none', '--read-only', '--mount', `type=volume,source=${sentinelVolume},target=/sentinel`, '--entrypoint', '/bin/sh', pins.postgres, '-c', 'while :; do sleep 60; done']); sentinelContainerCreated = true; + // Keep the shell simple, and assert it is really running before testing our cleanup. + await assertSentinels(); + const first = await configuration('a'), stack = await capture(first, 'normal-stack'); + await waitReady(stack); const owner = await loadOwner(first.config); + mark('normal workflows-enabled startup with installed ports4910/4911 already occupied'); + const firstSecretHashes = await Promise.all(['postgres-password', 'session-secret'].map(async name => createHash('sha256').update(await readFile(join(first.config.state, 'secrets', name))).digest('hex'))); + await run(process.execPath, ['scripts/dev-stack/acceptance.mjs'], { cwd: root, env: first.env, timeout: 900000, output: text => process.stdout.write(text) }); + const records = await processSnapshot(first.config); + assert((await resources(owner, 'volume')).length > 0); + await action(first, 'down'); assert.equal((await stack.closed).code, 0); + await assertStopped(records); assert.equal((await resources(owner, 'container')).length, 0); + assert((await resources(owner, 'volume')).length > 0, 'down should retain the dev database volume.'); + await assertSentinels(); mark('down stops only owned services and preserves state/database and all independent sentinels'); + const manifestBefore = await readFile(first.config.manifest); + const broken = { ...first.env, DOCKER_HOST: 'tcp://127.0.0.1:9' }; delete broken.DOCKER_CONTEXT; delete broken.DOCKER_TLS_VERIFY; delete broken.DOCKER_CERT_PATH; + await assert.rejects(action(first, 'reset', { env: broken, timeout: 30000 })); + assert.deepEqual(await readFile(first.config.manifest), manifestBefore); await assertSentinels(); + mark('failed Docker cleanup propagates failure and retains owned recovery state'); + await action(first, 'reset'); await assertReset(first.config, owner, records); await assertSentinels(); + mark('normal reset removes owned containers/volumes/networks/processes/state only'); + const second = await configuration('b'), failing = await capture(second, 'forced-failure-stack'); + assert(Object.values(second.config.ports).every(p => !Object.values(first.config.ports).includes(p))); + await waitReady(failing); const secondOwner = await loadOwner(second.config); + assert.notEqual(secondOwner.project, owner.project); + const secondSecretHashes = await Promise.all(['postgres-password', 'session-secret'].map(async name => createHash('sha256').update(await readFile(join(second.config.state, 'secrets', name))).digest('hex'))); + assert(firstSecretHashes.every((hash, i) => hash !== secondSecretHashes[i])); + mark('second fresh state uses alternate ports, project ownership and new generated secrets'); + const failureRecords = await processSnapshot(second.config), worker = (await readJson(second.config.supervisor)).children.find(x => x.name === 'worker'); + assert(worker && await sameProcess(worker)); process.kill(worker.pid, 'SIGKILL'); + let failureTimer; + const result = await Promise.race([failing.closed, new Promise((_done, reject) => { failureTimer = setTimeout(() => reject(new Error('Required-child failure left a half-running stack.')), 180000); })]).finally(() => clearTimeout(failureTimer)); + assert.notEqual(result.code, 0); assert.match(failing.output(), /worker exited unexpectedly/); + await assertStopped(failureRecords); assert.equal((await resources(secondOwner, 'container')).length, 0); + assert(await exists(second.config.manifest)); await assertSentinels(); + mark('an actual killed worker is named, produces failure and stops the other owned services'); + await action(second, 'reset'); await assertReset(second.config, secondOwner, failureRecords); await assertSentinels(); + mark('failure-path reset cleans every owned resource and leaves unrelated sentinels alive'); + await run('git', ['diff', '--exit-code'], { cwd: root, env: dockerEnvironment() }); + assert.equal((await run('git', ['rev-parse', 'HEAD'], { cwd: root, env: dockerEnvironment() })).trim(), candidate); + mark('exact candidate retained; every temporary reload canary restored byte-for-byte'); +} catch (error) { failure = error; } +finally { + for (const settings of [...configs].reverse()) if (await exists(settings.config.manifest)) { + try { await action(settings, 'reset'); } + catch (error) { failure ||= error; console.error('Owned recovery state remains; see the named lifecycle failure.'); } + } + // Proof-only sentinel removal is separately label-checked; the launcher never owns it. + try { + if (sentinelContainerCreated) { assert.equal((await docker(['container', 'inspect', '--format', `{{index .Config.Labels "${proofLabel}"}}`, sentinelName])).trim(), nonce); await docker(['container', 'rm', '-f', sentinelName]); } + if (sentinelVolumeCreated) { assert.equal((await docker(['volume', 'inspect', '--format', `{{index .Labels "${proofLabel}"}}`, sentinelVolume])).trim(), nonce); await docker(['volume', 'rm', sentinelVolume]); } + if (sentinelRecord) await stopRecorded([sentinelRecord]); + } catch (error) { failure ||= error; } + await writeFile(join(artifacts, 'lifecycle.json'), JSON.stringify({ source: candidate, platform: process.platform, status: failure ? 'FAIL' : 'PASS', checks, error: failure?.message || null, provider_inference: false }, null, 2) + '\n', { mode: 0o600 }); +} +if (failure) throw failure; diff --git a/scripts/dev-stack/opencode-wrapper.mjs b/scripts/dev-stack/opencode-wrapper.mjs new file mode 100644 index 000000000..60f8a2325 --- /dev/null +++ b/scripts/dev-stack/opencode-wrapper.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { mkdir, readFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Copied verbatim by our orchestrator into its private cli/bin, never globally. +const state = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const provider = join(state, 'providers', 'opencode'); +const executable = join(state, 'cli', 'vendor', 'opencode', 'node_modules', 'opencode-ai', 'bin', 'opencode'); +const args = process.argv.slice(2); +if (args[0] === '--run-inner') args.shift(); +if (args[0] === 'credential-status') { await readFile(executable); console.log('READY'); } +else if (args[0] === 'credential-observation') { console.log(`OBSERVATION:${createHash('sha256').update(await readFile(executable)).digest('hex')}`); } +else if (['credential-smoke', 'login', 'auth', 'run', 'credential-install'].includes(args[0])) { console.error('This contributor wrapper opens the UI only; provider authentication/inference is disabled.'); process.exitCode = 64; } +else { + for (const folder of ['', 'tmp', 'config', 'data', 'cache', 'state']) await mkdir(join(provider, folder), { recursive: true, mode: 0o700 }); + const child = spawn(process.execPath, [executable, ...args], { + stdio: 'inherit', + env: { PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin', TERM: 'xterm-256color', LANG: 'en_US.UTF-8', HOME: provider, TMPDIR: join(provider, 'tmp'), XDG_CONFIG_HOME: join(provider, 'config'), XDG_DATA_HOME: join(provider, 'data'), XDG_CACHE_HOME: join(provider, 'cache'), XDG_STATE_HOME: join(provider, 'state'), OPENCODE_DISABLE_AUTOUPDATE: 'true', OPENCODE_DISABLE_SHARE: 'true' } + }); + child.once('error', () => { console.error('Pinned state-local OpenCode could not start.'); process.exitCode = 1; }); + child.once('exit', (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exitCode = code ?? 1; }); + for (const signal of ['SIGTERM', 'SIGINT']) process.once(signal, () => child.kill(signal)); +} diff --git a/scripts/dev-stack/processes.mjs b/scripts/dev-stack/processes.mjs new file mode 100644 index 000000000..72e06cded --- /dev/null +++ b/scripts/dev-stack/processes.mjs @@ -0,0 +1,64 @@ +import { execFile } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { promisify } from 'node:util'; +const exec = promisify(execFile); +export const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); +export async function processIdentity(pid) { + if (!Number.isInteger(pid) || pid < 2) return null; + if (process.platform === 'linux') { + try { + const stat = await readFile(`/proc/${pid}/stat`, 'utf8'); + const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' '); + return ['Z', 'X'].includes(fields[0]) ? null : `linux:${fields[19]}`; + } catch (e) { if (['ENOENT', 'ESRCH'].includes(e.code)) return null; throw e; } + } + if (process.platform === 'darwin') { + try { + const { stdout } = await exec('ps', ['-p', String(pid), '-o', 'stat=', '-o', 'lstart=']); + const [status, ...start] = stdout.trim().split(/\s+/); + return !status || /^[ZX]/.test(status) ? null : `darwin:${start.join(' ')}`; + } catch (e) { if (e.code === 1) return null; throw e; } + } + throw new Error('Dev-stack processes require Linux, macOS or Windows11 WSL2.'); +} +export async function sameProcess(record, identity = processIdentity) { return Boolean(record?.identity && await identity(record.pid) === record.identity); } +export async function descendants(root, { identity = processIdentity, list = () => exec('ps', ['-axo', 'pid=,ppid=']) } = {}) { + if (!await sameProcess(root, identity)) throw new Error('Owned process root changed before descendant collection; recovery state retained.'); + const { stdout } = await list(); + const rows = stdout.trim().split('\n').map(line => line.trim().split(/\s+/).map(Number)); + const found = new Set([root.pid]); + for (let changed = true; changed;) { changed = false; for (const [child, parent] of rows) if (found.has(parent) && !found.has(child)) { found.add(child); changed = true; } } + const records = []; + for (const child of [...found].reverse()) { const value = await identity(child); if (value) records.push({ pid: child, identity: value }); } + if (!await sameProcess(root, identity)) throw new Error('Owned process root changed during descendant collection; refusing to adopt its replacement tree.'); + return records; +} +/** Stop admission first, then drain every spawn-to-registration critical section. */ +export class LaunchBarrier { + pending = new Set(); + stopping = false; + track(register) { + if (this.stopping) return Promise.reject(new Error('Launch interrupted by shutdown.')); + // Register the promise before running any spawn code, even synchronous callbacks. + const job = Promise.resolve().then(register); + this.pending.add(job); + return job.finally(() => this.pending.delete(job)); + } + async stop() { this.stopping = true; await Promise.allSettled([...this.pending]); } +} +export async function stopRecorded(records, { signal = process.kill, identity = processIdentity, timeout = 10000 } = {}) { + for (const r of records) if (await sameProcess(r, identity)) { try { signal(r.pid, 'SIGTERM'); } catch (e) { if (e.code !== 'ESRCH') throw e; } } + const deadline = Date.now() + timeout; + while (Date.now() < deadline && (await Promise.all(records.map(r => sameProcess(r, identity)))).some(Boolean)) await delay(100); + for (const r of records) if (await sameProcess(r, identity)) { try { signal(r.pid, 'SIGKILL'); } catch (e) { if (e.code !== 'ESRCH') throw e; } } + await delay(100); + if ((await Promise.all(records.map(r => sameProcess(r, identity)))).some(Boolean)) throw new Error('An owned process did not stop; recovery state retained.'); +} +export function redact(text, secretValues) { let out = String(text); for (const value of secretValues.filter(Boolean)) out = out.split(value).join('[redacted]'); return out; } +export function lineRedactor(secretValues, write) { + let pending = ''; + return { + chunk(value) { pending += value.toString('utf8'); let index; while ((index = pending.indexOf('\n')) !== -1) { write(redact(pending.slice(0, index + 1), secretValues)); pending = pending.slice(index + 1); } }, + end() { if (pending) write(redact(pending, secretValues)); pending = ''; } + }; +} diff --git a/scripts/dev-stack/resources.mjs b/scripts/dev-stack/resources.mjs new file mode 100644 index 000000000..31b4ada6a --- /dev/null +++ b/scripts/dev-stack/resources.mjs @@ -0,0 +1,54 @@ +import { spawn } from 'node:child_process'; +import { ownerLabel } from './state.mjs'; +import { descendants, lineRedactor, processIdentity, sameProcess, stopRecorded } from './processes.mjs'; + +export function dockerEnvironment(inherited = process.env) { + return Object.fromEntries(['PATH', 'HOME', 'DOCKER_HOST', 'DOCKER_CONTEXT', 'DOCKER_CONFIG', 'DOCKER_CERT_PATH', 'DOCKER_TLS_VERIFY', 'SSH_AUTH_SOCK'].filter(key => inherited[key]).map(key => [key, inherited[key]])); +} +export function run(command, args, { cwd, env, secrets = [], output = () => {}, timeout = 600000, signal } = {}) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { reject(new Error('Command cancelled before startup.')); return; } + const child = spawn(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }); + let text = '', settled = false, timedOut = false; + const identity = process.platform === 'win32' ? Promise.resolve(null) : new Promise((resolveIdentity, rejectIdentity) => { + child.once('spawn', () => { void processIdentity(child.pid).then(resolveIdentity, rejectIdentity); }); + child.once('error', () => resolveIdentity(null)); + }); + const cancel = async () => { + const original = await identity; + if (original && await sameProcess({ pid: child.pid, identity: original })) await stopRecorded(await descendants({ pid: child.pid, identity: original })); + else if (process.platform === 'win32') child.kill(); + }; + // Never fall back to an unverified POSIX PID if identity discovery fails. + const cancelled = () => { void cancel().catch(() => { output('[dev-stack] Owned command cleanup failed; recovery state is required.\n'); }); }; + signal?.addEventListener('abort', cancelled, { once: true }); + const redactor = lineRedactor(secrets, chunk => { text += chunk; output(chunk); }); + const timer = setTimeout(() => { timedOut = true; cancelled(); }, timeout); + child.stdout.on('data', x => redactor.chunk(x)); child.stderr.on('data', x => redactor.chunk(x)); + child.once('error', error => { settled = true; clearTimeout(timer); signal?.removeEventListener('abort', cancelled); reject(new Error(`${command} could not start (${error.code || 'error'}).`)); }); + child.once('close', (code, exitSignal) => { clearTimeout(timer); signal?.removeEventListener('abort', cancelled); redactor.end(); if (settled) return; if (code === 0 && !signal?.aborted && !timedOut) resolve(text); else reject(new Error(`${command} failed (${timedOut ? 'timeout' : code ?? exitSignal}); see the redacted output.`)); }); + }); +} +export const composeArgs = (config, owner, args) => ['compose', '--project-name', owner.project, '--file', config.compose, ...args]; +export async function assertOwnedDocker(owner, invoke = run, env = dockerEnvironment()) { + for (const kind of ['container', 'volume', 'network']) { + const ids = (await invoke('docker', [kind, 'ls', ...(kind === 'container' ? ['-a'] : []), '-q', '--filter', `label=com.docker.compose.project=${owner.project}`], { env })).trim().split(/\s+/).filter(Boolean); + for (const id of ids) { + const raw = await invoke('docker', [kind, 'inspect', '--format', kind === 'container' ? '{{json .Config.Labels}}' : '{{json .Labels}}', id], { env }); + const labels = JSON.parse(raw); + if (labels?.[ownerLabel] !== owner.nonce) throw new Error(`Foreign ${kind} shares this project name; refusing cleanup/start.`); + } + } +} +export async function compose(config, owner, args, options = {}) { + return run('docker', composeArgs(config, owner, args), { env: dockerEnvironment(), ...options }); +} +export async function removeOwnedDocker(config, owner, { volumes = false, output, secrets } = {}) { + await assertOwnedDocker(owner); + await compose(config, owner, ['down', '--remove-orphans', ...(volumes ? ['--volumes'] : [])], { output, secrets, timeout: 120000 }); + await assertOwnedDocker(owner); + for (const kind of ['container', 'network', ...(volumes ? ['volume'] : [])]) { + const remaining = await run('docker', [kind, 'ls', ...(kind === 'container' ? ['-a'] : []), '-q', '--filter', `label=com.docker.compose.project=${owner.project}`], { env: dockerEnvironment() }); + if (remaining.trim()) throw new Error(`Owned ${kind} remains; recovery state retained.`); + } +} diff --git a/scripts/dev-stack/state.mjs b/scripts/dev-stack/state.mjs new file mode 100644 index 000000000..1d615f086 --- /dev/null +++ b/scripts/dev-stack/state.mjs @@ -0,0 +1,106 @@ +import { randomBytes } from 'node:crypto'; +import { chmod, lstat, mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; + +export const ownerLabel = 'io.spaceapp.dev-stack.owner'; +export const privateWrite = async (file, value, options = {}) => { + const existing = await lstat(file).catch(e => { if (e.code === 'ENOENT') return null; throw e; }); + if (existing && (!existing.isFile() || existing.isSymbolicLink())) throw new Error('Refusing linked/non-file private output.'); + await writeFile(file, value, { mode: 0o600, ...options }); + await chmod(file, 0o600); +}; +export async function readJson(file) { return JSON.parse(await readFile(file, 'utf8')); } +export function locations(root, env = {}) { + const name = env.SPACE_DEV_STATE_DIR || '.dev-stack'; + // A direct, recognizable child only; down/reset never accept an arbitrary deletion target. + if (isAbsolute(name) || !/^\.dev-stack(?:-[a-zA-Z0-9_-]+)?$/.test(name)) { + throw new Error('SPACE_DEV_STATE_DIR must be .dev-stack or .dev-stack- inside this checkout.'); + } + const state = join(resolve(root), name); + const port = (key, fallback) => { + const raw = env[key] ?? String(fallback); + if (!/^\d+$/.test(raw) || +raw < 1024 || +raw > 65535 || [4910, 4911].includes(+raw)) throw new Error(`Invalid/nonisolated ${key}.`); + return +raw; + }; + const ports = { api: port('SPACE_DEV_API_PORT', 4810), web: port('SPACE_DEV_WEB_PORT', 4811), postgres: port('SPACE_DEV_POSTGRES_PORT', 54329), temporal: port('SPACE_DEV_TEMPORAL_PORT', 47233) }; + if (new Set(Object.values(ports)).size !== 4) throw new Error('Dev-stack ports must be distinct.'); + return { root: resolve(root), state, ports, manifest: join(state, 'owner.json'), compose: join(state, 'compose.json'), supervisor: join(state, 'supervisor.json') }; +} +export async function assertPlainState(config) { + const root = await realpath(config.root); + const info = await lstat(config.state).catch(e => { if (e.code === 'ENOENT') return null; throw e; }); + if (info && (!info.isDirectory() || info.isSymbolicLink() || dirname(await realpath(config.state)) !== root)) throw new Error('Refusing linked/non-owned dev-stack state.'); +} +export async function loadOwner(config) { + await assertPlainState(config); + const info = await lstat(config.manifest); + if (!info.isFile() || info.isSymbolicLink()) throw new Error('Invalid dev-stack owner manifest.'); + const owner = await readJson(config.manifest); + if (owner.version !== 1 || owner.root !== await realpath(config.root) || owner.state !== await realpath(config.state) || !/^[a-f0-9]{32}$/.test(owner.nonce) || owner.project !== `spaceapp-dev-${owner.nonce.slice(0, 16)}`) throw new Error('State does not belong to this checkout.'); + for (const directory of ['secrets', 'logs', 'run', 'home', 'tmp', 'cli', 'cli/bin', 'cli/vendor', 'providers', 'providers/opencode']) { + const info = await lstat(join(config.state, directory)).catch(e => { if (e.code === 'ENOENT') return null; throw e; }); + if (info && (!info.isDirectory() || info.isSymbolicLink())) throw new Error('Refusing linked/non-directory private state path.'); + } + return owner; +} +export async function initialize(config) { + await assertPlainState(config); + const existing = await lstat(config.state).catch(e => { if (e.code === 'ENOENT') return null; throw e; }); + if (existing) return loadOwner(config); // Never adopt an unknown/nonempty directory. + await mkdir(config.state, { mode: 0o700 }); + const nonce = randomBytes(16).toString('hex'); + const owner = { version: 1, root: await realpath(config.root), state: await realpath(config.state), nonce, project: `spaceapp-dev-${nonce.slice(0, 16)}`, ports: config.ports }; + await privateWrite(config.manifest, JSON.stringify(owner, null, 2) + '\n', { flag: 'wx' }); + for (const sub of ['secrets', 'logs', 'run', 'home', 'tmp', 'workspaces', 'memory', 'artifacts', 'providers/opencode', 'cli/bin', 'cli/vendor']) await mkdir(join(config.state, sub), { recursive: true, mode: 0o700 }); + for (const secret of ['postgres-password', 'session-secret', 'setup-token']) await privateWrite(join(config.state, 'secrets', secret), randomBytes(32).toString('hex'), { flag: 'wx' }); + return owner; +} +export function extractPins(composeSource, dockerfile) { + const image = service => { + const section = composeSource.match(new RegExp(`^ ${service}:\\r?\\n([\\s\\S]*?)(?=^ [a-zA-Z]|^networks:|$(?![\\s\\S]))`, 'm'))?.[1]; + const pin = section?.match(/^ {4}image:\s*([^\s]+@sha256:[a-f0-9]{64})\s*$/m)?.[1]; + if (!pin) throw new Error(`No canonical digest pin for ${service}.`); + return pin; + }; + const opencode = dockerfile.match(/\bopencode-ai@([0-9]+\.[0-9]+\.[0-9]+)\b/)?.[1]; + if (!opencode) throw new Error('No pinned opencode-ai version in Dockerfile.'); + return { postgres: image('postgres'), temporal: image('temporal'), opencode }; +} +export function composeDocument(owner, pins, password) { + const labels = { [ownerLabel]: owner.nonce }; + return { + services: { + postgres: { image: pins.postgres, labels, environment: { POSTGRES_USER: 'spaceapp', POSTGRES_DB: 'spaceapp_dev', POSTGRES_PASSWORD: password }, ports: [`127.0.0.1:${owner.ports.postgres}:5432`], volumes: ['postgres:/var/lib/postgresql/data'], healthcheck: { test: ['CMD-SHELL', 'pg_isready -U spaceapp -d spaceapp_dev'], interval: '2s', timeout: '5s', retries: 90 } }, + temporal: { image: pins.temporal, labels, depends_on: { postgres: { condition: 'service_healthy' } }, environment: { DB: 'postgres12', DB_PORT: '5432', POSTGRES_USER: 'spaceapp', POSTGRES_PWD: password, POSTGRES_SEEDS: 'postgres', DEFAULT_NAMESPACE: 'spaceapp-dev', DYNAMIC_CONFIG_FILE_PATH: 'config/dynamicconfig/docker.yaml' }, entrypoint: ['/bin/sh', '-ec'], command: ['exec /etc/temporal/entrypoint.sh autosetup'], ports: [`127.0.0.1:${owner.ports.temporal}:7233`], healthcheck: { test: ['CMD-SHELL', 'temporal operator cluster health --address 127.0.0.1:7233 >/dev/null && temporal operator namespace describe --namespace spaceapp-dev --address 127.0.0.1:7233 >/dev/null'], interval: '3s', timeout: '10s', retries: 90 } } + }, + volumes: { postgres: { labels } }, networks: { default: { labels } } + }; +} +export async function secrets(config) { + const read = async name => { const file = join(config.state, 'secrets', name); const info = await lstat(file); if (!info.isFile() || info.isSymbolicLink()) throw new Error('Secret must be a regular owned-state file.'); return readFile(file, 'utf8'); }; + return { password: await read('postgres-password'), session: await read('session-secret'), token: await read('setup-token').catch(e => { if (e.code === 'ENOENT') return ''; throw e; }) }; +} +export function serviceEnvironment(config, owner, secret, inherited = {}) { + const p = path => join(config.state, path); + const env = { + PATH: inherited.PATH || '/usr/local/bin:/usr/bin:/bin', LANG: 'en_US.UTF-8', TERM: 'xterm-256color', NODE_ENV: 'development', + HOME: p('home'), TMPDIR: p('tmp'), XDG_CONFIG_HOME: p('home/config'), XDG_DATA_HOME: p('home/data'), XDG_CACHE_HOME: p('home/cache'), XDG_STATE_HOME: p('home/state'), + npm_config_cache: p('npm-cache'), ELECTRON_SKIP_BINARY_DOWNLOAD: '1', + SPACE_DEV_STACK: 'true', SPACE_INSTANCE: 'dev', SPACE_PUBLIC_DISTRIBUTION: 'true', SPACE_OPTIONAL_MODULES: 'none', SPACE_DEV_LOGIN: 'false', SPACE_SESSION_SECRET: secret.session, SPACE_SETUP_TOKEN_FILE: p('secrets/setup-token'), + SPACE_API_HOST: '127.0.0.1', SPACE_API_PORT: String(owner.ports.api), SPACE_API_ORIGIN: `http://127.0.0.1:${owner.ports.api}`, SPACE_WEB_HOST: '127.0.0.1', SPACE_WEB_PORT: String(owner.ports.web), SPACE_BROWSER_EVIDENCE_TARGET_ORIGIN: `http://127.0.0.1:${owner.ports.web}`, + SPACE_RUNTIME_STORE: 'postgres', SPACE_DATABASE_URL: `postgres://spaceapp:${encodeURIComponent(secret.password)}@127.0.0.1:${owner.ports.postgres}/spaceapp_dev`, SPACE_TEMPORAL_ADDRESS: `127.0.0.1:${owner.ports.temporal}`, SPACE_TEMPORAL_NAMESPACE: 'spaceapp-dev', SPACE_TEMPORAL_TASK_QUEUE: `spaceapp-dev-${owner.nonce.slice(0, 16)}`, SPACEAPP_WORKFLOWS_ENABLED: 'true', SPACE_CLI_ENABLED: 'true', + SPACE_CLI_HOST_SOCKET: p('run/cli.sock'), SPACE_CLI_ADMIN_HOST_SOCKET: p('run/disabled-admin.sock'), SPACE_CLI_COMMAND_PATH: p('cli/bin'), SPACE_CLI_WORKSPACE_ROOT: p('workspaces'), SPACE_CLI_ROOT_ENABLED: 'false', + SPACE_INSTALL_ROOT: p('install'), SPACE_ARTIFACT_ROOT: p('artifacts'), SPACE_MEMORY_ROOT: p('memory'), SPACE_MEMORY_GRAPH_ROOT: p('memory-graph'), SPACE_STARTER_MEMORY_ROOT: join(config.root, 'starter-memory'), + SPACE_GEMINI_MEMORY_INDEX_PATH: p('memory/gemini_history.md'), SPACE_GEMINI_MEMORY_MONTHLY_PATH: p('memory/gemini_history_monthly.md'), SPACE_GEMINI_MEMORY_LOCK_PATH: p('memory/.memory.lock'), + SPACE_CODEX_HOME: p('providers/codex'), SPACE_CODEX_APP_SERVER_HOME: p('providers/codex'), SPACE_CODEX_GOALS_DB_PATH: p('providers/codex/goals.sqlite'), SPACE_CODEX_CLI_MODE_DEFAULTS_PROJECTION_PATH: p('providers/codex/modes.json'), + SPACE_APP_DIAGNOSTICS_ROOT: p('diagnostics'), SPACE_MAINTENANCE_AUTOPILOT_STATE_FILE: p('maintenance/state.json'), SPACE_CLI_ACCOUNT_BALANCER_STATE_FILE: p('accounts/state.json'), SPACE_TELEGRAM_SECRET_ROOT: p('telegram'), SPACE_TELEGRAM_OWNERSHIP_MANIFEST: p('telegram/ownership.json'), SPACE_STREAMING_SECRET_ROOT: p('streaming'), + SPACE_PROJECTS_ROOT: p('projects'), SPACE_DEMO_PROJECTS_ROOT: p('demos'), SPACE_DEMO_PROJECTS_VAR_ROOT: p('demo-state'), SPACE_DOT_WORKSPACE_ROOT: p('workspaces'), SPACE_T3CODE_SOCKET: p('run/disabled-t3.sock'), SPACE_BROWSER_HOST_SOCKET: p('run/disabled-browser.sock'), SPACE_CHESS_MOVE_SOCKET: 'disabled', + SPACE_BROWSER_SESSIONS_ENABLED: 'false', SPACE_BROWSER_EVIDENCE_ENABLED: 'false', SPACE_T3CODE_ENABLED: 'false', SPACE_FILES_HOST_MODE: 'disabled', SPACE_DEMO_PROJECTS_ENABLED: 'false', SPACE_DOT_TOOL_GATEWAY: 'disabled', SPACE_AGENT_TOOLS_WRITER: 'disabled', + SPACE_ENABLE_CODEX_TURNS: 'false', SPACE_ENABLE_DUMMY_TURNS: 'false', SPACE_CODEX_APP_SERVER_ENABLED: 'false', SPACE_ENABLE_CLI_CHAT_TURNS: 'false', SPACE_ROOM_AGENT_AUTOPILOT_ENABLED: 'false', SPACE_ROOM_AGENT_MONITOR_ENABLED: 'false', + SPACE_CLI_OPENCODE_CREDENTIAL_SMOKE: 'false', + SPACE_TELEGRAM_WORKER_ENABLED: 'false', SPACE_STREAMING_BOT_ENABLED: 'false', SPACE_MEMORY_MAINTENANCE_ENABLED: 'false', SPACE_MEMORY_MUTATIONS_ENABLED: 'false', SPACE_TELEMETRY_ENABLED: 'false' + }; + // No inherited SPACE_*, provider keys, SSH agents, npm tokens or owner profiles. + return env; +} +export function relativeInside(root, file) { const rel = relative(root, file); return rel && !isAbsolute(rel) && !rel.startsWith('..'); } diff --git a/scripts/dev-stack/supervisor.mjs b/scripts/dev-stack/supervisor.mjs new file mode 100644 index 000000000..af570c7ac --- /dev/null +++ b/scripts/dev-stack/supervisor.mjs @@ -0,0 +1,170 @@ +import { spawn } from 'node:child_process'; +import { createServer } from 'node:net'; +import { watch } from 'node:fs'; +import { appendFile, chmod, copyFile, lstat, readFile, rm, unlink } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { assertPlainState, composeDocument, extractPins, initialize, loadOwner, locations, privateWrite, readJson, secrets, serviceEnvironment } from './state.mjs'; +import { delay, descendants, LaunchBarrier, lineRedactor, processIdentity, sameProcess, stopRecorded } from './processes.mjs'; +import { assertOwnedDocker, compose, dockerEnvironment, removeOwnedDocker, run } from './resources.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const say = text => process.stdout.write(`[dev-stack] ${text}\n`); +async function assertFreePort(port) { await new Promise((resolvePromise, reject) => { const s = createServer(); s.once('error', () => reject(new Error(`Loopback port ${port} is already in use; choose SPACE_DEV_*_PORT values.`))); s.listen(port, '127.0.0.1', () => s.close(resolvePromise)); }); } +export async function down(config) { + const owner = await loadOwner(config); + const record = await readJson(config.supervisor).catch(e => { if (e.code === 'ENOENT') return null; throw e; }); + if (record?.nonce !== undefined && record.nonce !== owner.nonce) throw new Error('Foreign supervisor record.'); + if (record && await sameProcess(record)) { + process.kill(record.pid, 'SIGTERM'); + for (let i = 0; i < 1800 && await sameProcess(record); i++) await delay(100); + if (await sameProcess(record)) throw new Error('Supervisor did not stop; recovery state retained.'); + } + // Recover owned children even when the supervisor crashed before it could shut them down. + const processes = record?.children || []; + const owned = []; + for (const item of processes) if (await sameProcess(item)) owned.push(...await descendants(item)); + await stopRecorded(owned); + await removeOwnedDocker(config, owner); + await unlink(config.supervisor).catch(e => { if (e.code !== 'ENOENT') throw e; }); + say('Stopped owned services; state/volumes retained for the next start.'); +} +export async function reset(config) { + await assertPlainState(config); + if (!await lstat(config.state).catch(e => { if (e.code === 'ENOENT') return null; throw e; })) { say('No dev-stack state to reset.'); return; } + const owner = await loadOwner(config); + await down(config); + await removeOwnedDocker(config, owner, { volumes: true }); + // Verify the resolved absolute deletion target and ownership again after external commands. + await assertPlainState(config); await loadOwner(config); + await rm(config.state, { recursive: true }); + say('Removed only this dev stack’s containers, volumes and state.'); +} +export async function start(config, inherited = process.env) { + if (!['linux', 'darwin'].includes(process.platform)) throw new Error('Run dev:stack in Linux, macOS, or Windows11 WSL2. Native Windows is not the supported Unix-socket environment.'); + await run('docker', ['compose', 'version'], { env: dockerEnvironment(inherited) }); + await Promise.all(Object.values(config.ports).map(assertFreePort)); + const owner = await initialize(config); + if (JSON.stringify(owner.ports) !== JSON.stringify(config.ports)) throw new Error('Existing state has different ports. Reset it first or use its original SPACE_DEV_*_PORT values.'); + const old = await readJson(config.supervisor).catch(e => { if (e.code === 'ENOENT') return null; throw e; }); + if (old) { if (old.nonce !== owner.nonce || await sameProcess(old) || (await Promise.all((old.children || []).map(x => sameProcess(x)))).some(Boolean)) throw new Error('A prior supervisor/child is still alive; use down before restarting.'); await unlink(config.supervisor); } + const record = { nonce: owner.nonce, pid: process.pid, identity: await processIdentity(process.pid), children: [] }; + await privateWrite(config.supervisor, JSON.stringify(record), { flag: 'wx' }); + const secret = await secrets(config), redactions = [secret.password, encodeURIComponent(secret.password), secret.session, secret.token]; + const env = serviceEnvironment(config, owner, secret, inherited), children = new Map(), watchers = []; + const launches = new LaunchBarrier(); + let stopping = false, restarting = false, queued = false, startupComplete = false, statusTimer, readyTimer, debounce; + const cancellation = new AbortController(); + let activeCommand = Promise.resolve(), writeTail = Promise.resolve(), stopTail = Promise.resolve(); + const perform = fn => { activeCommand = fn(); return activeCommand; }; + const recordChildren = () => { record.children = [...children.values()].map(x => x.record); const text = JSON.stringify(record); writeTail = writeTail.then(() => privateWrite(config.supervisor, text)); return writeTail; }; + const log = chunk => process.stdout.write(chunk); + async function command(args) { return perform(() => run('npm', args, { cwd: config.root, env, secrets: redactions, output: log, signal: cancellation.signal })); } + const stopChildren = (names = [...children.keys()]) => { + const job = stopTail.then(async () => { + for (const name of names) { + const item = children.get(name); if (!item) continue; + item.expected = true; + if (!item.record.identity && item.child.exitCode === null && item.child.signalCode === null) throw new Error(`Unverified ${name} launch; recovery state retained.`); + const targets = await sameProcess(item.record) ? await descendants(item.record) : []; + await stopRecorded(targets); children.delete(name); + } + await recordChildren(); + }); + stopTail = job.catch(() => {}); + return job; + }; + async function shutdown(code = 0) { + if (stopping) return; stopping = true; + cancellation.abort(); + clearTimeout(debounce); clearInterval(statusTimer); clearInterval(readyTimer); for (const w of watchers) w.close(); + try { await activeCommand.catch(() => {}); await launches.stop(); await stopChildren(); await removeOwnedDocker(config, owner, { output: log, secrets: redactions }); await writeTail; await unlink(config.supervisor); } + catch (error) { say(`Cleanup failed: ${error.message} Recovery state kept; retry down/reset.`); process.exitCode = 1; return; } + say('All owned services stopped.'); process.exitCode = code; + } + for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { void shutdown().catch(() => { process.exitCode = 1; }); }); + const launch = (name, args, cwd = config.root) => launches.track(async () => { + if (stopping) throw new Error('Startup/reload interrupted by shutdown.'); + const child = spawn(process.execPath, [`--title=spaceapp-dev-${owner.nonce}-${name}`, ...args], { cwd, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] }); + const spawned = new Promise((resolvePromise, reject) => { child.once('spawn', resolvePromise); child.once('error', reject); }); + const item = { child, record: { name, pid: child.pid, identity: null }, expected: false }; + children.set(name, item); + const output = lineRedactor(redactions, text => { log(`[${name}] ${text}`); void appendFile(join(config.state, 'logs', `${name}.log`), text, { mode: 0o600 }).catch(error => { say(`${name} log write failed (${error.code || 'error'}).`); void shutdown(1); }); }); + child.stdout.on('data', x => output.chunk(x)); child.stderr.on('data', x => output.chunk(x)); + child.once('exit', (code, signal) => { output.end(); if (!item.expected && !stopping) { say(`${name} exited unexpectedly (${code ?? signal}); stopping the stack.`); void shutdown(1); } }); + await spawned; + item.record.identity = await processIdentity(child.pid); + await recordChildren(); + if (!item.record.identity) throw new Error(`${name} ended before its process identity could be registered.`); + }); + const sourceServices = async () => { + await launch('cli-host', ['packages/cli-host/dist/main.js']); + await launch('worker', ['apps/worker/dist/worker.js']); + await launch('api', ['--import', 'tsx', 'apps/api/src/server.ts']); + }; + const rebuild = async kind => { + if (stopping) return; + if (restarting) { queued = true; return; } + restarting = true; + try { + const names = kind === 'api' ? ['api'] : ['api', 'worker', 'cli-host']; + await stopChildren(names); + if (kind !== 'api') await command(['run', 'build']); + if (stopping) return; + if (kind === 'api') await launch('api', ['--import', 'tsx', 'apps/api/src/server.ts']); else await sourceServices(); + say(`${kind} source change rebuilt/restarted; waiting for readiness.`); + } catch (error) { say(`Reload failed: ${error.message}`); await shutdown(1); } + finally { restarting = false; if (queued && !stopping) { queued = false; void rebuild('packages'); } } + }; + try { + const pins = extractPins(await readFile(join(config.root, 'packages/run-spaceapp/templates/compose.yml'), 'utf8'), await readFile(join(config.root, 'Dockerfile'), 'utf8')); + await privateWrite(config.compose, JSON.stringify(composeDocument(owner, pins, secret.password), null, 2) + '\n'); + await assertOwnedDocker(owner); + say('Starting canonical digest-pinned PostgreSQL and Temporal on loopback.'); + await perform(() => compose(config, owner, ['up', '-d', '--wait', '--wait-timeout', '240'], { output: log, secrets: redactions, timeout: 420000, signal: cancellation.signal })); + await command(['run', 'build']); + const vendor = join(config.state, 'cli/vendor/opencode'); + const installed = await readJson(join(vendor, 'node_modules/opencode-ai/package.json')).catch(e => { if (e.code === 'ENOENT') return null; throw e; }); + if (installed?.version !== pins.opencode) await command(['install', '--prefix', vendor, '--ignore-scripts', '--no-audit', '--no-fund', `opencode-ai@${pins.opencode}`]); + const wrapper = join(config.state, 'cli/bin/opencode-vscode-parity'); + await copyFile(join(root, 'scripts/dev-stack/opencode-wrapper.mjs'), wrapper); await chmod(wrapper, 0o700); + await perform(() => run(process.execPath, ['packages/db/dist/migrate.js'], { cwd: config.root, env, secrets: redactions, output: log, signal: cancellation.signal })); + await sourceServices(); + await launch('web', [join(config.root, 'node_modules/vite/bin/vite.js'), '--host', '127.0.0.1', '--port', String(owner.ports.web), '--strictPort'], join(config.root, 'apps/web')); + startupComplete = true; + say(`URL http://127.0.0.1:${owner.ports.web} API http://127.0.0.1:${owner.ports.api}`); + const tokenDisplay = inherited.CI || inherited.SPACE_DEV_REDACT_SETUP === 'true' ? '[redacted: private state file]' : secret.token || '[already consumed]'; + say(`One-time owner setup token: ${tokenDisplay}`); + say(`State ${config.state}; down stops, reset removes only owned state. Browser/companion/root services are disabled. No provider account or inference is used.`); + const change = kind => { clearTimeout(debounce); debounce = setTimeout(() => { void rebuild(kind); }, 400); }; + for (const [dir, kind] of [['apps/api/src', 'api'], ['apps/worker/src', 'packages'], ['packages', 'packages']]) { + watchers.push(watch(join(config.root, dir), { recursive: true }, (_event, file) => { if (file && !/(?:^|[\\/])(?:node_modules|dist|\.git)(?:[\\/]|$)/.test(file) && /\.(?:ts|tsx|js|mjs|json)$/.test(file)) change(kind); })); + } + let lastReady = null, readinessFailures = 0, checkingReady = false; + const readyDeadline = Date.now() + 180000; + readyTimer = setInterval(async () => { + if (restarting || stopping || checkingReady) return; + checkingReady = true; + try { + const response = await fetch(`http://127.0.0.1:${owner.ports.api}/readyz`, { signal: AbortSignal.timeout(6000) }); + const body = await response.json(), ready = response.ok && body.ok === true; + const failed = Object.entries(body.dependencies || {}).filter(([key, value]) => ['database', 'worker', 'cliHost'].includes(key) && ['ERROR', 'UNAVAILABLE', 'STOPPED'].includes(value)).map(([key]) => key).join(', ') || 'API/workflows'; + if (ready !== lastReady) say(ready ? 'readyz ok: workflows and source services are ready.' : `readyz failed: ${failed} unavailable; inspect its service log.`); + // Give normal cold startup time. Once ready, a sustained dependency loss is fatal. + if (ready) readinessFailures = 0; + else if ((lastReady !== null && ++readinessFailures >= 4) || Date.now() > readyDeadline) { say(`Required service failure: ${failed}; stopping the stack.`); await shutdown(1); } + if (ready || lastReady !== null) lastReady = ready; + } catch { if (lastReady !== false) say('readyz failed: API is not responding.'); if (++readinessFailures >= 30 || (lastReady !== null && readinessFailures >= 4)) { say('Required service failure: API readiness; stopping the stack.'); await shutdown(1); } } + finally { checkingReady = false; } + }, 4000); + statusTimer = setInterval(() => { if (startupComplete && !stopping) void recordChildren().catch(() => { void shutdown(1); }); }, 3000); + } catch (error) { say(error.message); await shutdown(1); } +} +export async function main(args = process.argv.slice(2)) { + const config = locations(root, process.env), action = args[0] || 'up'; + if (args.length > 1 || !['up', 'down', 'reset', 'status'].includes(action)) throw new Error('Usage: npm run dev:stack [-- down|reset|status]'); + if (action === 'up') return start(config); + if (action === 'status') { const owner = await loadOwner(config), record = await readJson(config.supervisor).catch(() => null); say(JSON.stringify({ project: owner.project, ports: owner.ports, running: record ? await sameProcess(record) : false })); return; } + if (!['linux', 'darwin'].includes(process.platform)) throw new Error('Lifecycle commands require the same Linux/macOS/WSL2 environment that started this stack.'); + return action === 'down' ? down(config) : reset(config); +} diff --git a/scripts/public-export.mjs b/scripts/public-export.mjs index 2ec8cc7be..f694288f2 100644 --- a/scripts/public-export.mjs +++ b/scripts/public-export.mjs @@ -54,6 +54,7 @@ const rootFiles = new Set([ ]); const publicDocs = new Set([ + "docs/development-stack.md", // Linked from the README (review F4: the link was stripped because the file was not exported). "docs/architecture.md", "docs/clean-room-testing.md", @@ -78,6 +79,16 @@ const publicDocs = new Set([ export const publicRepositoryMaintainedFiles = new Set(["docs/security-model.md"]); const publicScripts = new Set([ + "scripts/dev-stack.mjs", + "scripts/dev-stack/state.mjs", + "scripts/dev-stack/processes.mjs", + "scripts/dev-stack/resources.mjs", + "scripts/dev-stack/supervisor.mjs", + "scripts/dev-stack/opencode-wrapper.mjs", + "scripts/dev-stack/acceptance.mjs", + "scripts/dev-stack/browser-proof.mjs", + "scripts/dev-stack/lifecycle-proof.mjs", + "scripts/tests/dev-stack.test.mjs", // Loaded by apps/api/src/cli-render-proof-fixture.ts (proof rooms); missing, that path failed (review F18). "scripts/cli-render-fixture.mjs", // Maintained here; listed so the CI re-export of this repository keeps them. diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs new file mode 100644 index 000000000..2f26dc761 --- /dev/null +++ b/scripts/tests/dev-stack.test.mjs @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { readFile, mkdir, mkdtemp, realpath, rm, stat, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { composeDocument, extractPins, initialize, loadOwner, locations, ownerLabel, privateWrite, secrets, serviceEnvironment } from '../dev-stack/state.mjs'; +import { descendants, LaunchBarrier, lineRedactor, processIdentity, sameProcess, stopRecorded } from '../dev-stack/processes.mjs'; +import { assertOwnedDocker, composeArgs, dockerEnvironment, run } from '../dev-stack/resources.mjs'; + +const root = fileURLToPath(new URL('../..', import.meta.url)); +const pins = extractPins(await readFile(join(root, 'packages/run-spaceapp/templates/compose.yml'), 'utf8'), await readFile(join(root, 'Dockerfile'), 'utf8')); +async function sandbox(t) { + const parent = await realpath(tmpdir()); + if (process.platform === 'win32') assert.match(parent, /^[FE]:[\\/]/i, 'Test temporary work must be on F: or E:.'); + const base = await mkdtemp(join(parent, 'space-dev-stack-test-')); + t.after(async () => { assert.equal(await realpath(base), resolve(base)); assert(resolve(base).startsWith(parent)); await rm(base, { recursive: true, force: true }); }); + return locations(base); +} +test('pins are derived from the canonical image/version sources', () => { + assert.match(pins.postgres, /^pgvector\/pgvector:.*@sha256:[a-f0-9]{64}$/); + assert.match(pins.temporal, /^temporalio\/auto-setup:.*@sha256:[a-f0-9]{64}$/); + assert.match(pins.opencode, /^\d+\.\d+\.\d+$/); + assert.throws(() => extractPins(' postgres:\n image: postgres:latest\n', 'opencode-ai@1.2.3'), /digest/); + const sample = `services:\n postgres:\n image: postgres:test@sha256:${'a'.repeat(64)}\n temporal:\n image: temporal:test@sha256:${'b'.repeat(64)}\n`; + assert.equal(extractPins(sample, 'opencode-ai@2.3.4').opencode, '2.3.4'); + assert.equal(extractPins(sample, 'opencode-ai@2.3.4').postgres, `postgres:test@sha256:${'a'.repeat(64)}`); +}); +test('ports are isolated, distinct and explicitly configurable', () => { + assert.equal(locations(root).ports.api, 4810); + assert.equal(locations(root, { SPACE_DEV_API_PORT: '5810' }).ports.api, 5810); + for (const value of ['4910', '4911', '0', '99999', '4810suffix', '0.0.0.0']) assert.throws(() => locations(root, { SPACE_DEV_API_PORT: value })); + assert.throws(() => locations(root, { SPACE_DEV_API_PORT: '4811' }), /distinct/); +}); +test('deletion targets are confined to named direct checkout children', () => { + for (const value of ['..', '../data', '/', 'node_modules', '.git', '.dev-stack/../secrets', '/tmp/.dev-stack']) assert.throws(() => locations(root, { SPACE_DEV_STATE_DIR: value })); + assert.equal(locations(root, { SPACE_DEV_STATE_DIR: '.dev-stack-isolated' }).state, join(resolve(root), '.dev-stack-isolated')); +}); +test('fresh state generates unique owners and secrets; restart preserves consumed-token state', async t => { + const config = await sandbox(t), owner = await initialize(config), initial = await secrets(config); + assert.equal(owner.nonce.length, 32); assert.equal(owner.project, `spaceapp-dev-${owner.nonce.slice(0, 16)}`); + assert.equal(new Set(Object.values(initial)).size, 3); + assert.deepEqual(await initialize(config), owner); + await rm(join(config.state, 'secrets/setup-token')); + await initialize(config); assert.equal((await secrets(config)).token, ''); + const second = await initialize(locations(config.root, { SPACE_DEV_STATE_DIR: '.dev-stack-second' })); + assert.notEqual(second.nonce, owner.nonce); +}); +test('secret files and generated compose are private on supported POSIX hosts', { skip: process.platform === 'win32' }, async t => { + const config = await sandbox(t); await initialize(config); + await privateWrite(config.compose, '{}'); + for (const name of ['postgres-password', 'session-secret', 'setup-token']) assert.equal((await stat(join(config.state, 'secrets', name))).mode & 0o777, 0o600); + assert.equal((await stat(config.compose)).mode & 0o777, 0o600); + assert.equal((await stat(config.state)).mode & 0o777, 0o700); +}); +test('an unknown existing directory is never adopted or deleted', async t => { + const config = await sandbox(t); await mkdir(config.state); + await assert.rejects(initialize(config)); await assert.rejects(loadOwner(config)); + assert((await stat(config.state)).isDirectory()); +}); +test('linked state and linked manifests are rejected before mutation', async t => { + const config = await sandbox(t), outside = join(config.root, 'sentinel'); await mkdir(outside); + await symlink(outside, config.state, process.platform === 'win32' ? 'junction' : 'dir'); + await assert.rejects(initialize(config), /linked/); assert((await stat(outside)).isDirectory()); + await rm(config.state); const owner = await initialize(config); assert(owner); +}); + +test('linked private control directories are rejected without touching their target', async t => { + const config = await sandbox(t); await initialize(config); + const outside = join(config.root, 'sentinel'); await mkdir(outside); + await rm(join(config.state, 'logs'), { recursive: true }); + await symlink(outside, join(config.state, 'logs'), process.platform === 'win32' ? 'junction' : 'dir'); + await assert.rejects(loadOwner(config), /linked/); + assert((await stat(outside)).isDirectory()); +}); + +test('private writers do not follow a linked output file', { skip: process.platform === 'win32' }, async t => { + const config = await sandbox(t), outside = join(config.root, 'sentinel'); + await privateWrite(outside, 'unchanged'); await initialize(config); + const file = join(config.state, 'compose.json'); await symlink(outside, file); + await assert.rejects(privateWrite(file, 'replacement'), /linked/); + assert.equal(await readFile(outside, 'utf8'), 'unchanged'); +}); +test('generated Docker services keep pins, loopback ports, generated ownership labels and no extra services', async t => { + const config = await sandbox(t), owner = await initialize(config), secret = await secrets(config), doc = composeDocument(owner, pins, secret.password); + assert.deepEqual(Object.keys(doc.services), ['postgres', 'temporal']); + for (const service of Object.values(doc.services)) { assert.equal(service.labels[ownerLabel], owner.nonce); assert.match(service.image, /@sha256:/); for (const port of service.ports) assert.match(port, /^127\.0\.0\.1:/); } + assert.equal(doc.volumes.postgres.labels[ownerLabel], owner.nonce); assert.equal(doc.networks.default.labels[ownerLabel], owner.nonce); + assert.equal(doc.services.temporal.environment.POSTGRES_PWD, secret.password); +}); +test('service environment isolates all writable state and never forwards provider credentials', async t => { + const config = await sandbox(t), owner = await initialize(config), secret = await secrets(config); + const env = serviceEnvironment(config, owner, secret, { PATH: '/node/bin:/usr/bin', HOME: '/owner/private', OPENAI_API_KEY: 'synthetic-should-not-propagate', SPACE_DEV_LOGIN: 'true', SSH_AUTH_SOCK: '/owner/agent' }); + assert.equal(env.OPENAI_API_KEY, undefined); assert.equal(env.SSH_AUTH_SOCK, undefined); assert.equal(env.SPACE_DEV_LOGIN, 'false'); + assert.equal(env.HOME, join(config.state, 'home')); assert.equal(env.SPACE_API_ORIGIN, 'http://127.0.0.1:4810'); assert.equal(env.SPACEAPP_WORKFLOWS_ENABLED, 'true'); + assert.equal(env.SPACE_ENABLE_CLI_CHAT_TURNS, 'false'); assert.equal(env.SPACE_CLI_OPENCODE_CREDENTIAL_SMOKE, 'false'); + assert(Object.values(env).every(x => !x.includes('/owner/private') && !x.includes('/opt/spaceapp'))); +}); +test('Docker context forwarding is limited to control-plane connectivity', () => { + assert.deepEqual(dockerEnvironment({ PATH: '/bin', DOCKER_CONTEXT: 'colima', OPENAI_API_KEY: 'synthetic', SPACE_SESSION_SECRET: 'synthetic' }), { PATH: '/bin', DOCKER_CONTEXT: 'colima' }); +}); +test('redactor handles secrets split between stdout chunks', () => { + let output = ''; const stream = lineRedactor(['synthetic-password', 'synthetic-token'], x => { output += x; }); + stream.chunk(Buffer.from('before synthetic-pass')); stream.chunk(Buffer.from('word\nsynthetic-to')); stream.chunk(Buffer.from('ken after')); stream.end(); + assert.equal(output, 'before [redacted]\n[redacted] after'); +}); +test('PID reuse never signals a process whose identity changed', async () => { + const signals = []; assert.equal(await sameProcess({ pid: 12345, identity: 'old' }, async () => 'new'), false); + await stopRecorded([{ pid: 12345, identity: 'old' }], { identity: async () => 'new', signal: (...args) => signals.push(args), timeout: 0 }); + assert.deepEqual(signals, []); +}); + +test('descendant discovery refuses a root reused during the ps snapshot', async () => { + let current = 'original'; + await assert.rejects(descendants({ pid: 12345, identity: 'original' }, { identity: async () => current, list: async () => { current = 'replacement'; return { stdout: '12345 1\n12346 12345\n' }; } }), /replacement tree/); +}); + +test('shutdown drains a real spawned child paused before identity registration', { skip: process.platform === 'win32' }, async t => { + const barrier = new LaunchBarrier(), children = []; + let release, announce, child; + const gate = new Promise(done => { release = done; }), spawned = new Promise(done => { announce = done; }); + t.after(async () => { release(); if (child && child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); }); + const launch = barrier.track(async () => { + child = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { stdio: 'ignore' }); + await new Promise((done, reject) => { child.once('spawn', done); child.once('error', reject); }); + announce(); await gate; + children.push({ pid: child.pid, identity: await processIdentity(child.pid) }); + }); + await spawned; + const shutdown = (async () => { await barrier.stop(); await stopRecorded(children); })(); + await assert.rejects(barrier.track(() => { throw new Error('must not be admitted'); }), /shutdown/); + release(); await launch; await shutdown; + assert.equal(await processIdentity(child.pid), null, 'A child spawned across shutdown must not be orphaned.'); +}); +test('only a matching owned process receives termination', async () => { + let alive = true; const signals = []; + await stopRecorded([{ pid: 12345, identity: 'ours' }], { identity: async () => alive ? 'ours' : null, signal: (pid, action) => { signals.push([pid, action]); alive = false; }, timeout: 1 }); + assert.deepEqual(signals, [[12345, 'SIGTERM']]); +}); +test('Docker cleanup refuses a foreign resource sharing the compose project', async () => { + const owner = { nonce: 'a'.repeat(32), project: 'our-project' }; + const invoke = async (_command, args) => args[1] === 'ls' ? 'foreign-id\n' : JSON.stringify({ [ownerLabel]: 'foreign-owner' }); + await assert.rejects(assertOwnedDocker(owner, invoke), /Foreign container/); +}); +test('Docker ownership failure propagates instead of claiming successful cleanup', async () => { + await assert.rejects(assertOwnedDocker({ project: 'ours' }, async () => { throw new Error('daemon unavailable'); }), /daemon unavailable/); +}); +test('compose invocation always names the exact owned project/file', () => { + assert.deepEqual(composeArgs({ compose: '/private/compose.json' }, { project: 'ours' }, ['down', '--volumes']), ['compose', '--project-name', 'ours', '--file', '/private/compose.json', 'down', '--volumes']); +}); +test('command output is redacted and a failed subprocess remains failure', async () => { + const env = { ...process.env }; + const output = await run(process.execPath, ['-e', 'console.log("synthetic-secret")'], { env, secrets: ['synthetic-secret'] }); + assert.equal(output.trim(), '[redacted]'); + await assert.rejects(run(process.execPath, ['-e', 'process.exit(7)'], { env }), /failed \(7\)/); +}); + +test('a command timeout cannot be converted into success by its signal handler', async () => { + await assert.rejects(run(process.execPath, ['-e', 'process.on("SIGTERM",()=>process.exit(0));setInterval(()=>{},1000)'], { env: { ...process.env }, timeout: 150 }), /timeout/); +}); + +test('a missing executable rejects promptly instead of leaving an identity wait', async () => { + await assert.rejects(run('spaceapp-deliberately-missing-executable', [], { env: { ...process.env }, timeout: 200 }), /could not start/); +}); diff --git a/scripts/tests/public-suite.test.mjs b/scripts/tests/public-suite.test.mjs index 3b67e4b63..0a5826888 100644 --- a/scripts/tests/public-suite.test.mjs +++ b/scripts/tests/public-suite.test.mjs @@ -18,3 +18,5 @@ import "./trivy-report-summary.test.mjs"; import "./verify-export-provenance.test.mjs"; import './opencode-first-run.test.mjs'; + +import "./dev-stack.test.mjs"; From 3d31cc81ea2f6745df0600870b690dcb65a82c44 Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 17 Oct 2026 17:18:24 +0200 Subject: [PATCH 02/27] fix: initialize proof path in runner step context --- .github/workflows/dev-stack.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml index 125cf5ea6..b49af32da 100644 --- a/.github/workflows/dev-stack.yml +++ b/.github/workflows/dev-stack.yml @@ -49,7 +49,6 @@ jobs: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" SPACE_DEV_REDACT_SETUP: "true" SPACE_DEV_CANDIDATE: ${{ github.event.pull_request.head.sha || github.sha }} - SPACE_DEV_PROOF_DIR: ${{ runner.temp }}/spaceapp-dev-stack-proof EXPECTED_SOURCE: ${{ inputs.expected_source }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -64,6 +63,7 @@ jobs: - name: Verify exact public candidate run: | + printf 'SPACE_DEV_PROOF_DIR=%s/spaceapp-dev-stack-proof\n' "$RUNNER_TEMP" >> "$GITHUB_ENV" node --input-type=module <<'NODE' import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; From 2fb7c2e04434c1e6a4e81d324f74ce5e015380e1 Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 17 Oct 2026 17:23:35 +0200 Subject: [PATCH 03/27] fix: provision ripgrep on hosted Ubuntu before stock gates --- .github/workflows/dev-stack.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml index b49af32da..6e7339ee8 100644 --- a/.github/workflows/dev-stack.yml +++ b/.github/workflows/dev-stack.yml @@ -88,6 +88,10 @@ jobs: colima start --profile spaceapp-dev-proof --cpu 3 --memory 6 --disk 30 docker context use colima-spaceapp-dev-proof + - name: Prepare hosted Linux search tool + if: runner.os == 'Linux' + run: sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep + - name: Verify existing Docker, Compose, browser and search tools run: | docker version From 95e6714585f45bd6a1b33f40bbdcfb727603a1fd Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 17 Oct 2026 17:33:11 +0200 Subject: [PATCH 04/27] test: register dev stack coverage and normalize portable fixture counts --- scripts/tests/public-credential-install.test.mjs | 4 +++- scripts/tests/public-package-metadata.test.mjs | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/tests/public-credential-install.test.mjs b/scripts/tests/public-credential-install.test.mjs index 1f6a36bdd..5c3f54bdc 100644 --- a/scripts/tests/public-credential-install.test.mjs +++ b/scripts/tests/public-credential-install.test.mjs @@ -84,7 +84,9 @@ const ask = (socketPath, body) => new Promise((resolve, reject) => { test('the credential agent runs only a wrapper\'s fixed credential actions, input on stdin', async () => { // Short path: Unix socket paths are limited to about 100 bytes. const root = await mkdtemp('/tmp/spaceapp-agent-'); - await writeFile(join(root, 'codex-vscode-parity'), '#!/bin/sh\nprintf "%s %s\\n" "$1" "$(wc -c)"\n'); + await writeFile(join(root, 'codex-vscode-parity'), `#!/bin/sh +printf "%s %s\\n" "$1" "$(wc -c | tr -d '[:space:]')" +`); await chmod(join(root, 'codex-vscode-parity'), 0o755); const socketPath = join(root, 'credentials.sock'); const server = await startCredentialAgent({ socketPath, commandPath: root }); diff --git a/scripts/tests/public-package-metadata.test.mjs b/scripts/tests/public-package-metadata.test.mjs index cf92bdea8..31d2cb913 100644 --- a/scripts/tests/public-package-metadata.test.mjs +++ b/scripts/tests/public-package-metadata.test.mjs @@ -28,7 +28,8 @@ const publicTestModules = [ "reset-owner-password.test.mjs", "rotate-owner-setup-token.test.mjs", "trivy-report-summary.test.mjs", - "verify-export-provenance.test.mjs" + "verify-export-provenance.test.mjs", + "dev-stack.test.mjs" ]; async function packageFiles(directory) { From 39dc9df8e860cc90483fe7d442a035d9bc65f8d0 Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 17 Oct 2026 18:11:40 +0200 Subject: [PATCH 05/27] fix(dev): confine instance storage and retain failure diagnostics --- apps/api/src/app.ts | 5 +- apps/api/src/auth-routes.ts | 3 +- apps/api/src/auth.ts | 3 +- apps/api/src/cli-agent-files.ts | 12 ++- apps/api/src/instance-storage.ts | 35 +++++++ apps/api/src/live-routes.ts | 7 +- apps/api/src/plugins-service.ts | 8 +- apps/api/src/space-control.ts | 6 +- apps/api/tests/dev-docx-isolation.test.ts | 70 +++++++++++++ apps/api/tests/dev-incidents-storage.test.ts | 80 +++++++++++++++ apps/api/tests/dev-oauth-storage.test.ts | 101 +++++++++++++++++++ apps/api/tests/instance-storage.test.ts | 97 ++++++++++++++++++ docs/development-stack.md | 11 +- scripts/dev-stack/resources.mjs | 29 +++++- scripts/dev-stack/state.mjs | 4 +- scripts/dev-stack/supervisor.mjs | 2 +- scripts/tests/dev-stack.test.mjs | 49 ++++++++- 17 files changed, 495 insertions(+), 27 deletions(-) create mode 100644 apps/api/src/instance-storage.ts create mode 100644 apps/api/tests/dev-docx-isolation.test.ts create mode 100644 apps/api/tests/dev-incidents-storage.test.ts create mode 100644 apps/api/tests/dev-oauth-storage.test.ts create mode 100644 apps/api/tests/instance-storage.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ab9f98f21..9f1eda468 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -91,6 +91,7 @@ import { createSpaceControl, supportsControlRuntime } from "./space-control.js"; import { createControlScreenshotCapture } from "./space-control-capture.js"; import { registerSpaceControlRoutes } from "./space-control-routes.js"; import { pluginsService } from "./plugins-service.js"; +import { instanceStorageDirectory } from "./instance-storage.js"; import { InMemorySourceControlRepository, PostgresSourceControlRepository } from "@space/db"; import { SourceControlService } from "./source-control/service.js"; import { registerSourceControlRoutes } from "./source-control/routes.js"; @@ -1471,7 +1472,7 @@ export async function createApp(options: CreateAppOptions = {}): Promise) => { try { - const dir = "/opt/spaceapp/var/live-voice-logs"; + const dir = instanceStorageDirectory("live-voice-logs"); await mkdir(dir, { recursive: true }); await appendFile(`${dir}/debug-incidents.jsonl`, JSON.stringify(incident) + "\n", "utf8"); } catch (err) { diff --git a/apps/api/src/auth-routes.ts b/apps/api/src/auth-routes.ts index 67d735a6e..4ed32a6b8 100644 --- a/apps/api/src/auth-routes.ts +++ b/apps/api/src/auth-routes.ts @@ -20,6 +20,7 @@ import { sendApiError } from "./http-errors.js"; import { createDesktopAuthHandoffStore, isValidDesktopAuthHandoffId } from "./desktop-auth-handoffs.js"; import { recordAudit } from "./request-audit.js"; import { idParamSchema, parseBody, parseQuery } from "./route-parsing.js"; +import { instanceSecretDirectories } from "./instance-storage.js"; /** * Operator login/logout and Google sign-in (OAuth, linked accounts, desktop handoff); the parent retains @@ -219,7 +220,7 @@ export function registerAuthRoutes(app: FastifyInstance, options: { auth.googleClientId = clientId; auth.googleClientSecret = clientSecret; let saved = false; - for (const dir of ["/opt/spaceapp/secrets", "/var/lib/spaceapp"]) { + for (const dir of instanceSecretDirectories()) { try { await mkdir(dir, { recursive: true, mode: 0o700 }); await writeFile( diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 5db0b1ba3..66fa4f74e 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -3,6 +3,7 @@ import { createHash, createHmac, randomBytes, scrypt as scryptCallback, timingSa import { authUserSchema, resolveSpaceInstance, sessionCookieNameFor, type AuthUser, type LoginInput } from "@space/contracts"; import { persistentOperatorSessionTtlSeconds, signPersistentOperatorSessionToken, signSessionTokenWithTtl } from "@space/runtime"; import { ANTIGRAVITY_CLIENT_ID, ANTIGRAVITY_CLIENT_SECRET } from "./antigravity-usage-services.js"; +import { instanceSecretDirectories } from "./instance-storage.js"; // A dev instance on another port of the same host must not overwrite the stable session cookie. const SESSION_COOKIE = sessionCookieNameFor(resolveSpaceInstance(process.env)); @@ -55,7 +56,7 @@ export function getAuthConfig(env: NodeJS.ProcessEnv): AuthConfig { let googleClientSecret = env.SPACE_GOOGLE_CLIENT_SECRET || env.GOOGLE_CLIENT_SECRET || undefined; if (!googleClientId) { try { - const candidates = ["/opt/spaceapp/secrets/google-oauth.json", "/var/lib/spaceapp/google-oauth.json"]; + const candidates = instanceSecretDirectories(env).map(dir => `${dir}/google-oauth.json`); for (const p of candidates) { if (existsSync(p)) { const raw = JSON.parse(readFileSync(p, "utf8")); diff --git a/apps/api/src/cli-agent-files.ts b/apps/api/src/cli-agent-files.ts index 6c8e3475d..7f04b41a7 100644 --- a/apps/api/src/cli-agent-files.ts +++ b/apps/api/src/cli-agent-files.ts @@ -8,12 +8,13 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { basename, dirname, extname, isAbsolute, join, relative, sep } from "node:path"; import { promisify } from "node:util"; import sanitizeHtml from "sanitize-html"; -import { idSchema, isAgentFileArtifact, type Artifact } from "@space/contracts"; +import { idSchema, isAgentFileArtifact, resolveSpaceInstance, type Artifact } from "@space/contracts"; import { makeSpaceId, type SpaceStore } from "@space/runtime"; import { z } from "zod"; import type { SpaceApiConfig } from "./config.js"; import { recordUploadedArtifact, UploadRecordUncertainError } from "./upload-record.js"; import { childProcessEnv } from "./child-process-env.js"; +import { instanceStorageDirectory } from "./instance-storage.js"; export const cliAgentFilesTokenHeader = "x-space-cli-agent-files-token"; export const agentFileMaxBytes = 100 * 1024 * 1024; @@ -208,8 +209,13 @@ export async function normalizeAgentFileDocx( throw new AgentFileDocxNormalizationError("The DOCX source is not a valid Office package."); } const directCommand = options.command ?? process.env.SPACE_LIBREOFFICE_PATH; - const temporaryRoot = options.temporaryRoot ?? - (directCommand ? tmpdir() : process.env.SPACE_AGENT_FILE_DOCX_WORK_ROOT ?? "/opt/spaceapp/var/agent-docx-work"); + const dev = resolveSpaceInstance(process.env) === "dev"; + if (dev && !directCommand) { + throw new AgentFileDocxNormalizationError("Dev DOCX normalization requires an explicit direct LibreOffice command; the installed privileged broker is not used."); + } + const temporaryRoot = dev + ? instanceStorageDirectory("agent-docx-work", options.temporaryRoot ?? process.env.SPACE_AGENT_FILE_DOCX_WORK_ROOT) + : options.temporaryRoot ?? (directCommand ? tmpdir() : process.env.SPACE_AGENT_FILE_DOCX_WORK_ROOT ?? "/opt/spaceapp/var/agent-docx-work"); await mkdir(temporaryRoot, { recursive: true, mode: 0o700 }); const workingRoot = await mkdtemp(join(temporaryRoot, "request-")); const inputDirectory = join(workingRoot, "input"); diff --git a/apps/api/src/instance-storage.ts b/apps/api/src/instance-storage.ts new file mode 100644 index 000000000..5f03dd6c0 --- /dev/null +++ b/apps/api/src/instance-storage.ts @@ -0,0 +1,35 @@ +import { isAbsolute, relative, resolve, sep, join } from "node:path"; +import { resolveSpaceInstance } from "@space/contracts"; + +function privateInstallRoot(env: NodeJS.ProcessEnv): string { + const configured = env.SPACE_INSTALL_ROOT; + if (!configured || !isAbsolute(configured) || resolve(configured) === resolve("/opt/spaceapp")) { + throw new Error("Dev service storage requires a private absolute SPACE_INSTALL_ROOT."); + } + return resolve(configured); +} + +/** Keep installed defaults; a dev instance's mutable service data stays under its own install root. */ +export function instanceStorageDirectory( + name: "plugins" | "personal-source-control" | "live-voice-logs" | "live-personal-memory.json" | "agent-docx-work", + override?: string, + env: NodeJS.ProcessEnv = process.env +): string { + const installed = `/opt/spaceapp/var/${name}`; + if (resolveSpaceInstance(env) !== "dev") return override || installed; + const root = privateInstallRoot(env); + if (override && !isAbsolute(override)) throw new Error("Dev service storage override must be absolute."); + const target = resolve(override || join(root, "var", name)); + const inside = relative(root, target); + if (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) { + throw new Error("Dev service storage cannot escape its private SPACE_INSTALL_ROOT."); + } + return target; +} + +/** Loader and save routes must agree; dev never probes the installed credential candidates. */ +export function instanceSecretDirectories(env: NodeJS.ProcessEnv = process.env): string[] { + return resolveSpaceInstance(env) === "dev" + ? [join(privateInstallRoot(env), "secrets")] + : ["/opt/spaceapp/secrets", "/var/lib/spaceapp"]; +} diff --git a/apps/api/src/live-routes.ts b/apps/api/src/live-routes.ts index 3efe72f4a..c2e8ed7d3 100644 --- a/apps/api/src/live-routes.ts +++ b/apps/api/src/live-routes.ts @@ -18,6 +18,7 @@ import { createLivePersonalMemory } from "./live-personal-memory.js"; import { buildLiveRoomContext } from "./live-room-context.js"; import type { RoomAgentService } from "./room-agent.js"; import type { RoomPaneController } from "./room-pane-control.js"; +import { instanceStorageDirectory } from "./instance-storage.js"; /** * Live pane search, URL fetch, room context, personal memory and history (with their repositories and close hooks); @@ -203,7 +204,7 @@ export function registerLiveRoutes(app: FastifyInstance, options: { legacyOwnerId: auth.operatorEmail ? "user:operator" : auth.devLogin ? "user:dev-operator" : undefined, readLegacy: async () => { if (!(store instanceof PostgresSpaceStore)) return null; - try { return await readFile("/opt/spaceapp/var/live-personal-memory.json", "utf8"); } + try { return await readFile(instanceStorageDirectory("live-personal-memory.json"), "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } } }); @@ -233,13 +234,13 @@ export function registerLiveRoutes(app: FastifyInstance, options: { assertAccess: (request, room) => assertRoomAccess(room, request.user!), readLegacy: async room => { if (!(store instanceof PostgresSpaceStore) || !/^[a-zA-Z0-9:_-]+$/.test(room)) return null; - try { return await readFile(join("/opt/spaceapp/var/live-voice-logs", `live-voice-${room}.jsonl`), "utf8"); } + try { return await readFile(join(instanceStorageDirectory("live-voice-logs"), `live-voice-${room}.jsonl`), "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } }, audit: async (owner, room, event) => { if (!(store instanceof PostgresSpaceStore)) return; const key = createHash("sha256").update(JSON.stringify([owner, room])).digest("hex"); - const directory = "/opt/spaceapp/var/live-voice-logs/owners"; + const directory = join(instanceStorageDirectory("live-voice-logs"), "owners"); await mkdir(directory, { recursive: true, mode: 0o700 }); await appendFile(join(directory, `${key}.jsonl`), JSON.stringify({ ...event, ownerId: owner, roomId: room, recordedAt: nowIso() }) + "\n", { mode: 0o600 }); } diff --git a/apps/api/src/plugins-service.ts b/apps/api/src/plugins-service.ts index 8660c945e..6625905c1 100644 --- a/apps/api/src/plugins-service.ts +++ b/apps/api/src/plugins-service.ts @@ -2,6 +2,7 @@ import { readFile, readdir, writeFile, mkdir, symlink, unlink, lstat, realpath } import { existsSync } from "node:fs"; import { basename, join } from "node:path"; import { + instanceBackgroundJobEnabled, type PluginCatalogItem, type PluginConnectionState, type PluginWithState, @@ -9,8 +10,7 @@ import { type AgentPluginConfig } from "@space/contracts"; import { parseSkillFrontmatter } from "@space/runtime"; - -const PLUGINS_DIR = "/opt/spaceapp/var/plugins"; +import { instanceStorageDirectory } from "./instance-storage.js"; export const DEFAULT_PLUGIN_CATALOG: PluginCatalogItem[] = [ { @@ -549,8 +549,8 @@ export interface StoredPluginConnection { export class PluginsService { constructor( - private readonly storageDirectory = PLUGINS_DIR, - private readonly syncSkills = true, + private readonly storageDirectory = instanceStorageDirectory("plugins"), + private readonly syncSkills = instanceBackgroundJobEnabled(process.env, "plugin-skill-links"), private readonly skillDirs: readonly string[] = SKILL_DIRS ) {} private get connectionsFile() { return join(this.storageDirectory, "connections.json"); } diff --git a/apps/api/src/space-control.ts b/apps/api/src/space-control.ts index f45ac078e..bad00d6a2 100644 --- a/apps/api/src/space-control.ts +++ b/apps/api/src/space-control.ts @@ -14,6 +14,8 @@ import { makeSpaceId, nowIso, SpaceConflictError, redactMemoryText, type SpaceSt import type { RoomPaneController, RoomPaneObservation } from "./room-pane-control.js"; import { resolveControlSettingsAction, executeControlPluginAction, restoreControlSettingsSnapshot, executeControlAppearanceAction, ALLOWED_APPEARANCE_THEMES, controlPluginActionSchema, controlRollbackSchema } from "./space-control-settings.js"; import type { DecisionsService } from "./decisions-service.js"; +import { instanceStorageDirectory } from "./instance-storage.js"; +import { join } from "node:path"; /** `dotId` attributes operations and schedules to the Space Dot acting for this user. */ export interface ControlActor {id:string; role:AuthUser["role"]; dotId?:string} @@ -1798,7 +1800,7 @@ export function createSpaceControl(options:{store:SpaceStore;repository:ControlR failedTools: failedTools.map(f => ({ tool: f.tool, error: f.error })), agentHandoffPrompt }) + "\n"; - appendFileSync("/opt/spaceapp/var/live-voice-logs/debug-incidents.jsonl", line); + appendFileSync(join(instanceStorageDirectory("live-voice-logs"), "debug-incidents.jsonl"), line); } catch {} } @@ -1906,7 +1908,7 @@ export function createSpaceControl(options:{store:SpaceStore;repository:ControlR let recentIncidents: any[] = []; try { - const content = await fsPromises.readFile("/opt/spaceapp/var/live-voice-logs/debug-incidents.jsonl", "utf8"); + const content = await fsPromises.readFile(join(instanceStorageDirectory("live-voice-logs"), "debug-incidents.jsonl"), "utf8"); recentIncidents = content.trim().split("\n").filter(Boolean).map(line => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean).slice(-5); } catch {} diff --git a/apps/api/tests/dev-docx-isolation.test.ts b/apps/api/tests/dev-docx-isolation.test.ts new file mode 100644 index 000000000..348d467ea --- /dev/null +++ b/apps/api/tests/dev-docx-isolation.test.ts @@ -0,0 +1,70 @@ +import { join, relative, isAbsolute } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { normalizeAgentFileDocx } from "../src/cli-agent-files.js"; + +const boundary = vi.hoisted(() => ({ directories: [] as string[], writes: [] as string[], commands: [] as { command: string; args: string[]; env?: NodeJS.ProcessEnv }[] })); +// Every filesystem/process boundary is virtual, including the historical installed +// work directory and sudo broker used by the stable-behavior and negative controls. +vi.mock("node:fs/promises", async importOriginal => { + const actual = await importOriginal(); + return { ...actual, + mkdir: vi.fn(async (path: unknown) => { boundary.directories.push(String(path)); }), + mkdtemp: vi.fn(async (prefix: string) => `${prefix}synthetic`), + writeFile: vi.fn(async (path: unknown) => { boundary.writes.push(String(path)); }), + readFile: vi.fn(async (path: unknown) => String(path).endsWith("result.json") + ? JSON.stringify({ contract: "SpaceAgentDocxNormalizationResultV1", ok: true }) + : Buffer.from([0x50, 0x4b, ...Buffer.from("synthetic-rebuilt")])), + rm: vi.fn(async () => undefined) + }; +}); +vi.mock("../src/safe-child-process.js", async importOriginal => { + const actual = await importOriginal(); + const { promisify } = await import("node:util"); + const execute = vi.fn(async (command: string, args: string[], options: { env?: NodeJS.ProcessEnv }) => { + boundary.commands.push({ command, args, env: options.env }); + return { stdout: "", stderr: "" }; + }); + const execFile = Object.assign(vi.fn(), { [promisify.custom]: execute }); + return { ...actual, execFile }; +}); + +const input = { buffer: Buffer.from([0x50, 0x4b, ...Buffer.from("synthetic-source")]), originalFilename: "synthetic.docx" }; +const install = join(process.cwd(), ".dev-stack-synthetic", "install"); +beforeEach(() => { + boundary.directories.length = 0; boundary.writes.length = 0; boundary.commands.length = 0; + vi.stubEnv("SPACE_INSTANCE", "dev"); vi.stubEnv("SPACE_INSTALL_ROOT", install); + vi.stubEnv("SPACE_LIBREOFFICE_PATH", undefined); vi.stubEnv("SPACE_AGENT_FILE_DOCX_WORK_ROOT", undefined); +}); +afterEach(() => vi.unstubAllEnvs()); + +describe("dev DOCX normalization isolation", () => { + it("fails before any write or installed broker call without an explicit direct normalizer", async () => { + await expect(normalizeAgentFileDocx(input)).rejects.toThrow(/installed privileged broker is not used/); + expect(boundary.directories).toEqual([]); expect(boundary.writes).toEqual([]); expect(boundary.commands).toEqual([]); + }); + + it("confines explicitly configured direct normalization to the private install root", async () => { + const command = join(install, "bin", "synthetic-libreoffice"); + const output = await normalizeAgentFileDocx(input, { command }); + expect(output).not.toEqual(input.buffer); + expect(boundary.commands).toHaveLength(1); + expect(boundary.commands[0]!.command).toBe(command); + for (const path of [...boundary.directories, ...boundary.writes, boundary.commands[0]!.env!.HOME!]) { + const inside = relative(install, path); + expect(inside).not.toMatch(/^\.\.(?:[/\\]|$)/); expect(isAbsolute(inside)).toBe(false); + } + }); + + it("rejects a direct dev normalizer's work-directory escape before writing", async () => { + await expect(normalizeAgentFileDocx(input, { command: "/synthetic/libreoffice", temporaryRoot: "/opt/spaceapp/var/agent-docx-work" })).rejects.toThrow(/escape/); + expect(boundary.directories).toEqual([]); expect(boundary.commands).toEqual([]); + }); + + it("preserves the stable work directory and broker arguments without executing either", async () => { + vi.stubEnv("SPACE_INSTANCE", "stable"); + await normalizeAgentFileDocx(input); + expect(boundary.directories[0]).toBe("/opt/spaceapp/var/agent-docx-work"); + expect(boundary.commands).toHaveLength(1); + expect(boundary.commands[0]).toMatchObject({ command: "/usr/bin/sudo", args: ["-n", "/opt/spaceapp/bin/space-docx-normalizer"] }); + }); +}); diff --git a/apps/api/tests/dev-incidents-storage.test.ts b/apps/api/tests/dev-incidents-storage.test.ts new file mode 100644 index 000000000..b19603f08 --- /dev/null +++ b/apps/api/tests/dev-incidents-storage.test.ts @@ -0,0 +1,80 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, relative, resolve } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import { InMemorySpaceStore } from "@space/runtime"; +import { InMemoryAppDiagnosticsRepository } from "@space/db"; +import { createApp, type CreateAppOptions } from "../src/app.js"; +import { AppDiagnosticsService } from "../src/app-diagnostics.js"; +import { getApiConfig } from "../src/config.js"; +import { cookieName, createCsrfToken, signSession } from "../src/auth.js"; + +const boundary = vi.hoisted(() => ({ root: "", directories: [] as string[], incidents: [] as { path: string; data: string }[] })); +vi.mock("node:fs/promises", async importOriginal => { + const actual = await importOriginal(); + const privatePath = (path: unknown) => { + const inside = relative(resolve(boundary.root), resolve(String(path))); + return boundary.root && inside !== ".." && !inside.startsWith("../") && !inside.startsWith("..\\") && !isAbsolute(inside); + }; + return { ...actual, + mkdir: vi.fn(async (path: Parameters[0], options: Parameters[1]) => { + if (String(path).endsWith("live-voice-logs")) { boundary.directories.push(String(path)); return; } + if (!privatePath(path)) throw new Error("Test blocked mkdir outside its private fixture."); + return actual.mkdir(path, options); + }), + appendFile: vi.fn(async (path: unknown, data: unknown) => { + boundary.incidents.push({ path: String(path), data: String(data) }); + // Both old installed and corrected dev destinations are virtualized. + }), + writeFile: vi.fn(async (path: Parameters[0], data: Parameters[1], options: Parameters[2]) => { + if (!privatePath(path)) throw new Error("Test blocked write outside its private fixture."); + return actual.writeFile(path, data, options); + }) + }; +}); +let app: Awaited> | undefined; +afterEach(async () => { + await app?.close(); app = undefined; + vi.unstubAllEnvs(); + if (boundary.root) { + const inside = relative(resolve(tmpdir()), resolve(boundary.root)); + if (!inside.startsWith("space-dev-incident-") || isAbsolute(inside) || inside.includes("/") || inside.includes("\\")) throw new Error("Unexpected fixture cleanup path."); + await rm(boundary.root, { recursive: true, force: true }); + } + boundary.root = ""; boundary.directories.length = 0; boundary.incidents.length = 0; +}); + +it("retains actual full-room failure and writes its diagnostic only to private dev storage", async () => { + boundary.root = await mkdtemp(join(tmpdir(), "space-dev-incident-")); + vi.stubEnv("SPACE_INSTANCE", "dev"); + vi.stubEnv("SPACE_INSTALL_ROOT", join(boundary.root, "install")); + const store = new InMemorySpaceStore(); + const user = { id: "user:dev-capacity", role: "ADMIN" as const, email: "synthetic@example.invalid" }; + store.upsertUser(user); + const room = store.createRoom({ name: "Full private dev room", initialPaneCount: 0, kind: "AGENT_PROOF" }); + for (let index = 0; index < room.paneCap; index++) store.createPane({ roomId: room.id, title: `Synthetic pane ${index}`, mode: "TERMINAL" }); + const secret = "synthetic-dev-incident-test-session"; + app = await createApp({ store, + config: getApiConfig({ SPACE_INSTANCE: "dev", SPACE_OPTIONAL_MODULES: "none", SPACE_PUBLIC_DISTRIBUTION: "true", SPACE_LOG_LEVEL: "silent", SPACE_CLI_ENABLED: "false", SPACE_CLI_COMMAND_PATH: "/nonexistent-dev-bin", SPACE_T3CODE_ENABLED: "false", SPACE_ARTIFACT_ROOT: boundary.root, SPACE_APP_DIAGNOSTICS_ROOT: join(boundary.root, "diagnostics") }), + auth: { sessionSecret: secret, devLogin: false, secureCookies: false }, + // Personal Git is outside this test; prevent its Unix-only credential-store + // constructor from running on native Windows, and do not contact providers. + sourceControlService: { close: async () => undefined } as unknown as CreateAppOptions["sourceControlService"], + streamingService: { initialize: async () => undefined, dispose: async () => undefined } as unknown as CreateAppOptions["streamingService"], + appDiagnosticsService: new AppDiagnosticsService({ repository: new InMemoryAppDiagnosticsRepository(), root: join(boundary.root, "diagnostics") }) + }); + const token = signSession(user, secret); + const response = await app.inject({ method: "POST", url: "/api/control/v1/commands", + headers: { cookie: `${cookieName}=${token}`, "x-space-csrf-token": createCsrfToken(token, secret)! }, + payload: { roomId: room.id, requestId: "dev-capacity-negative-path", actions: [{ kind: "resource", operation: "panes.open", input: { counts: { opencode: 1 } } }] } + }); + expect(response.statusCode).toBe(200); + expect(response.json().status).toBe("FAILED"); + expect(JSON.stringify(response.json())).toMatch(/capacity exceeded/i); + await vi.waitFor(() => expect(boundary.incidents).toHaveLength(1)); + const directory = join(boundary.root, "install", "var", "live-voice-logs"); + expect(boundary.directories).toEqual([directory]); + expect(boundary.incidents[0]!.path).toBe(`${directory}/debug-incidents.jsonl`); + expect(JSON.parse(boundary.incidents[0]!.data)).toMatchObject({ operation: "panes.open", roomId: room.id, roomCapacity: { freeSlots: 0 } }); + expect(store.listPanes(room.id)).toHaveLength(room.paneCap); +}); diff --git a/apps/api/tests/dev-oauth-storage.test.ts b/apps/api/tests/dev-oauth-storage.test.ts new file mode 100644 index 000000000..c56f7499f --- /dev/null +++ b/apps/api/tests/dev-oauth-storage.test.ts @@ -0,0 +1,101 @@ +import Fastify from "fastify"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { InMemorySpaceStore } from "@space/runtime"; +import { getAuthConfig } from "../src/auth.js"; +import { registerAuthRoutes } from "../src/auth-routes.js"; +import { getApiConfig } from "../src/config.js"; +import { instanceSecretDirectories } from "../src/instance-storage.js"; +import { locations, serviceEnvironment } from "../../../scripts/dev-stack/state.mjs"; + +const files = vi.hoisted(() => ({ + values: new Map(), reads: [] as string[], writes: [] as string[], directories: [] as string[] +})); + +// Virtualize both installed and private OAuth boundaries. No baseline control can +// read a real operator file or mkdir/write /opt, /var, or a Windows drive root. +vi.mock("node:fs", async importOriginal => { + const actual = await importOriginal(); + return { ...actual, + existsSync: vi.fn((path: Parameters[0]) => { + if (!String(path).endsWith("google-oauth.json")) return actual.existsSync(path); + files.reads.push(String(path)); + return files.values.has(String(path)); + }), + readFileSync: vi.fn((path: Parameters[0], ...args: unknown[]) => { + if (String(path).endsWith("google-oauth.json")) return files.values.get(String(path)); + return Reflect.apply(actual.readFileSync, actual, [path, ...args]); + }) + }; +}); +vi.mock("node:fs/promises", async importOriginal => { + const actual = await importOriginal(); + return { ...actual, + mkdir: vi.fn(async (path: unknown) => { files.directories.push(String(path)); }), + writeFile: vi.fn(async (path: unknown, data: unknown) => { + files.writes.push(String(path)); files.values.set(String(path), String(data)); + }) + }; +}); + +function devEnvironment() { + const config = locations(process.cwd(), {}); + return serviceEnvironment(config, { ports: config.ports, nonce: "0".repeat(32) }, + { password: "synthetic-database-value", session: "synthetic-session-value" }, {}); +} +const synthetic = { clientId: "synthetic-client.apps.invalid", clientSecret: "synthetic-test-value" }; +beforeEach(() => { + files.values.clear(); files.reads.length = 0; files.writes.length = 0; files.directories.length = 0; +}); +afterEach(() => vi.unstubAllEnvs()); + +describe("dev OAuth file isolation", () => { + it("does not inspect installed credentials when using the launcher's actual private environment", () => { + const env = devEnvironment(); + files.values.set("/opt/spaceapp/secrets/google-oauth.json", JSON.stringify(synthetic)); + files.values.set("/var/lib/spaceapp/google-oauth.json", JSON.stringify(synthetic)); + expect(getAuthConfig(env).googleClientId).toBeUndefined(); + expect(files.reads).toEqual([`${join(env.SPACE_INSTALL_ROOT, "secrets")}/google-oauth.json`]); + const privateFile = files.reads[0]!; + files.values.set(privateFile, JSON.stringify(synthetic)); files.reads.length = 0; + expect(getAuthConfig(env)).toMatchObject({ googleClientId: synthetic.clientId, googleClientSecret: synthetic.clientSecret }); + expect(files.reads).toEqual([privateFile]); + }); + + it("preserves both stable loader candidates and their fallback order", () => { + expect(instanceSecretDirectories({})).toEqual(["/opt/spaceapp/secrets", "/var/lib/spaceapp"]); + files.values.set("/var/lib/spaceapp/google-oauth.json", JSON.stringify(synthetic)); + expect(getAuthConfig({})).toMatchObject({ googleClientId: synthetic.clientId }); + expect(files.reads).toEqual(["/opt/spaceapp/secrets/google-oauth.json", "/var/lib/spaceapp/google-oauth.json"]); + }); + + it("saves synthetic settings through the real admin route only to the dev-private file", async () => { + const env = devEnvironment(); + vi.stubEnv("SPACE_INSTANCE", env.SPACE_INSTANCE); + vi.stubEnv("SPACE_INSTALL_ROOT", env.SPACE_INSTALL_ROOT); + const app = Fastify(); + const user = { id: "user:synthetic-dev-owner", email: "synthetic@example.invalid", role: "ADMIN" as const }; + app.decorateRequest("user", null); + app.addHook("onRequest", async request => { request.user = user; }); + type Options = Parameters[1]; + const unused = new Proxy({}, { get() { throw new Error("This offline storage test must not use browser/OAuth services."); } }); + registerAuthRoutes(app, { + // Route options do not launch Unix-only CLI hosts; OAuth still consumes the + // exact launcher environment above on both native Windows and hosted POSIX. + config: { ...getApiConfig({}), instance: "dev" }, auth: getAuthConfig(env), store: new InMemorySpaceStore(), routeOptions: {}, + browserSessionManager: unused as Options["browserSessionManager"], + connectorStateTools: unused as Options["connectorStateTools"], connectorServiceRef: { current: null }, + googleAccountBrowserStatuses: async () => new Map(), dotsAuthenticatedOwner: async () => user.id, + persistRevocation: async pending => { await pending; } + }); + try { + const response = await app.inject({ method: "POST", url: "/api/auth/google/configure", payload: synthetic }); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ ok: true, googleAuthEnabled: true }); + const directory = join(env.SPACE_INSTALL_ROOT, "secrets"); + expect(files.directories).toEqual([directory]); + expect(files.writes).toEqual([`${directory}/google-oauth.json`]); + expect(getAuthConfig(env)).toMatchObject({ googleClientId: synthetic.clientId, googleClientSecret: synthetic.clientSecret }); + } finally { await app.close(); } + }); +}); diff --git a/apps/api/tests/instance-storage.test.ts b/apps/api/tests/instance-storage.test.ts new file mode 100644 index 000000000..bf6b2e1c3 --- /dev/null +++ b/apps/api/tests/instance-storage.test.ts @@ -0,0 +1,97 @@ +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative, resolve, sep } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { instanceStorageDirectory } from "../src/instance-storage.js"; +import { PluginsService } from "../src/plugins-service.js"; + +const temporaryRoots: string[] = []; +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "space-instance-storage-")); + temporaryRoots.push(root); + return root; +} + +afterEach(async () => { + vi.unstubAllEnvs(); + for (const root of temporaryRoots.splice(0)) { + const inside = relative(resolve(tmpdir()), resolve(root)); + if (!inside.startsWith("space-instance-storage-") || inside.includes(sep)) { + throw new Error("Refusing cleanup outside this test's temporary root."); + } + await rm(root, { recursive: true, force: true }); + } +}); + +describe("instance-local service storage", () => { + it("preserves installed defaults and the stable personal Git override", () => { + expect(instanceStorageDirectory("plugins", undefined, {})).toBe("/opt/spaceapp/var/plugins"); + expect(instanceStorageDirectory("personal-source-control", undefined, {})).toBe("/opt/spaceapp/var/personal-source-control"); + expect(instanceStorageDirectory("personal-source-control", "/operator/chosen", { SPACE_INSTANCE: "stable" })).toBe("/operator/chosen"); + }); + + it("fails closed without a private absolute dev install root", async () => { + for (const SPACE_INSTALL_ROOT of [undefined, "relative", "/opt/spaceapp", "/opt/spaceapp/"]) { + expect(() => instanceStorageDirectory("plugins", undefined, { SPACE_INSTANCE: "dev", SPACE_INSTALL_ROOT })).toThrow(/private absolute/); + } + const root = await fixture(); + const env = { SPACE_INSTANCE: "dev", SPACE_INSTALL_ROOT: join(root, "install") }; + expect(() => instanceStorageDirectory("personal-source-control", "relative", env)).toThrow(/absolute/); + expect(() => instanceStorageDirectory("personal-source-control", join(root, "operator"), env)).toThrow(/escape/); + expect(() => instanceStorageDirectory("personal-source-control", `${env.SPACE_INSTALL_ROOT}-sibling`, env)).toThrow(/escape/); + }); + + it("keeps two dev instances and an explicit credential override in their own roots", async () => { + const root = await fixture(); + const first = { SPACE_INSTANCE: "dev", SPACE_INSTALL_ROOT: join(root, "first") }; + const second = { SPACE_INSTANCE: "dev", SPACE_INSTALL_ROOT: join(root, "second") }; + for (const name of ["plugins", "personal-source-control"] as const) { + expect(instanceStorageDirectory(name, undefined, first)).toBe(join(first.SPACE_INSTALL_ROOT, "var", name)); + expect(instanceStorageDirectory(name, undefined, second)).toBe(join(second.SPACE_INSTALL_ROOT, "var", name)); + expect(instanceStorageDirectory(name, undefined, first)).not.toBe(instanceStorageDirectory(name, undefined, second)); + } + const credentials = join(first.SPACE_INSTALL_ROOT, "private-credentials"); + expect(instanceStorageDirectory("personal-source-control", credentials, first)).toBe(credentials); + }); + + it("persists real dev plugin changes privately without syncing operator skill links", async () => { + const root = await fixture(); + const install = join(root, "install"), operator = join(root, "operator-skills"); + await mkdir(operator); + await writeFile(join(operator, "owner-sentinel"), "unchanged operator data"); + vi.stubEnv("SPACE_INSTANCE", "dev"); + vi.stubEnv("SPACE_INSTALL_ROOT", install); + vi.stubEnv("SPACE_INSTANCE_ENABLED_JOBS", ""); + const service = new PluginsService(undefined, undefined, [operator]); + const synced = vi.fn(async () => undefined); + service.onSkillsSynced = synced; + expect((await service.getPlugins()).length).toBeGreaterThan(0); + const config = { paneId: "dev-pane", enabledPluginIds: ["superpowers"], disabledPluginIds: [] }; + await service.setAgentConfig(config); + expect(await service.getAgentConfig(config.paneId)).toEqual(config); + expect((await service.connectPlugin({ pluginId: "superpowers", credentials: {} })).status).toBe("CONNECTED"); + expect((await service.disconnectPlugin("superpowers")).status).toBe("NOT_CONNECTED"); + await service.syncSkillSymlinks(); + expect(synced).not.toHaveBeenCalled(); + expect(await readdir(operator)).toEqual(["owner-sentinel"]); + expect(await readFile(join(operator, "owner-sentinel"), "utf8")).toBe("unchanged operator data"); + const storage = join(install, "var", "plugins"); + expect(JSON.parse(await readFile(join(storage, "connections.json"), "utf8"))[0]).toMatchObject({ pluginId: "superpowers", status: "NOT_CONNECTED" }); + expect(JSON.parse(await readFile(join(storage, "agent-configs.json"), "utf8"))).toEqual([config]); + }); + + it("retains stable synchronization and honors the existing explicit dev opt-in", async () => { + const root = await fixture(); + for (const instance of ["stable", "dev"] as const) { + vi.stubEnv("SPACE_INSTANCE", instance); + vi.stubEnv("SPACE_INSTANCE_ENABLED_JOBS", instance === "dev" ? "plugin-skill-links" : ""); + // No installed skill directory is ever used, even by this active-sync control. + const service = new PluginsService(join(root, instance), undefined, []); + const synced = vi.fn(async () => undefined); + service.onSkillsSynced = synced; + await service.connectPlugin({ pluginId: "superpowers", credentials: {} }); + await service.disconnectPlugin("superpowers"); + expect(synced).toHaveBeenCalledTimes(2); + } + }); +}); diff --git a/docs/development-stack.md b/docs/development-stack.md index b694f661c..4c5b21444 100644 --- a/docs/development-stack.md +++ b/docs/development-stack.md @@ -37,6 +37,9 @@ first-launch model smoke test. Authentication/inference commands are blocked; the acceptance proof sends no model prompt and needs no provider API key/account. Other provider wrappers, companion services, root CLI/file administration, background integrations and optional modules are disabled in this isolated stack. +DOCX conversion never falls back to the installed privileged normalizer in dev; +it requires an explicitly configured direct LibreOffice command and private work +directory. The launcher does not install that optional tool. Changes in `apps/api/src` restart the API. Worker or shared-package source changes stop the dependent source services, run the normal complete build, and restart @@ -44,6 +47,9 @@ them from this checkout. Vite handles web HMR directly, including WebSocket prox to the chosen API port. A required child exiting unexpectedly produces a named error and stops the stack. Sustained readiness loss also fails instead of leaving an apparently healthy partial stack. +On failure, bounded logs and health-probe output from owned containers are redacted +before cleanup. This capture does not expose container environments and a failed +diagnostic cannot skip the normal ownership checks or cleanup. ## State, ports and privacy @@ -61,7 +67,10 @@ npm run dev:stack The same state preserves its ports and database across restarts. It gets a random Compose project and ownership nonce, separate database/Temporal namespace/task -queue, Unix socket, writable directories and CLI/provider homes. The subprocess +queue, Unix socket, writable directories and CLI/provider homes. Plugin configuration, +personal Git credential storage, OAuth settings and diagnostic history stay under +that private install root. The canonical dev-instance job policy disables plugin +skill-link synchronization, including connect/disconnect calls. The subprocess environment is allowlisted; inherited provider keys, session secrets, operator homes and SSH agents are not forwarded to application children. Docker control connectivity still uses your selected Docker context, including the normal Docker diff --git a/scripts/dev-stack/resources.mjs b/scripts/dev-stack/resources.mjs index 31b4ada6a..2ece1c915 100644 --- a/scripts/dev-stack/resources.mjs +++ b/scripts/dev-stack/resources.mjs @@ -43,12 +43,31 @@ export async function assertOwnedDocker(owner, invoke = run, env = dockerEnviron export async function compose(config, owner, args, options = {}) { return run('docker', composeArgs(config, owner, args), { env: dockerEnvironment(), ...options }); } -export async function removeOwnedDocker(config, owner, { volumes = false, output, secrets } = {}) { - await assertOwnedDocker(owner); - await compose(config, owner, ['down', '--remove-orphans', ...(volumes ? ['--volumes'] : [])], { output, secrets, timeout: 120000 }); - await assertOwnedDocker(owner); +export async function diagnoseOwnedDocker(config, owner, { output = () => {}, secrets = [], invoke = run, env = dockerEnvironment() } = {}) { + const signal = AbortSignal.timeout(15000); + const control = (command, args, options = {}) => invoke(command, args, { env, timeout: 5000, signal, ...options }); + await assertOwnedDocker(owner, control, env); + const redactor = lineRedactor(secrets, output); + try { + output('[dev-stack] Bounded owned-service logs and health probes before cleanup:\n'); + await control('docker', composeArgs(config, owner, ['logs', '--no-color', '--tail', '100', 'postgres', 'temporal']), { secrets, output: chunk => redactor.chunk(chunk) }); + const ids = (await control('docker', ['container', 'ls', '-a', '-q', '--filter', `label=com.docker.compose.project=${owner.project}`])).trim().split(/\s+/).filter(Boolean); + for (const id of ids) { + // Health has status/probe output only. Never inspect Config.Env or the whole container. + await control('docker', ['container', 'inspect', '--format', '{{json .State.Health}}', id], { secrets, output: chunk => redactor.chunk(chunk) }); + } + } finally { redactor.end(); } +} +export async function removeOwnedDocker(config, owner, { volumes = false, output, secrets, diagnose = false, invoke = run, env = dockerEnvironment() } = {}) { + await assertOwnedDocker(owner, invoke, env); + if (diagnose) { + try { await diagnoseOwnedDocker(config, owner, { output, secrets, invoke, env }); } + catch { output?.('[dev-stack] Bounded diagnostics unavailable; continuing owned cleanup.\n'); } + } + await invoke('docker', composeArgs(config, owner, ['down', '--remove-orphans', ...(volumes ? ['--volumes'] : [])]), { env, output, secrets, timeout: 120000 }); + await assertOwnedDocker(owner, invoke, env); for (const kind of ['container', 'network', ...(volumes ? ['volume'] : [])]) { - const remaining = await run('docker', [kind, 'ls', ...(kind === 'container' ? ['-a'] : []), '-q', '--filter', `label=com.docker.compose.project=${owner.project}`], { env: dockerEnvironment() }); + const remaining = await invoke('docker', [kind, 'ls', ...(kind === 'container' ? ['-a'] : []), '-q', '--filter', `label=com.docker.compose.project=${owner.project}`], { env }); if (remaining.trim()) throw new Error(`Owned ${kind} remains; recovery state retained.`); } } diff --git a/scripts/dev-stack/state.mjs b/scripts/dev-stack/state.mjs index 1d615f086..5844378be 100644 --- a/scripts/dev-stack/state.mjs +++ b/scripts/dev-stack/state.mjs @@ -71,7 +71,7 @@ export function composeDocument(owner, pins, password) { return { services: { postgres: { image: pins.postgres, labels, environment: { POSTGRES_USER: 'spaceapp', POSTGRES_DB: 'spaceapp_dev', POSTGRES_PASSWORD: password }, ports: [`127.0.0.1:${owner.ports.postgres}:5432`], volumes: ['postgres:/var/lib/postgresql/data'], healthcheck: { test: ['CMD-SHELL', 'pg_isready -U spaceapp -d spaceapp_dev'], interval: '2s', timeout: '5s', retries: 90 } }, - temporal: { image: pins.temporal, labels, depends_on: { postgres: { condition: 'service_healthy' } }, environment: { DB: 'postgres12', DB_PORT: '5432', POSTGRES_USER: 'spaceapp', POSTGRES_PWD: password, POSTGRES_SEEDS: 'postgres', DEFAULT_NAMESPACE: 'spaceapp-dev', DYNAMIC_CONFIG_FILE_PATH: 'config/dynamicconfig/docker.yaml' }, entrypoint: ['/bin/sh', '-ec'], command: ['exec /etc/temporal/entrypoint.sh autosetup'], ports: [`127.0.0.1:${owner.ports.temporal}:7233`], healthcheck: { test: ['CMD-SHELL', 'temporal operator cluster health --address 127.0.0.1:7233 >/dev/null && temporal operator namespace describe --namespace spaceapp-dev --address 127.0.0.1:7233 >/dev/null'], interval: '3s', timeout: '10s', retries: 90 } } + temporal: { image: pins.temporal, labels, depends_on: { postgres: { condition: 'service_healthy' } }, environment: { DB: 'postgres12', DB_PORT: '5432', POSTGRES_USER: 'spaceapp', POSTGRES_PWD: password, POSTGRES_SEEDS: 'postgres', DEFAULT_NAMESPACE: 'spaceapp-dev', DYNAMIC_CONFIG_FILE_PATH: 'config/dynamicconfig/docker.yaml' }, entrypoint: ['/bin/sh', '-ec'], command: ['exec /etc/temporal/entrypoint.sh autosetup'], ports: [`127.0.0.1:${owner.ports.temporal}:7233`], healthcheck: { test: ['CMD-SHELL', 'temporal operator cluster health --address temporal:7233 >/dev/null && temporal operator namespace describe --namespace spaceapp-dev --address temporal:7233 >/dev/null'], interval: '3s', timeout: '10s', retries: 90 } } }, volumes: { postgres: { labels } }, networks: { default: { labels } } }; @@ -90,7 +90,7 @@ export function serviceEnvironment(config, owner, secret, inherited = {}) { SPACE_API_HOST: '127.0.0.1', SPACE_API_PORT: String(owner.ports.api), SPACE_API_ORIGIN: `http://127.0.0.1:${owner.ports.api}`, SPACE_WEB_HOST: '127.0.0.1', SPACE_WEB_PORT: String(owner.ports.web), SPACE_BROWSER_EVIDENCE_TARGET_ORIGIN: `http://127.0.0.1:${owner.ports.web}`, SPACE_RUNTIME_STORE: 'postgres', SPACE_DATABASE_URL: `postgres://spaceapp:${encodeURIComponent(secret.password)}@127.0.0.1:${owner.ports.postgres}/spaceapp_dev`, SPACE_TEMPORAL_ADDRESS: `127.0.0.1:${owner.ports.temporal}`, SPACE_TEMPORAL_NAMESPACE: 'spaceapp-dev', SPACE_TEMPORAL_TASK_QUEUE: `spaceapp-dev-${owner.nonce.slice(0, 16)}`, SPACEAPP_WORKFLOWS_ENABLED: 'true', SPACE_CLI_ENABLED: 'true', SPACE_CLI_HOST_SOCKET: p('run/cli.sock'), SPACE_CLI_ADMIN_HOST_SOCKET: p('run/disabled-admin.sock'), SPACE_CLI_COMMAND_PATH: p('cli/bin'), SPACE_CLI_WORKSPACE_ROOT: p('workspaces'), SPACE_CLI_ROOT_ENABLED: 'false', - SPACE_INSTALL_ROOT: p('install'), SPACE_ARTIFACT_ROOT: p('artifacts'), SPACE_MEMORY_ROOT: p('memory'), SPACE_MEMORY_GRAPH_ROOT: p('memory-graph'), SPACE_STARTER_MEMORY_ROOT: join(config.root, 'starter-memory'), + SPACE_INSTALL_ROOT: p('install'), SPACE_PERSONAL_GIT_ROOT: p('install/var/personal-source-control'), SPACE_AGENT_FILE_DOCX_WORK_ROOT: p('install/var/agent-docx-work'), SPACE_ARTIFACT_ROOT: p('artifacts'), SPACE_MEMORY_ROOT: p('memory'), SPACE_MEMORY_GRAPH_ROOT: p('memory-graph'), SPACE_STARTER_MEMORY_ROOT: join(config.root, 'starter-memory'), SPACE_GEMINI_MEMORY_INDEX_PATH: p('memory/gemini_history.md'), SPACE_GEMINI_MEMORY_MONTHLY_PATH: p('memory/gemini_history_monthly.md'), SPACE_GEMINI_MEMORY_LOCK_PATH: p('memory/.memory.lock'), SPACE_CODEX_HOME: p('providers/codex'), SPACE_CODEX_APP_SERVER_HOME: p('providers/codex'), SPACE_CODEX_GOALS_DB_PATH: p('providers/codex/goals.sqlite'), SPACE_CODEX_CLI_MODE_DEFAULTS_PROJECTION_PATH: p('providers/codex/modes.json'), SPACE_APP_DIAGNOSTICS_ROOT: p('diagnostics'), SPACE_MAINTENANCE_AUTOPILOT_STATE_FILE: p('maintenance/state.json'), SPACE_CLI_ACCOUNT_BALANCER_STATE_FILE: p('accounts/state.json'), SPACE_TELEGRAM_SECRET_ROOT: p('telegram'), SPACE_TELEGRAM_OWNERSHIP_MANIFEST: p('telegram/ownership.json'), SPACE_STREAMING_SECRET_ROOT: p('streaming'), diff --git a/scripts/dev-stack/supervisor.mjs b/scripts/dev-stack/supervisor.mjs index af570c7ac..3f0e62ed9 100644 --- a/scripts/dev-stack/supervisor.mjs +++ b/scripts/dev-stack/supervisor.mjs @@ -78,7 +78,7 @@ export async function start(config, inherited = process.env) { if (stopping) return; stopping = true; cancellation.abort(); clearTimeout(debounce); clearInterval(statusTimer); clearInterval(readyTimer); for (const w of watchers) w.close(); - try { await activeCommand.catch(() => {}); await launches.stop(); await stopChildren(); await removeOwnedDocker(config, owner, { output: log, secrets: redactions }); await writeTail; await unlink(config.supervisor); } + try { await activeCommand.catch(() => {}); await launches.stop(); await stopChildren(); await removeOwnedDocker(config, owner, { output: log, secrets: redactions, diagnose: code !== 0 }); await writeTail; await unlink(config.supervisor); } catch (error) { say(`Cleanup failed: ${error.message} Recovery state kept; retry down/reset.`); process.exitCode = 1; return; } say('All owned services stopped.'); process.exitCode = code; } diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs index 2f26dc761..cae8f99c0 100644 --- a/scripts/tests/dev-stack.test.mjs +++ b/scripts/tests/dev-stack.test.mjs @@ -7,7 +7,7 @@ import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import { composeDocument, extractPins, initialize, loadOwner, locations, ownerLabel, privateWrite, secrets, serviceEnvironment } from '../dev-stack/state.mjs'; import { descendants, LaunchBarrier, lineRedactor, processIdentity, sameProcess, stopRecorded } from '../dev-stack/processes.mjs'; -import { assertOwnedDocker, composeArgs, dockerEnvironment, run } from '../dev-stack/resources.mjs'; +import { assertOwnedDocker, composeArgs, diagnoseOwnedDocker, dockerEnvironment, removeOwnedDocker, run } from '../dev-stack/resources.mjs'; const root = fileURLToPath(new URL('../..', import.meta.url)); const pins = extractPins(await readFile(join(root, 'packages/run-spaceapp/templates/compose.yml'), 'utf8'), await readFile(join(root, 'Dockerfile'), 'utf8')); @@ -88,18 +88,63 @@ test('generated Docker services keep pins, loopback ports, generated ownership l for (const service of Object.values(doc.services)) { assert.equal(service.labels[ownerLabel], owner.nonce); assert.match(service.image, /@sha256:/); for (const port of service.ports) assert.match(port, /^127\.0\.0\.1:/); } assert.equal(doc.volumes.postgres.labels[ownerLabel], owner.nonce); assert.equal(doc.networks.default.labels[ownerLabel], owner.nonce); assert.equal(doc.services.temporal.environment.POSTGRES_PWD, secret.password); + const probe = doc.services.temporal.healthcheck.test[1]; + assert.match(probe, /cluster health --address temporal:7233/); + assert.match(probe, /namespace describe --namespace spaceapp-dev --address temporal:7233/); + assert(!probe.includes('127.0.0.1:7233')); }); test('service environment isolates all writable state and never forwards provider credentials', async t => { const config = await sandbox(t), owner = await initialize(config), secret = await secrets(config); - const env = serviceEnvironment(config, owner, secret, { PATH: '/node/bin:/usr/bin', HOME: '/owner/private', OPENAI_API_KEY: 'synthetic-should-not-propagate', SPACE_DEV_LOGIN: 'true', SSH_AUTH_SOCK: '/owner/agent' }); + const env = serviceEnvironment(config, owner, secret, { PATH: '/node/bin:/usr/bin', HOME: '/owner/private', OPENAI_API_KEY: 'synthetic-should-not-propagate', SPACE_DEV_LOGIN: 'true', SPACE_INSTANCE_ENABLED_JOBS: 'plugin-skill-links', SPACE_LIBREOFFICE_PATH: '/owner/private/normalizer', SSH_AUTH_SOCK: '/owner/agent' }); assert.equal(env.OPENAI_API_KEY, undefined); assert.equal(env.SSH_AUTH_SOCK, undefined); assert.equal(env.SPACE_DEV_LOGIN, 'false'); assert.equal(env.HOME, join(config.state, 'home')); assert.equal(env.SPACE_API_ORIGIN, 'http://127.0.0.1:4810'); assert.equal(env.SPACEAPP_WORKFLOWS_ENABLED, 'true'); + assert.equal(env.SPACE_PERSONAL_GIT_ROOT, join(config.state, 'install/var/personal-source-control')); + assert.equal(env.SPACE_AGENT_FILE_DOCX_WORK_ROOT, join(config.state, 'install/var/agent-docx-work')); + assert.equal(env.SPACE_INSTANCE_ENABLED_JOBS, undefined); assert.equal(env.SPACE_LIBREOFFICE_PATH, undefined); assert.equal(env.SPACE_ENABLE_CLI_CHAT_TURNS, 'false'); assert.equal(env.SPACE_CLI_OPENCODE_CREDENTIAL_SMOKE, 'false'); assert(Object.values(env).every(x => !x.includes('/owner/private') && !x.includes('/opt/spaceapp'))); }); test('Docker context forwarding is limited to control-plane connectivity', () => { assert.deepEqual(dockerEnvironment({ PATH: '/bin', DOCKER_CONTEXT: 'colima', OPENAI_API_KEY: 'synthetic', SPACE_SESSION_SECRET: 'synthetic' }), { PATH: '/bin', DOCKER_CONTEXT: 'colima' }); }); +test('owned failure diagnostics redact split secrets and select health data without container environments', async t => { + const config = await sandbox(t), owner = await initialize(config), calls = []; + let output = ''; + const invoke = async (_command, args, options) => { + calls.push(args); + if (args[1] === 'ls') return args[0] === 'container' ? 'owned-container' : ''; + if (args.includes('{{json .Config.Labels}}')) return JSON.stringify({ [ownerLabel]: owner.nonce }); + if (args.includes('logs')) { options.output('synthetic-pass'); options.output('word\n'); return ''; } + if (args.includes('{{json .State.Health}}')) { options.output('{"Status":"unhealthy","Output":"synthetic-password"}\n'); return ''; } + throw new Error('Unexpected diagnostic command'); + }; + await diagnoseOwnedDocker(config, owner, { invoke, env: { PATH: '/synthetic' }, secrets: ['synthetic-password'], output: text => { output += text; } }); + assert(!output.includes('synthetic-password')); assert.match(output, /\[redacted\]/); + assert(calls.some(args => args.includes('--tail') && args.includes('100'))); + assert(calls.some(args => args.includes('{{json .State.Health}}'))); + assert(!calls.some(args => args.includes('{{json .Config.Env}}') || (args.includes('inspect') && !args.includes('--format')))); +}); +test('diagnostics run before owned down and a diagnostic failure cannot prevent cleanup', async t => { + const config = await sandbox(t), owner = await initialize(config), calls = []; + let down = false, output = ''; + const invoke = async (_command, args) => { + calls.push(args); + if (args[1] === 'ls') return args[0] === 'container' && !down ? 'owned-container' : ''; + if (args.includes('{{json .Config.Labels}}')) return JSON.stringify({ [ownerLabel]: owner.nonce }); + if (args.includes('logs')) throw new Error('Synthetic diagnostic failure must not escape into logs'); + if (args.includes('down')) { down = true; return ''; } + throw new Error('Unexpected cleanup command'); + }; + await removeOwnedDocker(config, owner, { diagnose: true, invoke, env: {}, output: text => { output += text; } }); + assert(down); assert(calls.findIndex(args => args.includes('logs')) < calls.findIndex(args => args.includes('down'))); + assert.match(output, /diagnostics unavailable/); assert(!output.includes('Synthetic diagnostic failure')); +}); +test('a foreign project resource prevents both diagnostic reads and cleanup', async t => { + const config = await sandbox(t), owner = await initialize(config), calls = []; + const invoke = async (_command, args) => { calls.push(args); return args[1] === 'ls' ? 'foreign' : JSON.stringify({ [ownerLabel]: 'not-ours' }); }; + await assert.rejects(removeOwnedDocker(config, owner, { diagnose: true, invoke, env: {} }), /Foreign/); + assert(!calls.some(args => args.includes('logs') || args.includes('down') || args.includes('{{json .State.Health}}'))); +}); test('redactor handles secrets split between stdout chunks', () => { let output = ''; const stream = lineRedactor(['synthetic-password', 'synthetic-token'], x => { output += x; }); stream.chunk(Buffer.from('before synthetic-pass')); stream.chunk(Buffer.from('word\nsynthetic-to')); stream.chunk(Buffer.from('ken after')); stream.end(); From 4233164610595c156c22655fa51ecbb3b15a527f Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 17 Oct 2026 18:22:58 +0200 Subject: [PATCH 06/27] ci(dev): canonicalize hosted temporary root --- .github/workflows/dev-stack.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml index 6e7339ee8..0fdfef69c 100644 --- a/.github/workflows/dev-stack.yml +++ b/.github/workflows/dev-stack.yml @@ -67,6 +67,7 @@ jobs: node --input-type=module <<'NODE' import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; + import { appendFileSync, realpathSync } from 'node:fs'; assert.match(process.env.SPACE_DEV_CANDIDATE, /^[a-f0-9]{40}$/); const actual = execFileSync('git', ['rev-parse', 'HEAD'], {encoding:'utf8'}).trim(); assert.equal(actual, process.env.SPACE_DEV_CANDIDATE); @@ -74,6 +75,7 @@ jobs: assert.match(process.env.EXPECTED_SOURCE, /^[a-f0-9]{40}$/); assert.equal(actual, process.env.EXPECTED_SOURCE); } + appendFileSync(process.env.GITHUB_ENV, `TMPDIR=${realpathSync(process.env.RUNNER_TEMP)}\n`); console.log(`Exact source ${actual}; Node ${process.version}; ${process.platform}/${process.arch}`); NODE git diff --exit-code From 3dca961798a07596db99a261081a88b42a0d36be Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 17 Oct 2026 18:31:50 +0200 Subject: [PATCH 07/27] ci(dev): match platform validation to contributor scope --- .github/workflows/dev-stack.yml | 10 +++++++++- docs/development-stack.md | 11 +++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml index 0fdfef69c..9c7f1b307 100644 --- a/.github/workflows/dev-stack.yml +++ b/.github/workflows/dev-stack.yml @@ -114,9 +114,17 @@ jobs: - name: Full stock check run: npm run check - - name: Full stock tests + - name: Full stock tests (Linux reference platform) + if: runner.os == 'Linux' run: npm test + - name: Native macOS private-instance regressions + if: runner.os == 'macOS' + run: >- + node node_modules/vitest/vitest.mjs run --config apps/api/vitest.config.ts + apps/api/tests/instance-storage.test.ts apps/api/tests/dev-oauth-storage.test.ts + apps/api/tests/dev-docx-isolation.test.ts apps/api/tests/dev-incidents-storage.test.ts + - name: Full stock build run: npm run build diff --git a/docs/development-stack.md b/docs/development-stack.md index 4c5b21444..efa0e83b1 100644 --- a/docs/development-stack.md +++ b/docs/development-stack.md @@ -112,8 +112,15 @@ permissions, pins, environment isolation, redaction, PID reuse and failed comman These tests do not establish a working Docker stack or browser. `.github/workflows/dev-stack.yml` prepares only standard public `ubuntu-latest` -and `macos-15-intel` runners, checks the exact source, and executes unmodified -`npm run check`, `npm test`, `npm run build` and `npm run hygiene:preflight`. +and `macos-15-intel` runners and checks the exact source. Both run all 26 launcher +regressions, `npm run check`, `npm run build` and `npm run hygiene:preflight`. +Ubuntu also runs the full unmodified `npm test` suite. Native macOS runs all 13 +private-instance API regressions in the four `instance-storage`/`dev-*-storage` +and `dev-docx-isolation` test files, followed by the same complete real-stack proof. +The broader native-macOS stock suites retain upstream limitations, including the +Linux-only descriptor filesystem backend and `/proc` metadata helper; those suites +are not claimed to pass on macOS. Their assertions and the Linux full-suite gate +remain unchanged. The macOS job uses an ephemeral named Colima profile, removed afterward. It does not use a larger runner, paid fallback, provider credentials or an operator profile. From 31a06437bdf38162524e44b5da561a31bbe2f547 Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 17 Oct 2026 18:40:47 +0200 Subject: [PATCH 08/27] fix(dev): bind Linux process ownership to kernel boot --- docs/development-stack.md | 6 +++++- scripts/dev-stack/processes.mjs | 23 +++++++++++++++------ scripts/tests/dev-stack.test.mjs | 34 ++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/docs/development-stack.md b/docs/development-stack.md index efa0e83b1..bd6dde22e 100644 --- a/docs/development-stack.md +++ b/docs/development-stack.md @@ -104,6 +104,10 @@ the matching generated ownership label. A foreign resource, unavailable daemon or failed process/container removal fails the command and keeps recovery state. Restore the original Docker context and retry `down`/`reset`; do not delete the manifest as a shortcut. Stop the supervisor before manually repairing owned state. +Linux records include the kernel boot ID as well as process start ticks. A legacy +ticks-only record is refused and recovery state is kept; it is never guessed or +upgraded into permission to signal a process. Stop that stack with its original +launcher before changing versions, or independently resolve its old process state. ## Reproducible complete proof @@ -112,7 +116,7 @@ permissions, pins, environment isolation, redaction, PID reuse and failed comman These tests do not establish a working Docker stack or browser. `.github/workflows/dev-stack.yml` prepares only standard public `ubuntu-latest` -and `macos-15-intel` runners and checks the exact source. Both run all 26 launcher +and `macos-15-intel` runners and checks the exact source. Both run all 29 launcher regressions, `npm run check`, `npm run build` and `npm run hygiene:preflight`. Ubuntu also runs the full unmodified `npm test` suite. Native macOS runs all 13 private-instance API regressions in the four `instance-storage`/`dev-*-storage` diff --git a/scripts/dev-stack/processes.mjs b/scripts/dev-stack/processes.mjs index 72e06cded..0fa649ee2 100644 --- a/scripts/dev-stack/processes.mjs +++ b/scripts/dev-stack/processes.mjs @@ -3,16 +3,22 @@ import { readFile } from 'node:fs/promises'; import { promisify } from 'node:util'; const exec = promisify(execFile); export const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); -export async function processIdentity(pid) { +const bootPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; +const linuxIdentityPattern = /^linux:[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}:\d+$/; +export async function processIdentity(pid, { platform = process.platform, read = readFile } = {}) { if (!Number.isInteger(pid) || pid < 2) return null; - if (process.platform === 'linux') { + if (platform === 'linux') { + // Start ticks repeat after a reboot; persisted records must also match this kernel. + const boot = (await read('/proc/sys/kernel/random/boot_id', 'utf8')).trim(); + if (!bootPattern.test(boot)) throw new Error('Linux boot identity is invalid; recovery state retained.'); try { - const stat = await readFile(`/proc/${pid}/stat`, 'utf8'); + const stat = await read(`/proc/${pid}/stat`, 'utf8'); const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' '); - return ['Z', 'X'].includes(fields[0]) ? null : `linux:${fields[19]}`; + if (stat.lastIndexOf(')') < 0 || !/^[A-Za-z]$/.test(fields[0] || '') || !/^\d+$/.test(fields[19] || '')) throw new Error('Linux process identity is invalid; recovery state retained.'); + return ['Z', 'X'].includes(fields[0]) ? null : `linux:${boot}:${fields[19]}`; } catch (e) { if (['ENOENT', 'ESRCH'].includes(e.code)) return null; throw e; } } - if (process.platform === 'darwin') { + if (platform === 'darwin') { try { const { stdout } = await exec('ps', ['-p', String(pid), '-o', 'stat=', '-o', 'lstart=']); const [status, ...start] = stdout.trim().split(/\s+/); @@ -21,7 +27,12 @@ export async function processIdentity(pid) { } throw new Error('Dev-stack processes require Linux, macOS or Windows11 WSL2.'); } -export async function sameProcess(record, identity = processIdentity) { return Boolean(record?.identity && await identity(record.pid) === record.identity); } +export async function sameProcess(record, identity = processIdentity) { + if (typeof record?.identity === 'string' && record.identity.startsWith('linux:') && !linuxIdentityPattern.test(record.identity)) { + throw new Error('Legacy or invalid Linux process record; recovery state retained. No process can be adopted from start ticks alone.'); + } + return Boolean(record?.identity && await identity(record.pid) === record.identity); +} export async function descendants(root, { identity = processIdentity, list = () => exec('ps', ['-axo', 'pid=,ppid=']) } = {}) { if (!await sameProcess(root, identity)) throw new Error('Owned process root changed before descendant collection; recovery state retained.'); const { stdout } = await list(); diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs index cae8f99c0..1b78c7b47 100644 --- a/scripts/tests/dev-stack.test.mjs +++ b/scripts/tests/dev-stack.test.mjs @@ -155,6 +155,40 @@ test('PID reuse never signals a process whose identity changed', async () => { await stopRecorded([{ pid: 12345, identity: 'old' }], { identity: async () => 'new', signal: (...args) => signals.push(args), timeout: 0 }); assert.deepEqual(signals, []); }); +const bootA = '11111111-1111-4111-8111-111111111111', bootB = '22222222-2222-4222-8222-222222222222'; +function virtualLinux(boot, ticks = '42') { + const stat = `12345 (synthetic process) ${['S', ...Array(18).fill('0'), ticks].join(' ')}`; + return path => path === '/proc/sys/kernel/random/boot_id' ? Promise.resolve(boot + '\n') + : path === '/proc/12345/stat' ? Promise.resolve(stat) : Promise.reject(new Error('Unexpected virtual proc read')); +} +test('Linux identities distinguish same-PID start ticks and kernel boots using virtual proc reads', async () => { + const identify = (boot, ticks) => processIdentity(12345, { platform: 'linux', read: virtualLinux(boot, ticks) }); + assert.equal(await identify(bootA, '42'), `linux:${bootA}:42`); + assert.notEqual(await identify(bootA, '43'), await identify(bootA, '42')); + assert.notEqual(await identify(bootB, '42'), await identify(bootA, '42')); +}); +test('only a same-boot same-tick virtual process is signalled; reuse and reboot cannot be adopted', async () => { + const record = { pid: 12345, identity: `linux:${bootA}:42` }, signals = []; + for (const read of [virtualLinux(bootA, '43'), virtualLinux(bootB, '42')]) { + const identity = pid => processIdentity(pid, { platform: 'linux', read }); + assert.equal(await sameProcess(record, identity), false); + await stopRecorded([record], { identity, signal: (...args) => signals.push(args), timeout: 0 }); + } + assert.deepEqual(signals, []); + let live = true; + const identity = pid => live ? processIdentity(pid, { platform: 'linux', read: virtualLinux(bootA) }) : null; + assert.equal(await sameProcess(record, identity), true); + await stopRecorded([record], { identity, signal: (...args) => { signals.push(args); live = false; }, timeout: 0 }); + assert.deepEqual(signals, [[12345, 'SIGTERM']]); +}); +test('unavailable or malformed kernel identity and legacy Linux records fail closed without signals', async () => { + for (const boot of ['', 'not-a-boot-id']) await assert.rejects(processIdentity(12345, { platform: 'linux', read: virtualLinux(boot) }), /boot identity/); + await assert.rejects(processIdentity(12345, { platform: 'linux', read: async () => { throw Object.assign(new Error('Synthetic missing boot identity'), { code: 'ENOENT' }); } }), /missing boot identity/); + await assert.rejects(processIdentity(12345, { platform: 'linux', read: virtualLinux(bootA, 'invalid') }), /process identity/); + const signals = []; + await assert.rejects(stopRecorded([{ pid: 12345, identity: 'linux:42' }], { identity: async () => 'linux:42', signal: (...args) => signals.push(args), timeout: 0 }), /Legacy or invalid Linux process record/); + assert.deepEqual(signals, []); +}); test('descendant discovery refuses a root reused during the ps snapshot', async () => { let current = 'original'; From 3a45269a99c64021371718b3c38df27ac8187907 Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 17 Oct 2026 18:47:30 +0200 Subject: [PATCH 09/27] fix(dev): prove exact published loopback ports --- docs/development-stack.md | 2 +- scripts/dev-stack/acceptance.mjs | 10 ++--- scripts/dev-stack/resources.mjs | 48 +++++++++++++++++++++++ scripts/tests/dev-stack.test.mjs | 66 +++++++++++++++++++++++++++++++- 4 files changed, 119 insertions(+), 7 deletions(-) diff --git a/docs/development-stack.md b/docs/development-stack.md index bd6dde22e..8e67264cf 100644 --- a/docs/development-stack.md +++ b/docs/development-stack.md @@ -116,7 +116,7 @@ permissions, pins, environment isolation, redaction, PID reuse and failed comman These tests do not establish a working Docker stack or browser. `.github/workflows/dev-stack.yml` prepares only standard public `ubuntu-latest` -and `macos-15-intel` runners and checks the exact source. Both run all 29 launcher +and `macos-15-intel` runners and checks the exact source. Both run all 34 launcher regressions, `npm run check`, `npm run build` and `npm run hygiene:preflight`. Ubuntu also runs the full unmodified `npm test` suite. Native macOS runs all 13 private-instance API regressions in the four `instance-storage`/`dev-*-storage` diff --git a/scripts/dev-stack/acceptance.mjs b/scripts/dev-stack/acceptance.mjs index 447dd7d4a..440a1b8a6 100644 --- a/scripts/dev-stack/acceptance.mjs +++ b/scripts/dev-stack/acceptance.mjs @@ -9,7 +9,7 @@ import WebSocket from 'ws'; import { locations, loadOwner, readJson } from './state.mjs'; import { sameProcess, delay } from './processes.mjs'; import { browserProof, eventually } from './browser-proof.mjs'; -import { compose, run, dockerEnvironment } from './resources.mjs'; +import { assertLoopbackPublications, readOwnedPortPublications, run, dockerEnvironment } from './resources.mjs'; const root = resolve(fileURLToPath(new URL('../..', import.meta.url))), config = locations(root, process.env), owner = await loadOwner(config); const artifacts = process.env.SPACE_DEV_PROOF_DIR; @@ -57,10 +57,10 @@ try { if (terminalError) throw new Error('Actual CLI terminal WebSocket failed.'); return running && /opencode/i.test(stripVTControlCharacters(ui)); }, 'Real pinned OpenCode PTY terminal UI'); marker('source CLI host/PTY and actual OpenCode UI; no model prompt sent'); - const docker = (await compose(config, owner, ['ps', '--format', 'json'])).trim(); - const services = docker.startsWith('[') ? JSON.parse(docker) : docker.split('\n').filter(Boolean).map(line => JSON.parse(line)); - assert.equal(services.length, 2); - for (const service of services) for (const binding of service.Publishers || []) assert.equal(binding.URL, '127.0.0.1'); marker('Docker service ports bind only loopback'); + const services = await readOwnedPortPublications(config, owner); + // Preserve actual selected metadata before validation, including failures; no environment or command fields. + await writeFile(join(artifacts, 'docker-port-bindings.json'), JSON.stringify({ source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, services }, null, 2) + '\n', { mode: 0o600 }); + assertLoopbackPublications(services, owner.ports); marker('exact expected Docker service ports bind only loopback'); // Read-only host socket listings: assert all relevant Node listeners use loopback as well. const sockets = await run(process.platform === 'darwin' ? 'lsof' : 'ss', process.platform === 'darwin' ? ['-nP', '-iTCP', '-sTCP:LISTEN'] : ['-ltn'], { env: dockerEnvironment() }); for (const port of [owner.ports.api, owner.ports.web]) { diff --git a/scripts/dev-stack/resources.mjs b/scripts/dev-stack/resources.mjs index 2ece1c915..adcb815dd 100644 --- a/scripts/dev-stack/resources.mjs +++ b/scripts/dev-stack/resources.mjs @@ -43,6 +43,54 @@ export async function assertOwnedDocker(owner, invoke = run, env = dockerEnviron export async function compose(config, owner, args, options = {}) { return run('docker', composeArgs(config, owner, args), { env: dockerEnvironment(), ...options }); } +export async function readOwnedPortPublications(config, owner, { invoke = run, env = dockerEnvironment() } = {}) { + await assertOwnedDocker(owner, invoke, env); + const raw = (await invoke('docker', composeArgs(config, owner, ['ps', '--format', 'json']), { env })).trim(); + const services = raw.startsWith('[') ? JSON.parse(raw) : raw.split('\n').filter(Boolean).map(line => JSON.parse(line)); + const selected = []; + for (const service of services) { + if (!/^[a-f0-9]{12,64}$/.test(service.ID || '')) throw new Error('Invalid owned container ID in port proof.'); + // These fields contain port metadata only. Never return the Compose command or inspect Config.Env. + const ports = JSON.parse(await invoke('docker', ['container', 'inspect', '--format', '{{json .NetworkSettings.Ports}}', service.ID], { env })); + selected.push({ Service: service.Service, Publishers: Array.isArray(service.Publishers) ? service.Publishers.map(({ URL, TargetPort, PublishedPort, Protocol }) => ({ URL, TargetPort, PublishedPort, Protocol })) : null, NetworkSettingsPorts: ports }); + } + return selected; +} +export function assertLoopbackPublications(services, ports) { + const require = (condition, message) => { if (!condition) throw new Error(`Docker loopback proof: ${message}`); }; + const expected = { postgres: { target: 5432, published: ports.postgres }, temporal: { target: 7233, published: ports.temporal } }; + require(Array.isArray(services) && services.length === 2, 'expected exactly the two owned services.'); + const seen = new Set(); + for (const service of services) { + const wanted = expected[service.Service]; + require(wanted && !seen.has(service.Service), 'unexpected or duplicate service.'); seen.add(service.Service); + const check = (host, target, published, protocol) => { + require(host === '127.0.0.1', 'a published host address is not IPv4 loopback.'); + require(target === wanted.target && published === wanted.published && protocol === 'tcp', 'a missing or extra host mapping differs from the expected service port.'); + }; + require(Array.isArray(service.Publishers), 'missing Compose port metadata.'); + let composeCount = 0; + for (const binding of service.Publishers) { + require(Number.isInteger(binding.PublishedPort) && binding.PublishedPort >= 0 && binding.PublishedPort <= 65535 && Number.isInteger(binding.TargetPort) && binding.TargetPort > 0 && binding.TargetPort <= 65535 && ['tcp', 'udp'].includes(binding.Protocol), 'malformed Compose port metadata.'); + // Compose includes merely EXPOSED container ports here too. Only numeric zero is unpublished. + if (binding.PublishedPort === 0) { require(binding.URL === '', 'contradictory unpublished host address.'); continue; } + check(binding.URL, binding.TargetPort, binding.PublishedPort, binding.Protocol); composeCount++; + } + require(composeCount === 1, 'missing or duplicate expected Compose host mapping.'); + require(service.NetworkSettingsPorts && typeof service.NetworkSettingsPorts === 'object' && !Array.isArray(service.NetworkSettingsPorts), 'missing selected container port inspection.'); + let inspectedCount = 0; + for (const [key, bindings] of Object.entries(service.NetworkSettingsPorts)) { + const match = /^(\d+)\/(tcp|udp)$/.exec(key); require(match && +match[1] > 0 && +match[1] <= 65535, 'malformed inspected container port.'); + if (bindings === null) continue; // An exposed port without a host publication. + require(Array.isArray(bindings), 'malformed inspected bindings.'); + for (const binding of bindings) { + require(typeof binding.HostPort === 'string' && /^\d+$/.test(binding.HostPort), 'malformed inspected host port.'); + check(binding.HostIp, +match[1], +binding.HostPort, match[2]); inspectedCount++; + } + } + require(inspectedCount === 1, 'missing or duplicate expected inspected host mapping.'); + } +} export async function diagnoseOwnedDocker(config, owner, { output = () => {}, secrets = [], invoke = run, env = dockerEnvironment() } = {}) { const signal = AbortSignal.timeout(15000); const control = (command, args, options = {}) => invoke(command, args, { env, timeout: 5000, signal, ...options }); diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs index 1b78c7b47..40a844f47 100644 --- a/scripts/tests/dev-stack.test.mjs +++ b/scripts/tests/dev-stack.test.mjs @@ -7,7 +7,7 @@ import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import { composeDocument, extractPins, initialize, loadOwner, locations, ownerLabel, privateWrite, secrets, serviceEnvironment } from '../dev-stack/state.mjs'; import { descendants, LaunchBarrier, lineRedactor, processIdentity, sameProcess, stopRecorded } from '../dev-stack/processes.mjs'; -import { assertOwnedDocker, composeArgs, diagnoseOwnedDocker, dockerEnvironment, removeOwnedDocker, run } from '../dev-stack/resources.mjs'; +import { assertLoopbackPublications, assertOwnedDocker, composeArgs, diagnoseOwnedDocker, dockerEnvironment, readOwnedPortPublications, removeOwnedDocker, run } from '../dev-stack/resources.mjs'; const root = fileURLToPath(new URL('../..', import.meta.url)); const pins = extractPins(await readFile(join(root, 'packages/run-spaceapp/templates/compose.yml'), 'utf8'), await readFile(join(root, 'Dockerfile'), 'utf8')); @@ -33,6 +33,70 @@ test('ports are isolated, distinct and explicitly configurable', () => { for (const value of ['4910', '4911', '0', '99999', '4810suffix', '0.0.0.0']) assert.throws(() => locations(root, { SPACE_DEV_API_PORT: value })); assert.throws(() => locations(root, { SPACE_DEV_API_PORT: '4811' }), /distinct/); }); + +// Synthetic schema fixture derived from Compose v2.38.2 ps.go; not a captured live response. +const publishedPorts = { postgres: 54329, temporal: 47233 }; +function portFixture() { + return [ + { Service: 'postgres', Publishers: [{ URL: '127.0.0.1', TargetPort: 5432, PublishedPort: 54329, Protocol: 'tcp' }], NetworkSettingsPorts: { '5432/tcp': [{ HostIp: '127.0.0.1', HostPort: '54329' }] } }, + { Service: 'temporal', Publishers: [{ URL: '', TargetPort: 7234, PublishedPort: 0, Protocol: 'tcp' }, { URL: '127.0.0.1', TargetPort: 7233, PublishedPort: 47233, Protocol: 'tcp' }], NetworkSettingsPorts: { '7234/tcp': null, '7233/tcp': [{ HostIp: '127.0.0.1', HostPort: '47233' }] } } + ]; +} +test('port proof distinguishes exposed container ports from exact loopback host publications', () => { + const services = portFixture(); + // Preserve the observed old empty-URL assertion failure against the documented synthetic schema. + assert.throws(() => { for (const service of services) for (const binding of service.Publishers) assert.equal(binding.URL, '127.0.0.1'); }); + assert.doesNotThrow(() => assertLoopbackPublications(services, publishedPorts)); +}); +test('positive published ports with empty, wildcard or IPv6 host addresses always fail', () => { + for (const host of ['', '0.0.0.0', '::', '::1', '[::]', 'localhost']) { + const services = portFixture(); services[0].Publishers[0].URL = host; + assert.throws(() => assertLoopbackPublications(services, publishedPorts), /host address/); + } + const contradictory = portFixture(); contradictory[1].Publishers[0].URL = '0.0.0.0'; + assert.throws(() => assertLoopbackPublications(contradictory, publishedPorts), /unpublished/); +}); +test('missing, extra, mismatched, duplicated or malformed Compose publications fail', () => { + for (const mutate of [ + services => { services[0].Publishers = []; }, + services => { services[0].Publishers.push({ URL: '127.0.0.1', TargetPort: 5432, PublishedPort: 54330, Protocol: 'tcp' }); }, + services => { services[0].Publishers[0].TargetPort = 5433; }, + services => { services[0].Publishers[0].Protocol = 'udp'; }, + services => { services[0].Publishers.push({ ...services[0].Publishers[0] }); }, + services => { services[1].Publishers[0].PublishedPort = '0'; }, + services => { services[1].Service = 'postgres'; }, + services => { services.pop(); } + ]) { const services = portFixture(); mutate(services); assert.throws(() => assertLoopbackPublications(services, publishedPorts)); } +}); +test('selected engine inspection independently rejects missing, wildcard, extra or mismatched bindings', () => { + for (const mutate of [ + services => { services[0].NetworkSettingsPorts['5432/tcp'] = null; }, + services => { services[0].NetworkSettingsPorts['5432/tcp'][0].HostIp = ''; }, + services => { services[0].NetworkSettingsPorts['5432/tcp'][0].HostIp = '0.0.0.0'; }, + services => { services[0].NetworkSettingsPorts['5432/tcp'][0].HostIp = '::'; }, + services => { services[0].NetworkSettingsPorts['5432/tcp'][0].HostPort = '54330'; }, + services => { services[0].NetworkSettingsPorts['5433/tcp'] = [{ HostIp: '127.0.0.1', HostPort: '54329' }]; }, + services => { services[1].NetworkSettingsPorts['7234/tcp'] = [{ HostIp: '127.0.0.1', HostPort: '47234' }]; }, + services => { services[0].NetworkSettingsPorts['5432/tcp'].push({ HostIp: '127.0.0.1', HostPort: '54329' }); } + ]) { const services = portFixture(); mutate(services); assert.throws(() => assertLoopbackPublications(services, publishedPorts)); } +}); +test('port evidence reads only owned selected metadata and never returns Compose commands or environment', async () => { + const owner = { nonce: 'owned', project: 'owned-project' }, fixture = portFixture(), calls = []; + const ids = ['a'.repeat(64), 'b'.repeat(64)]; + const invoke = async (command, args) => { + calls.push([command, ...args]); + if (args.includes('ls')) return args[0] === 'container' ? ids.join('\n') : ''; + if (args.includes('{{json .Config.Labels}}')) return JSON.stringify({ [ownerLabel]: owner.nonce }); + if (args[0] === 'compose') return fixture.map((service, i) => JSON.stringify({ ...service, ID: ids[i], Command: 'must-not-be-retained', Env: ['must-not-be-retained'] })).join('\n'); + if (args.includes('{{json .NetworkSettings.Ports}}')) return JSON.stringify(fixture[ids.indexOf(args.at(-1))].NetworkSettingsPorts); + throw new Error('Unexpected port proof command'); + }; + const selected = await readOwnedPortPublications({ compose: 'private-compose.json' }, owner, { invoke, env: {} }); + assert.deepEqual(selected, fixture); assert.doesNotThrow(() => assertLoopbackPublications(selected, publishedPorts)); + assert.equal(JSON.stringify(selected).includes('must-not-be-retained'), false); + assert.equal(calls.some(call => call.includes('{{json .Config.Env}}')), false); + assert.equal(calls.filter(call => call.includes('{{json .NetworkSettings.Ports}}')).length, 2); +}); test('deletion targets are confined to named direct checkout children', () => { for (const value of ['..', '../data', '/', 'node_modules', '.git', '.dev-stack/../secrets', '/tmp/.dev-stack']) assert.throws(() => locations(root, { SPACE_DEV_STATE_DIR: value })); assert.equal(locations(root, { SPACE_DEV_STATE_DIR: '.dev-stack-isolated' }).state, join(resolve(root), '.dev-stack-isolated')); From e28ae75ec83551fb46328235bd41b7f5fb4ca216 Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 17 Oct 2026 19:51:00 +0200 Subject: [PATCH 10/27] Fix pinned CLI execution and prove live interactive terminal --- .github/workflows/dev-stack.yml | 3 + docs/development-stack.md | 22 ++++- scripts/dev-stack/acceptance.mjs | 59 +++++++++--- scripts/dev-stack/browser-proof.mjs | 21 ++++- scripts/dev-stack/capability-proof.mjs | 28 ++++++ scripts/dev-stack/opencode-runtime.mjs | 67 +++++++++++++ scripts/dev-stack/opencode-wrapper.mjs | 22 +++-- scripts/dev-stack/pty-capability.mjs | 86 +++++++++++++++++ scripts/dev-stack/resources.mjs | 12 ++- scripts/dev-stack/supervisor.mjs | 6 ++ scripts/dev-stack/terminal-proof.mjs | 52 +++++++++++ scripts/public-export.mjs | 4 + scripts/tests/dev-stack.test.mjs | 124 ++++++++++++++++++++++++- 13 files changed, 472 insertions(+), 34 deletions(-) create mode 100644 scripts/dev-stack/capability-proof.mjs create mode 100644 scripts/dev-stack/opencode-runtime.mjs create mode 100644 scripts/dev-stack/pty-capability.mjs create mode 100644 scripts/dev-stack/terminal-proof.mjs diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml index 9c7f1b307..25f38a688 100644 --- a/.github/workflows/dev-stack.yml +++ b/.github/workflows/dev-stack.yml @@ -108,6 +108,9 @@ jobs: - name: Install locked workspace dependencies run: npm ci --no-audit --no-fund + - name: Early actual pinned CLI and zero-provider PTY capability + run: node scripts/dev-stack/capability-proof.mjs + - name: Daemon-free isolation and cleanup regressions run: npm run test:dev-stack diff --git a/docs/development-stack.md b/docs/development-stack.md index 8e67264cf..3cab059b9 100644 --- a/docs/development-stack.md +++ b/docs/development-stack.md @@ -116,7 +116,7 @@ permissions, pins, environment isolation, redaction, PID reuse and failed comman These tests do not establish a working Docker stack or browser. `.github/workflows/dev-stack.yml` prepares only standard public `ubuntu-latest` -and `macos-15-intel` runners and checks the exact source. Both run all 34 launcher +and `macos-15-intel` runners and checks the exact source. Both run all 47 launcher regressions, `npm run check`, `npm run build` and `npm run hygiene:preflight`. Ubuntu also runs the full unmodified `npm test` suite. Native macOS runs all 13 private-instance API regressions in the four `instance-storage`/`dev-*-storage` @@ -128,11 +128,29 @@ remain unchanged. The macOS job uses an ephemeral named Colima profile, removed afterward. It does not use a larger runner, paid fallback, provider credentials or an operator profile. +Before the lengthy stock gates, a separate private-state capability step executes +the exact pinned OpenCode native optional-package binary with `--version` and a +real, zero-provider `printf` PTY. It never executes the npm postinstall or its +network fallback. The manifest versions, physical private path, executable and +actual version must match; a missing/mismatched binary fails with a bounded receipt. +On Darwin only, a real failed `posix_spawnp` probe plus the exact published +node-pty1.1.0 helper hash, mode0644, checkout confinement, ownership and single +link can authorize restoring that one helper's execute bits. The failure, modes, +unchanged bytes and successful second real probe are retained. Other failures +are not repaired or hidden; no installed/global dependency path is changed. + After those gates, `scripts/dev-stack/lifecycle-proof.mjs` starts a real stack with synthetic installed ports4910/4911 occupied by an independent process. It exercises HTTP owner claim and sign-in, invalidates the consumed token, creates the actual Getting Started/OpenCode pane, observes the real PTY UI, and renders that pane in -the runner’s existing headless Chrome. It changes a CSS canary and observes real +the runner’s existing headless Chrome. Its current matching API/WS session must +remain active/RUNNING; error, exit or unexpected close is fatal. Keyboard input +targets only that pane's attached error-free terminal: Ctrl+P opens the pinned +command menu, a unique local filter produces the matching empty-result state, +Escape dismisses it, and Ctrl+P opens a fresh menu again. Only new output after +each input counts. No Enter, provider login or model prompt is sent. The private +provider HOME/XDG roots do not replace the pane's requested working directory. +It changes a CSS canary and observes real HMR without a navigation, then changes API/shared-package source and observes rebuilt output plus new API/worker processes and restored readiness. All changed source bytes are restored. diff --git a/scripts/dev-stack/acceptance.mjs b/scripts/dev-stack/acceptance.mjs index 440a1b8a6..9bb408b0f 100644 --- a/scripts/dev-stack/acceptance.mjs +++ b/scripts/dev-stack/acceptance.mjs @@ -4,12 +4,14 @@ import { randomBytes, randomUUID } from 'node:crypto'; import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; import { isAbsolute, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { stripVTControlCharacters } from 'node:util'; import WebSocket from 'ws'; import { locations, loadOwner, readJson } from './state.mjs'; import { sameProcess, delay } from './processes.mjs'; import { browserProof, eventually } from './browser-proof.mjs'; import { assertLoopbackPublications, readOwnedPortPublications, run, dockerEnvironment } from './resources.mjs'; +import { InteractiveTerminalProof, assertBrowserTerminal } from './terminal-proof.mjs'; +import { extractPins } from './state.mjs'; +import { verifyOpenCode } from './opencode-runtime.mjs'; const root = resolve(fileURLToPath(new URL('../..', import.meta.url))), config = locations(root, process.env), owner = await loadOwner(config); const artifacts = process.env.SPACE_DEV_PROOF_DIR; @@ -18,7 +20,7 @@ const proofRelative = relative(root, resolve(artifacts)); if (!proofRelative || (!proofRelative.startsWith('..') && !isAbsolute(proofRelative))) throw new Error('Proof output must be outside the checkout/state.'); await mkdir(artifacts, { recursive: true }); const origin = `http://127.0.0.1:${owner.ports.web}`, api = `http://127.0.0.1:${owner.ports.api}`; -let cookie = '', csrf = null, browser, terminal; +let cookie = '', csrf = null, browser, terminal, interactive, password, token, ticketToken, native; const checks = []; async function request(path, body, expected = 200) { const response = await fetch(`${api}${path}`, { method: body === undefined ? 'GET' : 'POST', headers: { ...(cookie ? { cookie } : {}), ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...(csrf ? { [csrf.headerName]: csrf.csrfToken } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000) }); @@ -33,8 +35,8 @@ try { assert.equal((await (await fetch(`${origin}/readyz`)).json()).ok, true); marker('Vite forwards to the isolated API port'); for (const name of ['postgres-password', 'session-secret', 'setup-token']) assert.equal((await stat(join(config.state, 'secrets', name))).mode & 0o777, 0o600); assert.equal((await stat(config.compose)).mode & 0o777, 0o600); marker('all generated secret/config files0600'); - const token = await readFile(join(config.state, 'secrets/setup-token'), 'utf8'); - const password = randomBytes(24).toString('hex'), email = 'dev-stack-owner@example.invalid'; + token = await readFile(join(config.state, 'secrets/setup-token'), 'utf8'); + password = randomBytes(24).toString('hex'); const email = 'dev-stack-owner@example.invalid'; const claimed = await request('/api/setup/claim', { token, email, password }); assert.equal(claimed.isAuthenticated, true); await request('/api/setup/claim', { token, email, password }, 409); marker('normal one-use owner claim'); await request('/api/auth/login', { email, password }); csrf = await request('/api/auth/csrf'); marker('normal owner sign-in'); @@ -45,18 +47,44 @@ try { if (!pane) pane = await request('/api/panes', { roomId: starter.room.id, title: 'OpenCode CLI', mode: 'TERMINAL', terminalRuntimeId: 'cli:opencode', cwd: join(config.state, 'workspaces') }); const started = await request(`/api/panes/${encodeURIComponent(pane.id)}/cli/session`, { runtimeId: 'cli:opencode' }); const ticket = started.websocket; assert(ticket?.token && ticket.sessionId); + ticketToken = ticket.token; + const pins = extractPins(await readFile(join(root, 'packages/run-spaceapp/templates/compose.yml'), 'utf8'), await readFile(join(root, 'Dockerfile'), 'utf8')); + const selected = await verifyOpenCode(config.state, pins.opencode); + native = { version: selected.version, package: selected.package, relativeExecutable: selected.relativeExecutable, sha256: selected.sha256 }; const url = new URL(`${api.replace('http:', 'ws:')}/api/panes/${encodeURIComponent(pane.id)}/cli/terminal`); for (const [k, v] of Object.entries({ sessionId: ticket.sessionId, token: ticket.token, clientId: randomUUID(), initialCols: '120', initialRows: '32' })) url.searchParams.set(k, v); terminal = new WebSocket(url, { headers: { cookie, origin } }); - let ui = '', running = false, terminalError; - terminal.on('error', error => { terminalError = error; }); - terminal.on('message', chunk => { const event = JSON.parse(chunk.toString()); if (event.type === 'output') ui += event.data; if (event.type === 'status' && event.status === 'RUNNING') running = true; }); + interactive = new InteractiveTerminalProof(ticket.sessionId); + terminal.on('open', () => interactive.open()); + terminal.on('error', error => interactive.error(error.code)); + terminal.on('close', code => interactive.close(code)); + terminal.on('message', chunk => { try { interactive.message(JSON.parse(chunk.toString())); } catch { interactive.fail('Malformed terminal WebSocket message.'); } }); + const currentSession = () => request(`/api/panes/${encodeURIComponent(pane.id)}/cli/session`); await eventually(async () => { - const current = await request(`/api/panes/${encodeURIComponent(pane.id)}/cli/session`); - running ||= current?.session?.status === 'RUNNING'; - if (terminalError) throw new Error('Actual CLI terminal WebSocket failed.'); - return running && /opencode/i.test(stripVTControlCharacters(ui)); - }, 'Real pinned OpenCode PTY terminal UI'); marker('source CLI host/PTY and actual OpenCode UI; no model prompt sent'); + interactive.assertHealthy(); + return interactive.live(await currentSession()) && interactive.sequence > 0; + }, 'Current matching OpenCode API/WS session'); + browser = await browserProof({ state: config.state, origin, cookie, artifacts, paneId: pane.id }); + // Exercise real terminal keyboard input through the actual matching browser pane. + // No Enter or provider/model command is sent: only a local palette and filter. + const deadline = Date.now() + 120000; + const waitInteractive = async (predicate, label) => eventually(async () => { interactive.assertHealthy(); return interactive.live(await currentSession()) && predicate(); }, label, Math.max(1, deadline - Date.now())); + let barrier = interactive.barrier(); await browser.key('Ctrl+P'); + await waitInteractive(() => interactive.menu(barrier), 'Fresh pinned OpenCode command menu after Ctrl+P'); + await browser.screenshot('opencode-command-menu.png'); + const challenge = `spaceproof${randomBytes(8).toString('hex')}`; + barrier = interactive.barrier(); await browser.key('text', challenge); + await waitInteractive(() => interactive.challenge(barrier, challenge), 'Fresh unique palette filter response'); + await browser.screenshot('opencode-filter-response.png'); + barrier = interactive.barrier(); await browser.key('Escape'); + await waitInteractive(() => interactive.sequence > barrier, 'Fresh terminal response to palette dismissal'); + barrier = interactive.barrier(); await browser.key('Ctrl+P'); + await waitInteractive(() => interactive.menu(barrier), 'Fresh command menu after dismissal and reopening'); + const browserState = await browser.terminalState(); assertBrowserTerminal(browserState, pane.id); + await browser.screenshot('opencode-pane.png'); + interactive.seal(await currentSession()); + await writeFile(join(artifacts, 'opencode-interactive-proof.json'), JSON.stringify({ source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, native, browser: browserState, terminal: interactive.diagnostic([cookie, ticket.token, password, token]), challenge, transitions: ['Ctrl+P -> fresh Commands and registered menu entries', 'unique palette filter -> fresh matching response', 'Escape -> fresh response', 'Ctrl+P -> fresh full command menu'], modelInference: false }, null, 2) + '\n', { mode: 0o600 }); + terminal.close(); marker('actual pinned OpenCode command menu, reversible filter, current API/WS and matching live browser pane; no model prompt sent'); const services = await readOwnedPortPublications(config, owner); // Preserve actual selected metadata before validation, including failures; no environment or command fields. await writeFile(join(artifacts, 'docker-port-bindings.json'), JSON.stringify({ source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, services }, null, 2) + '\n', { mode: 0o600 }); @@ -66,7 +94,7 @@ try { for (const port of [owner.ports.api, owner.ports.web]) { const lines = sockets.split('\n').filter(x => new RegExp(`:${port}(?:\\s|$)`).test(x)); assert(lines.length > 0); assert(lines.every(x => x.includes(`127.0.0.1:${port}`))); } marker('API and Vite bind only loopback'); - browser = await browserProof({ state: config.state, origin, cookie, artifacts }); marker('actual browser Getting Started/terminal rendering'); + marker('actual browser Getting Started and tested live terminal rendering'); const css = join(root, 'apps/web/src/styles.css'), originalCss = await readFile(css), canary = `proof-${randomUUID()}`; const timeOrigin = await browser.evaluate('performance.timeOrigin'); try { @@ -92,4 +120,9 @@ try { assert((await readFile(join(root, 'packages/contracts/dist/index.js'), 'utf8')).includes(canary), 'Changed shared package source must actually reach built output'); } finally { await writeFile(packageFile, originalPackage); } await writeFile(join(artifacts, 'acceptance.json'), JSON.stringify({ source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, checks, model_inference: false, real_operator_data: false }, null, 2) + '\n'); +} catch (error) { + // Selected synthetic state only. Never serialize a session response, ticket, + // cookie, websocket URL, environment or real provider/owner credentials. + if (interactive) await writeFile(join(artifacts, 'opencode-failure.json'), JSON.stringify({ source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, native, terminal: interactive.diagnostic([cookie, cookie.split('=').slice(1).join('='), password, token, ticketToken, csrf?.csrfToken]), browser: await browser?.terminalState().catch(() => null) }, null, 2) + '\n', { mode: 0o600 }); + throw error; } finally { terminal?.close(); await browser?.close(); } diff --git a/scripts/dev-stack/browser-proof.mjs b/scripts/dev-stack/browser-proof.mjs index 861443d9c..755e9c469 100644 --- a/scripts/dev-stack/browser-proof.mjs +++ b/scripts/dev-stack/browser-proof.mjs @@ -5,13 +5,14 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import WebSocket from 'ws'; import { delay, descendants, processIdentity, sameProcess, stopRecorded } from './processes.mjs'; +import { assertBrowserTerminal } from './terminal-proof.mjs'; export async function eventually(fn, label, timeout = 120000) { const until = Date.now() + timeout; let last; - while (Date.now() < until) { try { const value = await fn(); if (value) return value; } catch (error) { last = error; } await delay(400); } + while (Date.now() < until) { try { const value = await fn(); if (value) return value; } catch (error) { if (error.fatal) throw error; last = error; } await delay(400); } throw new Error(`${label} did not succeed${last ? ` (${last.message})` : ''}.`); } -export async function browserProof({ state, origin, cookie, artifacts }) { +export async function browserProof({ state, origin, cookie, artifacts, paneId }) { const binary = process.env.SPACE_DEV_BROWSER || (process.platform === 'darwin' ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : '/usr/bin/google-chrome'); if (!existsSync(binary)) throw new Error('Hosted Chrome is missing. Set SPACE_DEV_BROWSER to an existing reviewed browser; no hidden download/fallback.'); const profile = join(state, 'browser-proof'); await mkdir(profile, { mode: 0o700 }); @@ -45,9 +46,19 @@ export async function browserProof({ state, origin, cookie, artifacts }) { await call('Page.navigate', { url: `${origin}/` }); await eventually(() => evaluate('document.body.innerText.toLowerCase().includes("getting started")'), 'Actual Getting Started web room'); await evaluate(`(() => { const room = [...document.querySelectorAll('button,[role="button"]')].find(x => /getting started/i.test(x.textContent || x.getAttribute('title') || '')); room?.click(); return Boolean(room); })()`); - await eventually(() => evaluate('Boolean(document.querySelector(".xterm-screen")?.getBoundingClientRect().width)'), 'Rendered terminal pane in the actual browser'); + const paneExpression = `document.querySelector('[data-space-pane-id="' + CSS.escape(${JSON.stringify(paneId)}) + '"]')`; + const terminalState = () => evaluate(`(() => { const pane = ${paneExpression}, terminal = pane?.querySelector('.terminal-pane'); if (!terminal) return null; return { paneId: pane.dataset.spacePaneId, status: terminal.dataset.terminalStatus, hasSession: terminal.dataset.terminalHasSession, hasWebsocket: terminal.dataset.terminalHasWebsocket, hasError: terminal.dataset.terminalHasError, width: terminal.querySelector('.xterm-screen')?.getBoundingClientRect().width || 0 }; })()`); + await eventually(async () => { const current = await terminalState(); assertBrowserTerminal(current, paneId); return current; }, 'Matching attached error-free terminal in the actual browser'); await mkdir(artifacts, { recursive: true }); - await writeFile(join(artifacts, 'opencode-pane.png'), Buffer.from((await call('Page.captureScreenshot', { format: 'png' })).data, 'base64')); - return { evaluate, async screenshot(name) { await writeFile(join(artifacts, name), Buffer.from((await call('Page.captureScreenshot', { format: 'png' })).data, 'base64')); }, close }; + const key = async (name, text) => { + assertBrowserTerminal(await terminalState(), paneId); + assert.equal(await evaluate(`(() => { const input = ${paneExpression}?.querySelector('.xterm-helper-textarea'); input?.focus(); return document.activeElement === input; })()`), true, 'Keyboard must target the tested terminal.'); + if (text) await call('Input.dispatchKeyEvent', { type: 'char', text }); + else { + const params = name === 'Ctrl+P' ? { key: 'p', code: 'KeyP', windowsVirtualKeyCode: 80, modifiers: 2 } : { key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }; + await call('Input.dispatchKeyEvent', { type: 'keyDown', ...params }); await call('Input.dispatchKeyEvent', { type: 'keyUp', ...params }); + } + }; + return { evaluate, terminalState, key, async screenshot(name) { assertBrowserTerminal(await terminalState(), paneId); await writeFile(join(artifacts, name), Buffer.from((await call('Page.captureScreenshot', { format: 'png' })).data, 'base64')); }, close }; } catch (error) { await close(); throw error; } } diff --git a/scripts/dev-stack/capability-proof.mjs b/scripts/dev-stack/capability-proof.mjs new file mode 100644 index 000000000..c848e3d97 --- /dev/null +++ b/scripts/dev-stack/capability-proof.mjs @@ -0,0 +1,28 @@ +// Fail early on real native dependencies before the lengthy stock gates. This +// isolated temporary state is never an operator profile or a provider login. +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { extractPins, initialize, loadOwner, locations, secrets, serviceEnvironment } from './state.mjs'; +import { verifyOpenCode } from './opencode-runtime.mjs'; +import { verifyPtyCapability } from './pty-capability.mjs'; +import { run } from './resources.mjs'; + +const root = resolve(fileURLToPath(new URL('../..', import.meta.url))), artifacts = resolve(process.env.SPACE_DEV_PROOF_DIR || ''); +const rel = relative(root, artifacts); +assert(process.env.SPACE_DEV_PROOF_DIR && rel && (rel.startsWith('..') || isAbsolute(rel)), 'Private proof directory must be outside the checkout.'); +await mkdir(artifacts, { recursive: true, mode: 0o700 }); +const config = locations(root, { SPACE_DEV_STATE_DIR: `.dev-stack-capability-${randomUUID()}` }); +const owner = await initialize(config), env = serviceEnvironment(config, owner, await secrets(config), process.env); +const proof = { source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, stage: 'before-pty', ok: false, providerCredentials: false, modelInference: false }; +try { + await verifyPtyCapability(root, config.state, { inherited: env, evidence: join(artifacts, 'early-pty-capability.json') }); + const pins = extractPins(await readFile(join(root, 'packages/run-spaceapp/templates/compose.yml'), 'utf8'), await readFile(join(root, 'Dockerfile'), 'utf8')); + proof.expectedVersion = pins.opencode; proof.stage = 'install-private-pinned-package'; + await run('npm', ['install', '--prefix', join(config.state, 'cli/vendor/opencode'), '--ignore-scripts', '--no-audit', '--no-fund', `opencode-ai@${pins.opencode}`], { cwd: root, env, output: text => process.stdout.write(text) }); + await verifyOpenCode(config.state, pins.opencode, { inherited: env, observe: async value => Object.assign(proof, value) }); + console.log('[dev-stack-capability] PASS actual pinned native --version and real zero-provider printf PTY'); +} catch (error) { proof.error ??= { name: error.name, code: error.code ?? null, signal: error.signal ?? null }; throw error; +} finally { await writeFile(join(artifacts, 'early-opencode-capability.json'), JSON.stringify(proof, null, 2) + '\n', { mode: 0o600 }); await loadOwner(config); await rm(config.state, { recursive: true }); } diff --git a/scripts/dev-stack/opencode-runtime.mjs b/scripts/dev-stack/opencode-runtime.mjs new file mode 100644 index 000000000..83fc7b640 --- /dev/null +++ b/scripts/dev-stack/opencode-runtime.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { access, lstat, mkdir, readFile, realpath } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +const execute = promisify(execFile); +const invokeVersion = async (file, args, options) => (await execute(file, args, { ...options, encoding: 'utf8', maxBuffer: 4096, killSignal: 'SIGKILL' })).stdout; + +// The pinned npm package's public bin is a postinstall placeholder. We never run +// its postinstall/network fallback: select the already-installed native package. +export function nativePackage(platform = process.platform, arch = process.arch) { + if (!['linux', 'darwin'].includes(platform) || !['x64', 'arm64'].includes(arch)) throw new Error('Unsupported pinned OpenCode platform.'); + return `opencode-${platform}-${arch}${arch === 'x64' ? '-baseline' : ''}`; +} +export async function confinedFile(root, file) { + const absolute = resolve(root), target = resolve(file), rel = relative(absolute, target); + if (!rel || isAbsolute(rel) || rel.startsWith('..') || await realpath(absolute) !== absolute) throw new Error('Native path must stay inside its physical private root.'); + let current = absolute; + for (const part of rel.split(/[\\/]/)) { + current = join(current, part); + const info = await lstat(current); + if (info.isSymbolicLink() || await realpath(current) !== current) throw new Error('Refusing a linked native package path.'); + if (current === target ? !info.isFile() : !info.isDirectory()) throw new Error('Invalid native package path type.'); + } + return target; +} +export async function fileHash(file) { + const hash = createHash('sha256'); + for await (const data of createReadStream(file)) hash.update(data); + return hash.digest('hex'); +} +export function opencodeEnvironment(state, inherited = {}) { + const home = join(state, 'providers', 'opencode'); + return { PATH: inherited.PATH || '/usr/local/bin:/usr/bin:/bin', TERM: 'xterm-256color', LANG: 'en_US.UTF-8', HOME: home, TMPDIR: join(home, 'tmp'), XDG_CONFIG_HOME: join(home, 'config'), XDG_DATA_HOME: join(home, 'data'), XDG_CACHE_HOME: join(home, 'cache'), XDG_STATE_HOME: join(home, 'state'), OPENCODE_DISABLE_AUTOUPDATE: 'true', OPENCODE_DISABLE_SHARE: 'true' }; +} +export async function resolveOpenCode(state, version, { platform = process.platform, arch = process.arch } = {}) { + assert.match(version, /^\d+\.\d+\.\d+$/); + const modules = join(state, 'cli', 'vendor', 'opencode', 'node_modules'); + const coreFile = await confinedFile(state, join(modules, 'opencode-ai', 'package.json')); + const core = JSON.parse(await readFile(coreFile, 'utf8')), name = nativePackage(platform, arch); + if (core.name !== 'opencode-ai' || core.version !== version || core.optionalDependencies?.[name] !== version) throw new Error('Pinned OpenCode core/native dependency mismatch.'); + const packageFile = await confinedFile(state, join(modules, name, 'package.json')); + const selected = JSON.parse(await readFile(packageFile, 'utf8')); + if (selected.name !== name || selected.version !== version) throw new Error('Pinned OpenCode native package mismatch.'); + const executable = await confinedFile(state, join(dirname(packageFile), 'bin', 'opencode')); + await access(executable, constants.X_OK); + return { executable, version, package: name, relativeExecutable: relative(state, executable), sha256: await fileHash(executable) }; +} +export async function verifyOpenCode(state, version, { invoke = invokeVersion, inherited, observe = async () => {}, ...selection } = {}) { + const observation = { expectedVersion: version, actualVersion: null, stage: 'resolve-native', ok: false }; + try { + const native = await resolveOpenCode(state, version, selection), env = opencodeEnvironment(state, inherited); + observation.native = { version: native.version, package: native.package, relativeExecutable: native.relativeExecutable, sha256: native.sha256 }; + for (const path of [env.HOME, env.TMPDIR, env.XDG_CONFIG_HOME, env.XDG_DATA_HOME, env.XDG_CACHE_HOME, env.XDG_STATE_HOME]) await mkdir(path, { recursive: true, mode: 0o700 }); + observation.stage = 'actual-native-version'; + const actual = (await invoke(native.executable, ['--version'], { cwd: env.HOME, env, timeout: 15000 })).trim(); + observation.actualVersion = /^\d+\.\d+\.\d+$/.test(actual) ? actual : '[unexpected version output]'; + if (actual !== version) throw Object.assign(new Error('Actual private OpenCode executable version differs from its canonical pin.'), { code: 'VERSION_MISMATCH' }); + observation.ok = true; return native; + } catch (error) { + observation.error = { name: error.name, code: error.code ?? null, signal: error.signal ?? null, killed: error.killed === true }; throw error; + } finally { await observe(observation); } +} diff --git a/scripts/dev-stack/opencode-wrapper.mjs b/scripts/dev-stack/opencode-wrapper.mjs index 60f8a2325..adcc9225f 100644 --- a/scripts/dev-stack/opencode-wrapper.mjs +++ b/scripts/dev-stack/opencode-wrapper.mjs @@ -1,26 +1,28 @@ #!/usr/bin/env node -import { createHash } from 'node:crypto'; import { spawn } from 'node:child_process'; -import { mkdir, readFile } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { confinedFile, opencodeEnvironment, verifyOpenCode } from './opencode-runtime.mjs'; // Copied verbatim by our orchestrator into its private cli/bin, never globally. const state = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); -const provider = join(state, 'providers', 'opencode'); -const executable = join(state, 'cli', 'vendor', 'opencode', 'node_modules', 'opencode-ai', 'bin', 'opencode'); +const version = (await readFile(await confinedFile(state, join(state, 'cli', 'opencode-version')), 'utf8')).trim(); const args = process.argv.slice(2); if (args[0] === '--run-inner') args.shift(); -if (args[0] === 'credential-status') { await readFile(executable); console.log('READY'); } -else if (args[0] === 'credential-observation') { console.log(`OBSERVATION:${createHash('sha256').update(await readFile(executable)).digest('hex')}`); } -else if (['credential-smoke', 'login', 'auth', 'run', 'credential-install'].includes(args[0])) { console.error('This contributor wrapper opens the UI only; provider authentication/inference is disabled.'); process.exitCode = 64; } +if (['credential-smoke', 'login', 'auth', 'run', 'credential-install'].includes(args[0])) { console.error('This contributor wrapper opens the UI only; provider authentication/inference is disabled.'); process.exitCode = 64; } else { - for (const folder of ['', 'tmp', 'config', 'data', 'cache', 'state']) await mkdir(join(provider, folder), { recursive: true, mode: 0o700 }); - const child = spawn(process.execPath, [executable, ...args], { + const native = await verifyOpenCode(state, version, { inherited: process.env }); + if (args[0] === 'credential-status') { console.log('READY'); } + else if (args[0] === 'credential-observation') { console.log(`OBSERVATION:${native.sha256}`); } + else { + const env = opencodeEnvironment(state, process.env); + const child = spawn(native.executable, args, { stdio: 'inherit', - env: { PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin', TERM: 'xterm-256color', LANG: 'en_US.UTF-8', HOME: provider, TMPDIR: join(provider, 'tmp'), XDG_CONFIG_HOME: join(provider, 'config'), XDG_DATA_HOME: join(provider, 'data'), XDG_CACHE_HOME: join(provider, 'cache'), XDG_STATE_HOME: join(provider, 'state'), OPENCODE_DISABLE_AUTOUPDATE: 'true', OPENCODE_DISABLE_SHARE: 'true' } + env }); child.once('error', () => { console.error('Pinned state-local OpenCode could not start.'); process.exitCode = 1; }); child.once('exit', (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exitCode = code ?? 1; }); for (const signal of ['SIGTERM', 'SIGINT']) process.once(signal, () => child.kill(signal)); + } } diff --git a/scripts/dev-stack/pty-capability.mjs b/scripts/dev-stack/pty-capability.mjs new file mode 100644 index 000000000..d740991b4 --- /dev/null +++ b/scripts/dev-stack/pty-capability.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { constants } from 'node:fs'; +import { lstat, mkdir, open, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { confinedFile, fileHash } from './opencode-runtime.mjs'; +import { run } from './resources.mjs'; + +const self = fileURLToPath(import.meta.url), marker = 'SPACE_DEV_ZERO_PROVIDER_PTY_OK'; +const helperHashes = { + x64: '46a4455777c6122d7e71cd706ea4b37b19bedd297684be8b6dabb833ff2a3965', + arm64: '21c589109bca43e287df884f3c34ab888033a83927ea7d273949ac5030583f26' +}; +export function knownHelperRepair({ platform, arch, version, sha256, mode, uid, nlink }, ownerUid = process.getuid?.()) { + return platform === 'darwin' && version === '1.1.0' && helperHashes[arch] === sha256 && mode === 0o644 && Number.isInteger(ownerUid) && uid === ownerUid && nlink === 1; +} +async function helperInfo(root) { + const packageFile = await confinedFile(root, join(root, 'node_modules/node-pty/package.json')); + const pkg = JSON.parse(await readFile(packageFile, 'utf8')); + assert.equal(pkg.name, 'node-pty'); + if (process.platform !== 'darwin') return { version: pkg.version, platform: process.platform, arch: process.arch }; + // Resolve exactly as node-pty's UnixTerminal does, including a possible local build. + const require = createRequire(packageFile), utils = require('./lib/utils.js'); + const native = utils.loadNativeModule('pty'); + const file = await confinedFile(root, resolve(dirname(packageFile), 'lib', native.dir, 'spawn-helper')); + const info = await lstat(file); + return { version: pkg.version, platform: process.platform, arch: process.arch, file, relativeHelper: relative(root, file), sha256: await fileHash(file), mode: info.mode & 0o777, uid: info.uid, nlink: info.nlink }; +} +export async function verifyPtyCapability(root, state, { inherited = {}, evidence = join(state, 'logs', 'pty-capability.json'), invoke = run } = {}) { + let helper; + try { helper = await helperInfo(root); } + catch (error) { + await mkdir(dirname(evidence), { recursive: true, mode: 0o700 }); + await writeFile(evidence, JSON.stringify({ capability: 'real zero-provider printf PTY', ok: false, stage: 'inspect-confined-helper', platform: process.platform, arch: process.arch, error: { name: error.name, code: error.code ?? null } }, null, 2) + '\n', { mode: 0o600 }); throw error; + } + const { file, ...selected } = helper; + const proof = { capability: 'real zero-provider printf PTY', helper: selected, attempts: [], repair: null, ok: false }; + const home = join(state, 'pty-capability'); await mkdir(home, { recursive: true, mode: 0o700 }); + const probe = async () => { + let output = ''; + try { + const text = await invoke(process.execPath, [self, '--probe', root, home], { cwd: root, env: { PATH: inherited.PATH || '/usr/local/bin:/usr/bin:/bin', HOME: home, TMPDIR: home, LANG: 'en_US.UTF-8', TERM: 'xterm-256color' }, timeout: 15000, output: chunk => { output = (output + chunk).slice(-4096); } }); + const result = JSON.parse(text.trim()); + assert.equal(result.capability, 'node-pty-printf'); assert.equal(result.ok, true); + proof.attempts.push({ ok: true, result }); return true; + } catch { proof.attempts.push({ ok: false, diagnostic: output.slice(-4096) }); return false; } + }; + try { + if (!await probe()) { + // Only the exact published 1.1.0 helper-mode defect is eligible. The actual + // failed spawn is retained first; an unrelated failure never triggers chmod. + if (!knownHelperRepair(helper) || !/posix_spawnp failed/.test(proof.attempts[0].diagnostic)) throw new Error('Real printf PTY capability failed; selected helper metadata and typed error retained.'); + await confinedFile(root, file); + const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const info = await handle.stat(); + const { createHash } = await import('node:crypto'); + const sha256 = createHash('sha256').update(await handle.readFile()).digest('hex'); + if (!knownHelperRepair({ ...helper, sha256, mode: info.mode & 0o777, uid: info.uid, nlink: info.nlink })) throw new Error('PTY helper changed; refusing repair.'); + await confinedFile(root, file); await handle.chmod(0o755); + } finally { await handle.close(); } + proof.repair = { reason: 'Actual posix_spawnp failure and exact pinned private helper0644', before: helper.mode, after: (await lstat(file)).mode & 0o777, bytesUnchanged: await fileHash(file) === helper.sha256 }; + assert.equal(proof.repair.bytesUnchanged, true); + if (!await probe()) throw new Error('Real printf PTY still fails after the bounded helper permission correction.'); + } + proof.ok = true; return proof; + } finally { await mkdir(dirname(evidence), { recursive: true, mode: 0o700 }); await writeFile(evidence, JSON.stringify(proof, null, 2) + '\n', { mode: 0o600 }); } +} + +if (process.argv[1] && resolve(process.argv[1]) === self && process.argv[2] === '--probe') { + const root = resolve(process.argv[3]), home = resolve(process.argv[4]); + try { + const require = createRequire(join(root, 'package.json')), pty = require('node-pty'); + const term = pty.spawn('/bin/sh', ['-c', `printf '${marker}'`], { name: 'xterm-256color', cols: 80, rows: 24, cwd: home, env: { PATH: process.env.PATH, HOME: home, TMPDIR: home, TERM: 'xterm-256color' } }); + let output = ''; + await new Promise((done, reject) => { + const timer = setTimeout(() => { term.kill(); reject(new Error('Synthetic PTY timed out.')); }, 10000); + term.onData(data => { output += data; }); + term.onExit(({ exitCode }) => { clearTimeout(timer); if (exitCode === 0 && output === marker) done(); else reject(new Error('Synthetic PTY output/exit mismatch.')); }); + }); + console.log(JSON.stringify({ capability: 'node-pty-printf', ok: true, exitCode: 0, exactOutput: true, modelInference: false })); + } catch (error) { + console.log(JSON.stringify({ capability: 'node-pty-printf', ok: false, error: { name: error.name, message: error.message.slice(0, 500) } })); process.exitCode = 1; + } +} diff --git a/scripts/dev-stack/resources.mjs b/scripts/dev-stack/resources.mjs index adcb815dd..16fd42805 100644 --- a/scripts/dev-stack/resources.mjs +++ b/scripts/dev-stack/resources.mjs @@ -97,13 +97,21 @@ export async function diagnoseOwnedDocker(config, owner, { output = () => {}, se await assertOwnedDocker(owner, control, env); const redactor = lineRedactor(secrets, output); try { - output('[dev-stack] Bounded owned-service logs and health probes before cleanup:\n'); - await control('docker', composeArgs(config, owner, ['logs', '--no-color', '--tail', '100', 'postgres', 'temporal']), { secrets, output: chunk => redactor.chunk(chunk) }); + output(`[dev-stack] Bounded owned-service logs and health probes before cleanup (${new Date().toISOString()}):\n`); const ids = (await control('docker', ['container', 'ls', '-a', '-q', '--filter', `label=com.docker.compose.project=${owner.project}`])).trim().split(/\s+/).filter(Boolean); for (const id of ids) { + // A field allowlist, never full State/inspect or Config.Env/command. + const format = '{"status":{{json .State.Status}},"running":{{json .State.Running}},"exitCode":{{json .State.ExitCode}},"oomKilled":{{json .State.OOMKilled}},"startedAt":{{json .State.StartedAt}},"finishedAt":{{json .State.FinishedAt}},"error":{{json .State.Error}},"restartCount":{{json .RestartCount}},"ports":{{json .NetworkSettings.Ports}}}'; + await control('docker', ['container', 'inspect', '--format', format, id], { secrets, output: chunk => redactor.chunk(chunk) }); // Health has status/probe output only. Never inspect Config.Env or the whole container. await control('docker', ['container', 'inspect', '--format', '{{json .State.Health}}', id], { secrets, output: chunk => redactor.chunk(chunk) }); } + try { + const text = await control(process.platform === 'darwin' ? 'lsof' : 'ss', process.platform === 'darwin' ? ['-nP', `-iTCP:${owner.ports.postgres}`, `-iTCP:${owner.ports.temporal}`, '-sTCP:LISTEN'] : ['-ltn']); + const lines = text.split('\n').filter(line => [owner.ports.postgres, owner.ports.temporal].some(port => new RegExp(`:${port}(?:\\s|$)`).test(line))).map(line => line.slice(0, 400)); + redactor.chunk(JSON.stringify({ selectedHostPortListeners: lines }) + '\n'); + } catch { output('[dev-stack] Selected host-port listener inspection unavailable or no listener; container metadata retained.\n'); } + await control('docker', composeArgs(config, owner, ['logs', '--no-color', '--tail', '100', 'postgres', 'temporal']), { secrets, output: chunk => redactor.chunk(chunk) }); } finally { redactor.end(); } } export async function removeOwnedDocker(config, owner, { volumes = false, output, secrets, diagnose = false, invoke = run, env = dockerEnvironment() } = {}) { diff --git a/scripts/dev-stack/supervisor.mjs b/scripts/dev-stack/supervisor.mjs index 3f0e62ed9..c1fea8727 100644 --- a/scripts/dev-stack/supervisor.mjs +++ b/scripts/dev-stack/supervisor.mjs @@ -7,6 +7,8 @@ import { fileURLToPath } from 'node:url'; import { assertPlainState, composeDocument, extractPins, initialize, loadOwner, locations, privateWrite, readJson, secrets, serviceEnvironment } from './state.mjs'; import { delay, descendants, LaunchBarrier, lineRedactor, processIdentity, sameProcess, stopRecorded } from './processes.mjs'; import { assertOwnedDocker, compose, dockerEnvironment, removeOwnedDocker, run } from './resources.mjs'; +import { verifyOpenCode } from './opencode-runtime.mjs'; +import { verifyPtyCapability } from './pty-capability.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); const say = text => process.stdout.write(`[dev-stack] ${text}\n`); @@ -119,6 +121,7 @@ export async function start(config, inherited = process.env) { try { const pins = extractPins(await readFile(join(config.root, 'packages/run-spaceapp/templates/compose.yml'), 'utf8'), await readFile(join(config.root, 'Dockerfile'), 'utf8')); await privateWrite(config.compose, JSON.stringify(composeDocument(owner, pins, secret.password), null, 2) + '\n'); + await verifyPtyCapability(config.root, config.state, { inherited: env }); await assertOwnedDocker(owner); say('Starting canonical digest-pinned PostgreSQL and Temporal on loopback.'); await perform(() => compose(config, owner, ['up', '-d', '--wait', '--wait-timeout', '240'], { output: log, secrets: redactions, timeout: 420000, signal: cancellation.signal })); @@ -126,8 +129,11 @@ export async function start(config, inherited = process.env) { const vendor = join(config.state, 'cli/vendor/opencode'); const installed = await readJson(join(vendor, 'node_modules/opencode-ai/package.json')).catch(e => { if (e.code === 'ENOENT') return null; throw e; }); if (installed?.version !== pins.opencode) await command(['install', '--prefix', vendor, '--ignore-scripts', '--no-audit', '--no-fund', `opencode-ai@${pins.opencode}`]); + await verifyOpenCode(config.state, pins.opencode, { inherited: env }); + await privateWrite(join(config.state, 'cli/opencode-version'), pins.opencode); const wrapper = join(config.state, 'cli/bin/opencode-vscode-parity'); await copyFile(join(root, 'scripts/dev-stack/opencode-wrapper.mjs'), wrapper); await chmod(wrapper, 0o700); + await copyFile(join(root, 'scripts/dev-stack/opencode-runtime.mjs'), join(config.state, 'cli/bin/opencode-runtime.mjs')); await perform(() => run(process.execPath, ['packages/db/dist/migrate.js'], { cwd: config.root, env, secrets: redactions, output: log, signal: cancellation.signal })); await sourceServices(); await launch('web', [join(config.root, 'node_modules/vite/bin/vite.js'), '--host', '127.0.0.1', '--port', String(owner.ports.web), '--strictPort'], join(config.root, 'apps/web')); diff --git a/scripts/dev-stack/terminal-proof.mjs b/scripts/dev-stack/terminal-proof.mjs new file mode 100644 index 000000000..1e710cf78 --- /dev/null +++ b/scripts/dev-stack/terminal-proof.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { stripVTControlCharacters } from 'node:util'; + +const menuTitles = ['Switch session', 'New session', 'Switch model', 'Switch agent', 'Connect provider', 'View status', 'Switch theme', 'Help', 'Exit the app']; +// Exact 1.18.4 references are recorded in the validation evidence. A brand name, +// rendered terminal rectangle, or historical RUNNING flag is never UI proof. +export class InteractiveTerminalProof { + constructor(sessionId) { this.sessionId = sessionId; this.wsState = 'CONNECTING'; this.status = null; this.events = []; this.sequence = 0; this.fatal = null; this.sealed = false; this.session = null; } + fail(message) { if (!this.sealed && !this.fatal) this.fatal = message; } + open() { this.wsState = 'OPEN'; } + error(code = 'WEBSOCKET_ERROR') { this.fail(`Terminal WebSocket error (${String(code).slice(0, 80)}).`); } + close(code) { this.wsState = 'CLOSED'; this.closeCode = code; this.fail('Terminal WebSocket closed during the interactive proof.'); } + message(event) { + if (this.sealed) return; + if (event.type === 'error') this.fail(`Terminal error (${String(event.code || 'UNKNOWN').slice(0, 80)}).`); + if (event.type === 'status') { this.status = event.status; this.exitCode = event.exitCode ?? null; if (['ERROR', 'EXITED'].includes(event.status) || this.exitCode !== null) this.fail(`Terminal status/exit evidence ${event.status}.`); } + if (event.type === 'output') { + const text = stripVTControlCharacters(String(event.data)); + this.events.push({ sequence: ++this.sequence, text }); + while (this.events.length > 256) this.events.shift(); + const recent = this.events.map(item => item.text).join('').slice(-8000); + if (/MODULE_NOT_FOUND|Cannot find module|postinstall[^\n]*not|CLI process exited|Pinned state-local OpenCode could not start/i.test(recent)) this.fail('Terminal output reports a CLI bootstrap/exit failure.'); + } + } + assertHealthy() { if (this.fatal) { const error = new Error(this.fatal); error.fatal = true; throw error; } } + live(response) { + this.assertHealthy(); + const session = response?.session; + this.session = session ? { sessionId: session.sessionId, status: session.status, isActive: session.isActive, exitCode: session.exitCode ?? null } : null; + if (!session || session.sessionId !== this.sessionId || session.runtimeId !== 'cli:opencode' || session.isActive !== true || session.exitCode !== null || ['ERROR', 'EXITED'].includes(session.status)) { + this.fail('Fresh API session is absent, inactive, failed, or differs from the tested OpenCode session.'); this.assertHealthy(); + } + return this.wsState === 'OPEN' && this.status === 'RUNNING' && session.status === 'RUNNING'; + } + barrier() { this.assertHealthy(); return this.sequence; } + fresh(after) { return this.events.filter(event => event.sequence > after).map(event => event.text).join(''); } + menu(after) { this.assertHealthy(); const text = this.fresh(after); return text.includes('Commands') && menuTitles.filter(title => text.includes(title)).length >= 2; } + challenge(after, value) { this.assertHealthy(); const text = this.fresh(after); return text.includes(value) && text.includes('No results found'); } + seal(response) { assert.equal(this.live(response), true, 'UI success requires a final fresh live API/WS session.'); this.sealed = true; } + diagnostic(redactions = []) { + let excerpt = this.events.map(event => event.text).join('').slice(-6000); + for (const secret of redactions.filter(Boolean).sort((a, b) => b.length - a.length)) excerpt = excerpt.split(secret).join('[redacted]'); + excerpt = excerpt.replace(/([?&](?:token|sessionToken|access_token)=)[^\s&]+/gi, '$1[redacted]'); + return { expectedSessionId: this.sessionId, currentSession: this.session, websocket: { state: this.wsState, status: this.status, exitCode: this.exitCode ?? null, closeCode: this.closeCode ?? null, fatal: this.fatal }, outputEvents: this.sequence, sealed: this.sealed, syntheticTerminalExcerpt: excerpt }; + } +} +export function assertBrowserTerminal(state, paneId) { + assert(state && state.paneId === paneId, 'The rendered terminal must belong to the tested pane.'); + assert.equal(state.status, 'attached', 'The tested browser terminal must be currently attached.'); + assert.equal(state.hasSession, 'true'); assert.equal(state.hasWebsocket, 'true'); assert.equal(state.hasError, 'false'); + assert(state.width > 0, 'The matching live terminal must actually render.'); +} diff --git a/scripts/public-export.mjs b/scripts/public-export.mjs index f694288f2..913d8017f 100644 --- a/scripts/public-export.mjs +++ b/scripts/public-export.mjs @@ -85,6 +85,10 @@ const publicScripts = new Set([ "scripts/dev-stack/resources.mjs", "scripts/dev-stack/supervisor.mjs", "scripts/dev-stack/opencode-wrapper.mjs", + "scripts/dev-stack/opencode-runtime.mjs", + "scripts/dev-stack/pty-capability.mjs", + "scripts/dev-stack/capability-proof.mjs", + "scripts/dev-stack/terminal-proof.mjs", "scripts/dev-stack/acceptance.mjs", "scripts/dev-stack/browser-proof.mjs", "scripts/dev-stack/lifecycle-proof.mjs", diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs index 40a844f47..d2a921d44 100644 --- a/scripts/tests/dev-stack.test.mjs +++ b/scripts/tests/dev-stack.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { readFile, mkdir, mkdtemp, realpath, rm, stat, symlink } from 'node:fs/promises'; +import { chmod, copyFile, readFile, mkdir, mkdtemp, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { test } from 'node:test'; @@ -8,6 +8,10 @@ import { fileURLToPath } from 'node:url'; import { composeDocument, extractPins, initialize, loadOwner, locations, ownerLabel, privateWrite, secrets, serviceEnvironment } from '../dev-stack/state.mjs'; import { descendants, LaunchBarrier, lineRedactor, processIdentity, sameProcess, stopRecorded } from '../dev-stack/processes.mjs'; import { assertLoopbackPublications, assertOwnedDocker, composeArgs, diagnoseOwnedDocker, dockerEnvironment, readOwnedPortPublications, removeOwnedDocker, run } from '../dev-stack/resources.mjs'; +import { confinedFile, nativePackage, opencodeEnvironment, resolveOpenCode, verifyOpenCode } from '../dev-stack/opencode-runtime.mjs'; +import { InteractiveTerminalProof, assertBrowserTerminal } from '../dev-stack/terminal-proof.mjs'; +import { knownHelperRepair } from '../dev-stack/pty-capability.mjs'; +import { isPublicExportPath } from '../public-export.mjs'; const root = fileURLToPath(new URL('../..', import.meta.url)); const pins = extractPins(await readFile(join(root, 'packages/run-spaceapp/templates/compose.yml'), 'utf8'), await readFile(join(root, 'Dockerfile'), 'utf8')); @@ -180,22 +184,28 @@ test('owned failure diagnostics redact split secrets and select health data with if (args.includes('{{json .Config.Labels}}')) return JSON.stringify({ [ownerLabel]: owner.nonce }); if (args.includes('logs')) { options.output('synthetic-pass'); options.output('word\n'); return ''; } if (args.includes('{{json .State.Health}}')) { options.output('{"Status":"unhealthy","Output":"synthetic-password"}\n'); return ''; } + if (args.some(arg => arg.includes('"oomKilled":'))) { options.output('{"status":"running","oomKilled":false,"error":"synthetic-password"}\n'); return ''; } + if (args[0] === '-ltn' || args[0] === '-nP') return `LISTEN 127.0.0.1:${owner.ports.temporal}\nLISTEN 0.0.0.0:11111 unrelated-must-not-be-retained`; throw new Error('Unexpected diagnostic command'); }; await diagnoseOwnedDocker(config, owner, { invoke, env: { PATH: '/synthetic' }, secrets: ['synthetic-password'], output: text => { output += text; } }); assert(!output.includes('synthetic-password')); assert.match(output, /\[redacted\]/); assert(calls.some(args => args.includes('--tail') && args.includes('100'))); assert(calls.some(args => args.includes('{{json .State.Health}}'))); + assert(calls.some(args => args.some(arg => arg.includes('"oomKilled":')))); + assert(output.includes(`127.0.0.1:${owner.ports.temporal}`)); assert(!output.includes('unrelated-must-not-be-retained')); assert(!calls.some(args => args.includes('{{json .Config.Env}}') || (args.includes('inspect') && !args.includes('--format')))); }); test('diagnostics run before owned down and a diagnostic failure cannot prevent cleanup', async t => { const config = await sandbox(t), owner = await initialize(config), calls = []; let down = false, output = ''; - const invoke = async (_command, args) => { + const invoke = async (_command, args, options) => { calls.push(args); if (args[1] === 'ls') return args[0] === 'container' && !down ? 'owned-container' : ''; if (args.includes('{{json .Config.Labels}}')) return JSON.stringify({ [ownerLabel]: owner.nonce }); if (args.includes('logs')) throw new Error('Synthetic diagnostic failure must not escape into logs'); + if (args.includes('{{json .State.Health}}') || args.some(arg => arg.includes('"oomKilled":'))) { options.output('{}\n'); return ''; } + if (args[0] === '-ltn' || args[0] === '-nP') return ''; if (args.includes('down')) { down = true; return ''; } throw new Error('Unexpected cleanup command'); }; @@ -306,3 +316,113 @@ test('a command timeout cannot be converted into success by its signal handler', test('a missing executable rejects promptly instead of leaving an identity wait', async () => { await assert.rejects(run('spaceapp-deliberately-missing-executable', [], { env: { ...process.env }, timeout: 200 }), /could not start/); }); + +async function nativeFixture(t, platform = 'linux', arch = 'x64') { + const config = await sandbox(t); await initialize(config); + const name = nativePackage(platform, arch), modules = join(config.state, 'cli/vendor/opencode/node_modules'); + const core = join(modules, 'opencode-ai/package.json'), selected = join(modules, name, 'package.json'), executable = join(modules, name, 'bin/opencode'); + await mkdir(join(modules, 'opencode-ai'), { recursive: true }); await mkdir(join(modules, name, 'bin'), { recursive: true }); + await writeFile(core, JSON.stringify({ name: 'opencode-ai', version: pins.opencode, optionalDependencies: { [name]: pins.opencode } })); + await writeFile(selected, JSON.stringify({ name, version: pins.opencode })); + await writeFile(executable, `#!/bin/sh\nif [ "$1" = '--version' ]; then printf '%s\\n' '${pins.opencode}'; else printf '%s\\n' "$PWD" "$HOME"; fi\n`); await chmod(executable, 0o700); + return { config, core, selected, executable, name }; +} +test('native OpenCode resolves the exact confined optional binary rather than the postinstall placeholder', async t => { + const fixture = await nativeFixture(t); + const native = await resolveOpenCode(fixture.config.state, pins.opencode, { platform: 'linux', arch: 'x64' }); + assert.equal(native.executable, fixture.executable); assert.equal(native.package, fixture.name); assert.match(native.sha256, /^[a-f0-9]{64}$/); + assert.equal(nativePackage('darwin', 'x64'), 'opencode-darwin-x64-baseline'); + assert.throws(() => nativePackage('win32', 'x64'), /Unsupported/); + assert.throws(() => nativePackage('linux', 'unknown'), /Unsupported/); +}); +test('the public source export retains every new runtime and capability proof module', () => { + for (const name of ['opencode-runtime.mjs', 'pty-capability.mjs', 'capability-proof.mjs', 'terminal-proof.mjs']) assert.equal(isPublicExportPath(`scripts/dev-stack/${name}`), true, name); +}); +test('native version verification executes the selected file directly with only a private provider environment', async t => { + const { config, executable } = await nativeFixture(t); let calls = 0; + const invoke = async (file, args, options) => { + calls++; assert.equal(file, executable); assert.deepEqual(args, ['--version']); assert.equal(options.timeout, 15000); + assert.equal(options.env.OPENAI_API_KEY, undefined); assert.equal(options.env.SSH_AUTH_SOCK, undefined); + assert.equal(options.env.HOME, join(config.state, 'providers/opencode')); assert.equal(options.cwd, options.env.HOME); + return pins.opencode + '\n'; + }; + await verifyOpenCode(config.state, pins.opencode, { platform: 'linux', arch: 'x64', invoke, inherited: { PATH: '/reviewed', OPENAI_API_KEY: 'must-not-inherit', SSH_AUTH_SOCK: 'must-not-inherit' } }); + assert.equal(calls, 1); + let failure; + await assert.rejects(verifyOpenCode(config.state, pins.opencode, { platform: 'linux', arch: 'x64', invoke: async () => '0.0.0', observe: async value => { failure = value; } }), /Actual private.*version/); + assert.equal(failure.ok, false); assert.equal(failure.stage, 'actual-native-version'); assert.equal(failure.actualVersion, '0.0.0'); assert.equal(failure.error.code, 'VERSION_MISMATCH'); assert.equal(failure.native.relativeExecutable.includes('node_modules'), true); + assert.equal(opencodeEnvironment(config.state, { ANTHROPIC_API_KEY: 'secret' }).ANTHROPIC_API_KEY, undefined); +}); +test('wrong core/native version, missing optional dependency and linked package paths fail closed', async t => { + const fixture = await nativeFixture(t); + const original = JSON.parse(await readFile(fixture.core, 'utf8')); + for (const core of [{ ...original, version: '0.0.0' }, { ...original, optionalDependencies: {} }]) { + await writeFile(fixture.core, JSON.stringify(core)); await assert.rejects(resolveOpenCode(fixture.config.state, pins.opencode, { platform: 'linux', arch: 'x64' }), /mismatch/); + } + await writeFile(fixture.core, JSON.stringify(original)); + await writeFile(fixture.selected, JSON.stringify({ name: fixture.name, version: '0.0.0' })); + await assert.rejects(resolveOpenCode(fixture.config.state, pins.opencode, { platform: 'linux', arch: 'x64' }), /mismatch/); + const external = join(fixture.config.root, 'foreign'); await mkdir(external); + const link = join(fixture.config.state, 'linked'); await symlink(external, link, process.platform === 'win32' ? 'junction' : 'dir'); + await writeFile(join(external, 'binary'), 'foreign sentinel'); + await assert.rejects(confinedFile(fixture.config.state, join(link, 'binary')), /linked/); + await assert.rejects(confinedFile(fixture.config.state, join(external, 'binary')), /physical private root/); + assert.equal(await readFile(join(external, 'binary'), 'utf8'), 'foreign sentinel'); +}); +test('actual private wrapper preserves the requested pane cwd while keeping provider HOME private', { skip: process.platform === 'win32' }, async t => { + const { config } = await nativeFixture(t, process.platform, process.arch); + const bin = join(config.state, 'cli/bin'); + for (const name of ['opencode-wrapper.mjs', 'opencode-runtime.mjs']) await copyFile(join(root, 'scripts/dev-stack', name), join(bin, name)); + await writeFile(join(config.state, 'cli/opencode-version'), pins.opencode); + const output = await run(process.execPath, [join(bin, 'opencode-wrapper.mjs')], { cwd: join(config.state, 'workspaces'), env: { PATH: process.env.PATH }, timeout: 15000 }); + assert.deepEqual(output.trim().split('\n'), [join(config.state, 'workspaces'), join(config.state, 'providers/opencode')]); +}); +const liveSession = () => ({ session: { sessionId: 'tested', runtimeId: 'cli:opencode', status: 'RUNNING', isActive: true, exitCode: null } }); +function liveProof() { const proof = new InteractiveTerminalProof('tested'); proof.open(); proof.message({ type: 'status', status: 'RUNNING' }); assert.equal(proof.live(liveSession()), true); return proof; } +test('the historical module-error/brand substring false positive is explicitly rejected', () => { + const output = "Error: Cannot find module '/private/opencode-ai/bin/opencode'\nMODULE_NOT_FOUND\nCLI process exited (code 1)"; + assert.equal(/opencode/i.test(output), true, 'The old assertion accepts this actual error family.'); + const proof = liveProof(); proof.message({ type: 'output', data: output }); + assert.throws(() => proof.live(liveSession()), /bootstrap\/exit failure/); + const split = liveProof(); split.message({ type: 'output', data: 'MODULE_NOT_' }); split.message({ type: 'output', data: 'FOUND opencode' }); assert.throws(() => split.live(liveSession()), /bootstrap\/exit failure/); +}); +test('RUNNING cannot survive a terminal error, exit, WebSocket error or close', () => { + for (const end of [p => p.message({ type: 'status', status: 'ERROR' }), p => p.message({ type: 'status', status: 'EXITED' }), p => p.message({ type: 'status', status: 'RUNNING', exitCode: 0 }), p => p.message({ type: 'error', code: 'CLI_TERMINAL_SETUP_FAILED' }), p => p.error('ECONNRESET'), p => p.close(1008)]) { + const proof = liveProof(); end(proof); proof.message({ type: 'status', status: 'RUNNING' }); assert.throws(() => proof.live(liveSession())); + } + const proof = liveProof(); proof.message({ type: 'status', status: 'IDLE' }); assert.equal(proof.live(liveSession()), false); +}); +test('each interactive result needs a fresh matching active RUNNING API session', () => { + for (const response of [null, { session: { ...liveSession().session, sessionId: 'another' } }, { session: { ...liveSession().session, runtimeId: 'cli:other' } }, { session: { ...liveSession().session, isActive: false } }, { session: { ...liveSession().session, status: 'EXITED' } }, { session: { ...liveSession().session, exitCode: 0 } }]) assert.throws(() => liveProof().live(response), /Fresh API/); + assert.equal(liveProof().live({ session: { ...liveSession().session, status: 'IDLE' } }), false); +}); +test('menu and unique filter evidence must arrive after the corresponding input barrier', () => { + const proof = liveProof(), menu = 'Commands\nSwitch model\nSwitch agent\nExit the app'; + proof.message({ type: 'output', data: menu }); let barrier = proof.barrier(); assert.equal(proof.menu(barrier), false); + proof.message({ type: 'output', data: 'opencode' }); assert.equal(proof.menu(barrier), false); + proof.message({ type: 'output', data: menu }); assert.equal(proof.menu(barrier), true); + proof.message({ type: 'output', data: 'spaceproof-unique' }); barrier = proof.barrier(); assert.equal(proof.challenge(barrier, 'spaceproof-unique'), false); + proof.message({ type: 'output', data: 'Search: spaceproof-unique' }); assert.equal(proof.challenge(barrier, 'spaceproof-unique'), false); + proof.message({ type: 'output', data: 'No results found' }); assert.equal(proof.challenge(barrier, 'spaceproof-unique'), true); +}); +test('browser proof rejects unrelated, disconnected, empty or erroneous terminal panes', () => { + const state = { paneId: 'tested', status: 'attached', hasSession: 'true', hasWebsocket: 'true', hasError: 'false', width: 1200 }; + assert.doesNotThrow(() => assertBrowserTerminal(state, 'tested')); + for (const change of [{ paneId: 'unrelated' }, { status: 'closed' }, { hasSession: 'false' }, { hasWebsocket: 'false' }, { hasError: 'true' }, { width: 0 }]) assert.throws(() => assertBrowserTerminal({ ...state, ...change }, 'tested')); +}); +test('interactive fatal latch ends only after verified success, allowing later intentional HMR/cleanup', () => { + const proof = liveProof(); proof.seal(liveSession()); proof.close(1000); assert.equal(proof.diagnostic().websocket.fatal, null); + const failed = liveProof(); failed.close(1000); assert.throws(() => failed.seal(liveSession())); +}); +test('synthetic terminal diagnostics are bounded, selected and redact supplied/query secrets', () => { + const proof = liveProof(); proof.message({ type: 'output', data: 'x'.repeat(8000) + '\nprivate-cookie http://local/?token=private-ticket\n' }); + const value = proof.diagnostic(['private-cookie', 'private-ticket']); assert(value.syntheticTerminalExcerpt.length <= 6000); + assert.equal(JSON.stringify(value).includes('private-cookie'), false); assert.equal(JSON.stringify(value).includes('private-ticket'), false); + assert.deepEqual(Object.keys(value.currentSession), ['sessionId', 'status', 'isActive', 'exitCode']); +}); +test('known Darwin helper repair refuses any unpinned, linked, foreign, changed or already executable metadata', () => { + const record = { platform: 'darwin', arch: 'x64', version: '1.1.0', sha256: '46a4455777c6122d7e71cd706ea4b37b19bedd297684be8b6dabb833ff2a3965', mode: 0o644, uid: 123, nlink: 1 }; + assert.equal(knownHelperRepair(record, 123), true); + for (const change of [{ platform: 'linux' }, { arch: 'other' }, { version: '1.1.1' }, { sha256: 'changed' }, { mode: 0o755 }, { uid: 124 }, { nlink: 2 }]) assert.equal(knownHelperRepair({ ...record, ...change }, 123), false); + assert.equal(knownHelperRepair(record, null), false); +}); From 02806e901594abb84f8b3df9f9903788d8ee4f70 Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 17 Oct 2026 20:11:52 +0200 Subject: [PATCH 11/27] Bound browser input events and verify real CDP typing early --- docs/development-stack.md | 7 +++- scripts/dev-stack/browser-proof.mjs | 44 ++++++++++++++++++++++++-- scripts/dev-stack/capability-proof.mjs | 6 ++-- scripts/tests/dev-stack.test.mjs | 20 ++++++++++++ 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/docs/development-stack.md b/docs/development-stack.md index 3cab059b9..a09f8468c 100644 --- a/docs/development-stack.md +++ b/docs/development-stack.md @@ -116,7 +116,7 @@ permissions, pins, environment isolation, redaction, PID reuse and failed comman These tests do not establish a working Docker stack or browser. `.github/workflows/dev-stack.yml` prepares only standard public `ubuntu-latest` -and `macos-15-intel` runners and checks the exact source. Both run all 47 launcher +and `macos-15-intel` runners and checks the exact source. Both run all 48 launcher regressions, `npm run check`, `npm run build` and `npm run hygiene:preflight`. Ubuntu also runs the full unmodified `npm test` suite. Native macOS runs all 13 private-instance API regressions in the four `instance-storage`/`dev-*-storage` @@ -138,6 +138,11 @@ node-pty1.1.0 helper hash, mode0644, checkout confinement, ownership and single link can authorize restoring that one helper's execute bits. The failure, modes, unchanged bytes and successful second real probe are retained. Other failures are not repaired or hidden; no installed/global dependency path is changed. +The same early step uses the hosted browser's isolated blank page to record its +actual version and long-character-event behavior, then verifies exact delivery +of the synthetic filter through awaited single-character events. It uses no +app/account session, paste, Enter, provider or model. The actual terminal proof +uses that same bounded typing method while retaining every live/menu/filter check. After those gates, `scripts/dev-stack/lifecycle-proof.mjs` starts a real stack with synthetic installed ports4910/4911 occupied by an independent process. It exercises diff --git a/scripts/dev-stack/browser-proof.mjs b/scripts/dev-stack/browser-proof.mjs index 755e9c469..be2fd57a0 100644 --- a/scripts/dev-stack/browser-proof.mjs +++ b/scripts/dev-stack/browser-proof.mjs @@ -7,12 +7,19 @@ import WebSocket from 'ws'; import { delay, descendants, processIdentity, sameProcess, stopRecorded } from './processes.mjs'; import { assertBrowserTerminal } from './terminal-proof.mjs'; +export async function typeTerminalFilter(call, text) { + assert(typeof text === 'string' && /^[a-z0-9]{1,64}$/.test(text), 'Only the synthetic local palette filter is permitted.'); + // Chromium's keyboard event text has a tiny UTF-16 buffer. Await each actual + // character event: preserve the challenge/order without a paste or Enter. + for (const character of text) await call('Input.dispatchKeyEvent', { type: 'char', text: character }); +} + export async function eventually(fn, label, timeout = 120000) { const until = Date.now() + timeout; let last; while (Date.now() < until) { try { const value = await fn(); if (value) return value; } catch (error) { if (error.fatal) throw error; last = error; } await delay(400); } throw new Error(`${label} did not succeed${last ? ` (${last.message})` : ''}.`); } -export async function browserProof({ state, origin, cookie, artifacts, paneId }) { +async function openProofBrowser(state) { const binary = process.env.SPACE_DEV_BROWSER || (process.platform === 'darwin' ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : '/usr/bin/google-chrome'); if (!existsSync(binary)) throw new Error('Hosted Chrome is missing. Set SPACE_DEV_BROWSER to an existing reviewed browser; no hidden download/fallback.'); const profile = join(state, 'browser-proof'); await mkdir(profile, { mode: 0o700 }); @@ -35,10 +42,43 @@ export async function browserProof({ state, origin, cookie, artifacts, paneId }) const id = next++, timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP ${method} timed out.`)); }, 30000); pending.set(id, { resolve, reject, timer }); socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }); + const version = await send('Browser.getVersion'); const target = await send('Target.createTarget', { url: 'about:blank' }); const { sessionId } = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); const call = (method, params) => send(method, params, sessionId); const evaluate = async expression => { const result = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (result.exceptionDetails) throw new Error('Browser evaluation failed.'); return result.result.value; }; + return { call, evaluate, close, version: { product: version.product, protocolVersion: version.protocolVersion } }; + } catch (error) { await close(); throw error; } +} +export async function browserKeyboardCapability({ state, artifacts, challenge }) { + const proof = { source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, challengeLength: challenge.length, baseline: null, perCharacter: null, ok: false }; + let browser; + try { + browser = await openProofBrowser(state); + const { call, evaluate } = browser; proof.browser = browser.version; + // Task-owned about:blank only; no account, external page, app session or model. + await call('Page.enable'); + await eventually(() => evaluate('Boolean(document.body)'), 'Synthetic blank-page readiness', 10000); + await evaluate('document.body.innerHTML = ""; document.getElementById("filter").focus()'); + try { + await call('Input.dispatchKeyEvent', { type: 'char', text: challenge }); + proof.baseline = { rejected: false, deliveredExactText: await evaluate(`document.getElementById('filter').value === ${JSON.stringify(challenge)}`) }; + } catch (error) { + if (!/Invalid ['"]text['"] parameter/.test(error.message)) throw error; + proof.baseline = { rejected: true, error: 'Invalid text parameter', deliveredNoText: await evaluate('document.getElementById("filter").value === ""') }; + assert.equal(proof.baseline.deliveredNoText, true); + } + await evaluate('document.getElementById("filter").value = ""; document.getElementById("filter").focus()'); + await typeTerminalFilter(call, challenge); + assert.equal(await evaluate(`document.getElementById('filter').value === ${JSON.stringify(challenge)}`), true, 'Actual Chrome must receive every serial character in exact order.'); + proof.perCharacter = { eventCount: challenge.length, maxUtf16UnitsPerEvent: 1, deliveredExactText: true, enterOrPasteSent: false }; + proof.ok = true; return proof; + } catch (error) { proof.error = { name: error.name, message: error.message.slice(0, 300) }; throw error; + } finally { await mkdir(artifacts, { recursive: true }); await writeFile(join(artifacts, 'early-browser-keyboard-capability.json'), JSON.stringify(proof, null, 2) + '\n', { mode: 0o600 }); await browser?.close(); } +} +export async function browserProof({ state, origin, cookie, artifacts, paneId }) { + const { call, evaluate, close } = await openProofBrowser(state); + try { await call('Page.enable'); await call('Network.enable'); const index = cookie.indexOf('='); assert(index > 0); await call('Network.setCookie', { url: origin, name: cookie.slice(0, index), value: cookie.slice(index + 1), httpOnly: true, sameSite: 'Lax' }); @@ -53,7 +93,7 @@ export async function browserProof({ state, origin, cookie, artifacts, paneId }) const key = async (name, text) => { assertBrowserTerminal(await terminalState(), paneId); assert.equal(await evaluate(`(() => { const input = ${paneExpression}?.querySelector('.xterm-helper-textarea'); input?.focus(); return document.activeElement === input; })()`), true, 'Keyboard must target the tested terminal.'); - if (text) await call('Input.dispatchKeyEvent', { type: 'char', text }); + if (text) await typeTerminalFilter(call, text); else { const params = name === 'Ctrl+P' ? { key: 'p', code: 'KeyP', windowsVirtualKeyCode: 80, modifiers: 2 } : { key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }; await call('Input.dispatchKeyEvent', { type: 'keyDown', ...params }); await call('Input.dispatchKeyEvent', { type: 'keyUp', ...params }); diff --git a/scripts/dev-stack/capability-proof.mjs b/scripts/dev-stack/capability-proof.mjs index c848e3d97..048151634 100644 --- a/scripts/dev-stack/capability-proof.mjs +++ b/scripts/dev-stack/capability-proof.mjs @@ -1,7 +1,7 @@ // Fail early on real native dependencies before the lengthy stock gates. This // isolated temporary state is never an operator profile or a provider login. import assert from 'node:assert/strict'; -import { randomUUID } from 'node:crypto'; +import { randomBytes, randomUUID } from 'node:crypto'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { isAbsolute, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -9,6 +9,7 @@ import { extractPins, initialize, loadOwner, locations, secrets, serviceEnvironm import { verifyOpenCode } from './opencode-runtime.mjs'; import { verifyPtyCapability } from './pty-capability.mjs'; import { run } from './resources.mjs'; +import { browserKeyboardCapability } from './browser-proof.mjs'; const root = resolve(fileURLToPath(new URL('../..', import.meta.url))), artifacts = resolve(process.env.SPACE_DEV_PROOF_DIR || ''); const rel = relative(root, artifacts); @@ -18,11 +19,12 @@ const config = locations(root, { SPACE_DEV_STATE_DIR: `.dev-stack-capability-${r const owner = await initialize(config), env = serviceEnvironment(config, owner, await secrets(config), process.env); const proof = { source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, stage: 'before-pty', ok: false, providerCredentials: false, modelInference: false }; try { + await browserKeyboardCapability({ state: config.state, artifacts, challenge: `spaceproof${randomBytes(8).toString('hex')}` }); await verifyPtyCapability(root, config.state, { inherited: env, evidence: join(artifacts, 'early-pty-capability.json') }); const pins = extractPins(await readFile(join(root, 'packages/run-spaceapp/templates/compose.yml'), 'utf8'), await readFile(join(root, 'Dockerfile'), 'utf8')); proof.expectedVersion = pins.opencode; proof.stage = 'install-private-pinned-package'; await run('npm', ['install', '--prefix', join(config.state, 'cli/vendor/opencode'), '--ignore-scripts', '--no-audit', '--no-fund', `opencode-ai@${pins.opencode}`], { cwd: root, env, output: text => process.stdout.write(text) }); await verifyOpenCode(config.state, pins.opencode, { inherited: env, observe: async value => Object.assign(proof, value) }); - console.log('[dev-stack-capability] PASS actual pinned native --version and real zero-provider printf PTY'); + console.log('[dev-stack-capability] PASS actual browser keyboard, pinned native --version and real zero-provider printf PTY'); } catch (error) { proof.error ??= { name: error.name, code: error.code ?? null, signal: error.signal ?? null }; throw error; } finally { await writeFile(join(artifacts, 'early-opencode-capability.json'), JSON.stringify(proof, null, 2) + '\n', { mode: 0o600 }); await loadOwner(config); await rm(config.state, { recursive: true }); } diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs index d2a921d44..3027d0bdf 100644 --- a/scripts/tests/dev-stack.test.mjs +++ b/scripts/tests/dev-stack.test.mjs @@ -12,6 +12,7 @@ import { confinedFile, nativePackage, opencodeEnvironment, resolveOpenCode, veri import { InteractiveTerminalProof, assertBrowserTerminal } from '../dev-stack/terminal-proof.mjs'; import { knownHelperRepair } from '../dev-stack/pty-capability.mjs'; import { isPublicExportPath } from '../public-export.mjs'; +import { typeTerminalFilter } from '../dev-stack/browser-proof.mjs'; const root = fileURLToPath(new URL('../..', import.meta.url)); const pins = extractPins(await readFile(join(root, 'packages/run-spaceapp/templates/compose.yml'), 'utf8'), await readFile(join(root, 'Dockerfile'), 'utf8')); @@ -426,3 +427,22 @@ test('known Darwin helper repair refuses any unpinned, linked, foreign, changed for (const change of [{ platform: 'linux' }, { arch: 'other' }, { version: '1.1.1' }, { sha256: 'changed' }, { mode: 0o755 }, { uid: 124 }, { nlink: 2 }]) assert.equal(knownHelperRepair({ ...record, ...change }, 123), false); assert.equal(knownHelperRepair(record, null), false); }); +test('CDP typing preserves the exact long filter through serial bounded character events and propagates failure', async () => { + const challenge = 'spaceproof0123456789abcdef', events = []; + let active = false; + const call = async (method, params) => { + assert.equal(active, false, 'Input events must be awaited in order.'); active = true; + try { + assert.equal(method, 'Input.dispatchKeyEvent'); assert.equal(params.type, 'char'); + if (params.text.length >= 4) throw new Error("Invalid 'text' parameter"); + await new Promise(done => setTimeout(done, 1)); events.push(params.text); + } finally { active = false; } + }; + await assert.rejects(call('Input.dispatchKeyEvent', { type: 'char', text: challenge }), /Invalid 'text'/); + await typeTerminalFilter(call, challenge); + assert.equal(events.join(''), challenge); assert.equal(events.length, challenge.length); assert(events.every(text => text.length === 1)); + for (const text of ['bad\rinput', 'bad\ninput', '\u0010', 'UPPERCASE', '']) await assert.rejects(typeTerminalFilter(call, text), /synthetic local palette filter/); + let attempts = 0; + await assert.rejects(typeTerminalFilter(async () => { if (++attempts === 3) throw new Error('Typed transport failure'); }, challenge), /Typed transport failure/); + assert.equal(attempts, 3, 'A failed event must stop typing rather than mask/retry it.'); +}); From 88d9e1ba1e55bd146562258bffc735f5ef96c4d6 Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 17 Oct 2026 20:26:34 +0200 Subject: [PATCH 12/27] Keep capability browser state short and retain startup diagnostics --- docs/development-stack.md | 7 +++++- scripts/dev-stack/acceptance.mjs | 2 +- scripts/dev-stack/browser-proof.mjs | 31 ++++++++++++++++++++------ scripts/dev-stack/capability-proof.mjs | 8 ++++--- scripts/dev-stack/state.mjs | 7 ++++-- scripts/tests/dev-stack.test.mjs | 16 ++++++++++++- 6 files changed, 56 insertions(+), 15 deletions(-) diff --git a/docs/development-stack.md b/docs/development-stack.md index a09f8468c..55ebc99ab 100644 --- a/docs/development-stack.md +++ b/docs/development-stack.md @@ -116,7 +116,7 @@ permissions, pins, environment isolation, redaction, PID reuse and failed comman These tests do not establish a working Docker stack or browser. `.github/workflows/dev-stack.yml` prepares only standard public `ubuntu-latest` -and `macos-15-intel` runners and checks the exact source. Both run all 48 launcher +and `macos-15-intel` runners and checks the exact source. Both run all 50 launcher regressions, `npm run check`, `npm run build` and `npm run hygiene:preflight`. Ubuntu also runs the full unmodified `npm test` suite. Native macOS runs all 13 private-instance API regressions in the four `instance-storage`/`dev-*-storage` @@ -143,6 +143,11 @@ actual version and long-character-event behavior, then verifies exact delivery of the synthetic filter through awaited single-character events. It uses no app/account session, paste, Enter, provider or model. The actual terminal proof uses that same bounded typing method while retaining every live/menu/filter check. +Its capability state uses a short random name and refuses an existing directory; +full-size ownership/secret nonces remain unchanged. This avoids the Linux Chrome +singleton socket's small Unix path budget while keeping HOME/TMPDIR/profile in +that private state. Startup failures retain bounded redacted stderr, binary +version, exit/signal and selected paths; a terminated child fails immediately. After those gates, `scripts/dev-stack/lifecycle-proof.mjs` starts a real stack with synthetic installed ports4910/4911 occupied by an independent process. It exercises diff --git a/scripts/dev-stack/acceptance.mjs b/scripts/dev-stack/acceptance.mjs index 9bb408b0f..18e169c62 100644 --- a/scripts/dev-stack/acceptance.mjs +++ b/scripts/dev-stack/acceptance.mjs @@ -123,6 +123,6 @@ try { } catch (error) { // Selected synthetic state only. Never serialize a session response, ticket, // cookie, websocket URL, environment or real provider/owner credentials. - if (interactive) await writeFile(join(artifacts, 'opencode-failure.json'), JSON.stringify({ source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, native, terminal: interactive.diagnostic([cookie, cookie.split('=').slice(1).join('='), password, token, ticketToken, csrf?.csrfToken]), browser: await browser?.terminalState().catch(() => null) }, null, 2) + '\n', { mode: 0o600 }); + if (interactive) await writeFile(join(artifacts, 'opencode-failure.json'), JSON.stringify({ source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, native, terminal: interactive.diagnostic([cookie, cookie.split('=').slice(1).join('='), password, token, ticketToken, csrf?.csrfToken]), browser: await browser?.terminalState().catch(() => null), browserStartup: error.browserStartup }, null, 2) + '\n', { mode: 0o600 }); throw error; } finally { terminal?.close(); await browser?.close(); } diff --git a/scripts/dev-stack/browser-proof.mjs b/scripts/dev-stack/browser-proof.mjs index be2fd57a0..c6b549a41 100644 --- a/scripts/dev-stack/browser-proof.mjs +++ b/scripts/dev-stack/browser-proof.mjs @@ -1,12 +1,21 @@ import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; +import { execFile, spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { promisify } from 'node:util'; import WebSocket from 'ws'; import { delay, descendants, processIdentity, sameProcess, stopRecorded } from './processes.mjs'; import { assertBrowserTerminal } from './terminal-proof.mjs'; +const execute = promisify(execFile); +export function assertBrowserRunning(child, startError) { + if (startError || child.exitCode !== null || child.signalCode !== null) throw Object.assign(new Error(`Hosted Chrome ended before CDP (${startError?.code || child.signalCode || child.exitCode}).`), { fatal: true }); +} +export function safeBrowserStderr(text) { + return String(text).replace(/wss?:\/\/[^\s"']+/gi, '[redacted websocket endpoint]').replace(/((?:token|password|secret|cookie)=)[^\s"']+/gi, '$1[redacted]').slice(-6000); +} + export async function typeTerminalFilter(call, text) { assert(typeof text === 'string' && /^[a-z0-9]{1,64}$/.test(text), 'Only the synthetic local palette filter is permitted.'); // Chromium's keyboard event text has a tiny UTF-16 buffer. Await each actual @@ -23,8 +32,13 @@ async function openProofBrowser(state) { const binary = process.env.SPACE_DEV_BROWSER || (process.platform === 'darwin' ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : '/usr/bin/google-chrome'); if (!existsSync(binary)) throw new Error('Hosted Chrome is missing. Set SPACE_DEV_BROWSER to an existing reviewed browser; no hidden download/fallback.'); const profile = join(state, 'browser-proof'); await mkdir(profile, { mode: 0o700 }); - const child = spawn(binary, ['--headless=new', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check', '--disable-background-networking', '--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: 'ignore', env: { PATH: process.env.PATH, HOME: join(state, 'home'), TMPDIR: join(state, 'tmp') } }); - let startError; + const env = { PATH: process.env.PATH, HOME: join(state, 'home'), TMPDIR: join(state, 'tmp') }; + let binaryVersion = null, versionError = null; + try { binaryVersion = (await execute(binary, ['--version'], { env, timeout: 10000, maxBuffer: 4096, killSignal: 'SIGKILL', encoding: 'utf8' })).stdout.trim().slice(0, 160); } + catch (error) { versionError = { name: error.name, code: error.code ?? null, signal: error.signal ?? null }; } + const child = spawn(binary, ['--headless=new', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check', '--disable-background-networking', '--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'], env }); + let startError, stderr = ''; + child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString()).slice(-8192); }); child.once('error', error => { startError = error; }); const identity = new Promise(done => { child.once('spawn', () => { void processIdentity(child.pid).then(value => done({ pid: child.pid, identity: value }), () => done(null)); }); child.once('error', () => done(null)); }); let socket, next = 1; const pending = new Map(); @@ -34,7 +48,7 @@ async function openProofBrowser(state) { if (original && await sameProcess(original)) await stopRecorded(await descendants(original)); } try { - const [port, path] = (await eventually(async () => { if (startError || child.exitCode !== null) throw new Error('Hosted Chrome failed to start.'); return (await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).trim(); }, 'Chrome CDP startup')).split('\n'); + const [port, path] = (await eventually(async () => { assertBrowserRunning(child, startError); return (await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).trim(); }, 'Chrome CDP startup')).split('\n'); socket = new WebSocket(`ws://127.0.0.1:${port}${path}`); await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); }); socket.on('message', data => { const response = JSON.parse(data.toString()); const item = pending.get(response.id); if (item) { pending.delete(response.id); clearTimeout(item.timer); if (response.error) item.reject(new Error(response.error.message)); else item.resolve(response.result); } }); @@ -47,8 +61,11 @@ async function openProofBrowser(state) { const { sessionId } = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); const call = (method, params) => send(method, params, sessionId); const evaluate = async expression => { const result = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (result.exceptionDetails) throw new Error('Browser evaluation failed.'); return result.result.value; }; - return { call, evaluate, close, version: { product: version.product, protocolVersion: version.protocolVersion } }; - } catch (error) { await close(); throw error; } + return { call, evaluate, close, version: { product: version.product, protocolVersion: version.protocolVersion, binaryVersion } }; + } catch (error) { + error.browserStartup = { platform: process.platform, profile, tmpdir: env.TMPDIR, binaryVersion, versionError, exitCode: child.exitCode, signalCode: child.signalCode, startErrorCode: startError?.code ?? null, stderr: safeBrowserStderr(stderr) }; + await close(); throw error; + } } export async function browserKeyboardCapability({ state, artifacts, challenge }) { const proof = { source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, challengeLength: challenge.length, baseline: null, perCharacter: null, ok: false }; @@ -73,7 +90,7 @@ export async function browserKeyboardCapability({ state, artifacts, challenge }) assert.equal(await evaluate(`document.getElementById('filter').value === ${JSON.stringify(challenge)}`), true, 'Actual Chrome must receive every serial character in exact order.'); proof.perCharacter = { eventCount: challenge.length, maxUtf16UnitsPerEvent: 1, deliveredExactText: true, enterOrPasteSent: false }; proof.ok = true; return proof; - } catch (error) { proof.error = { name: error.name, message: error.message.slice(0, 300) }; throw error; + } catch (error) { proof.error = { name: error.name, message: error.message.slice(0, 300) }; if (error.browserStartup) proof.startup = error.browserStartup; throw error; } finally { await mkdir(artifacts, { recursive: true }); await writeFile(join(artifacts, 'early-browser-keyboard-capability.json'), JSON.stringify(proof, null, 2) + '\n', { mode: 0o600 }); await browser?.close(); } } export async function browserProof({ state, origin, cookie, artifacts, paneId }) { diff --git a/scripts/dev-stack/capability-proof.mjs b/scripts/dev-stack/capability-proof.mjs index 048151634..81c8785b2 100644 --- a/scripts/dev-stack/capability-proof.mjs +++ b/scripts/dev-stack/capability-proof.mjs @@ -1,7 +1,7 @@ // Fail early on real native dependencies before the lengthy stock gates. This // isolated temporary state is never an operator profile or a provider login. import assert from 'node:assert/strict'; -import { randomBytes, randomUUID } from 'node:crypto'; +import { randomBytes } from 'node:crypto'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { isAbsolute, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -15,8 +15,10 @@ const root = resolve(fileURLToPath(new URL('../..', import.meta.url))), artifact const rel = relative(root, artifacts); assert(process.env.SPACE_DEV_PROOF_DIR && rel && (rel.startsWith('..') || isAbsolute(rel)), 'Private proof directory must be outside the checkout.'); await mkdir(artifacts, { recursive: true, mode: 0o700 }); -const config = locations(root, { SPACE_DEV_STATE_DIR: `.dev-stack-capability-${randomUUID()}` }); -const owner = await initialize(config), env = serviceEnvironment(config, owner, await secrets(config), process.env); +// Keep Chrome's private TMPDIR short enough for its Unix singleton socket. The +// owner/secret nonces remain full-size; this short directory name is not identity. +const config = locations(root, { SPACE_DEV_STATE_DIR: `.dev-stack-${randomBytes(2).toString('hex')}` }); +const owner = await initialize(config, { fresh: true }), env = serviceEnvironment(config, owner, await secrets(config), process.env); const proof = { source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, stage: 'before-pty', ok: false, providerCredentials: false, modelInference: false }; try { await browserKeyboardCapability({ state: config.state, artifacts, challenge: `spaceproof${randomBytes(8).toString('hex')}` }); diff --git a/scripts/dev-stack/state.mjs b/scripts/dev-stack/state.mjs index 5844378be..18234bc89 100644 --- a/scripts/dev-stack/state.mjs +++ b/scripts/dev-stack/state.mjs @@ -43,10 +43,13 @@ export async function loadOwner(config) { } return owner; } -export async function initialize(config) { +export async function initialize(config, { fresh = false } = {}) { await assertPlainState(config); const existing = await lstat(config.state).catch(e => { if (e.code === 'ENOENT') return null; throw e; }); - if (existing) return loadOwner(config); // Never adopt an unknown/nonempty directory. + if (existing) { + if (fresh) throw new Error('Fresh capability state already exists; refusing adoption.'); + return loadOwner(config); // Never adopt an unknown/nonempty directory. + } await mkdir(config.state, { mode: 0o700 }); const nonce = randomBytes(16).toString('hex'); const owner = { version: 1, root: await realpath(config.root), state: await realpath(config.state), nonce, project: `spaceapp-dev-${nonce.slice(0, 16)}`, ports: config.ports }; diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs index 3027d0bdf..1bec8d871 100644 --- a/scripts/tests/dev-stack.test.mjs +++ b/scripts/tests/dev-stack.test.mjs @@ -12,7 +12,7 @@ import { confinedFile, nativePackage, opencodeEnvironment, resolveOpenCode, veri import { InteractiveTerminalProof, assertBrowserTerminal } from '../dev-stack/terminal-proof.mjs'; import { knownHelperRepair } from '../dev-stack/pty-capability.mjs'; import { isPublicExportPath } from '../public-export.mjs'; -import { typeTerminalFilter } from '../dev-stack/browser-proof.mjs'; +import { assertBrowserRunning, eventually, safeBrowserStderr, typeTerminalFilter } from '../dev-stack/browser-proof.mjs'; const root = fileURLToPath(new URL('../..', import.meta.url)); const pins = extractPins(await readFile(join(root, 'packages/run-spaceapp/templates/compose.yml'), 'utf8'), await readFile(join(root, 'Dockerfile'), 'utf8')); @@ -446,3 +446,17 @@ test('CDP typing preserves the exact long filter through serial bounded characte await assert.rejects(typeTerminalFilter(async () => { if (++attempts === 3) throw new Error('Typed transport failure'); }, challenge), /Typed transport failure/); assert.equal(attempts, 3, 'A failed event must stop typing rather than mask/retry it.'); }); +test('a short fresh capability state cannot adopt or delete an existing owned instance', async t => { + const config = await sandbox(t), owner = await initialize(config, { fresh: true }), secret = await secrets(config); + await assert.rejects(initialize(config, { fresh: true }), /refusing adoption/); + assert.deepEqual(await secrets(config), secret); assert.equal((await loadOwner(config)).nonce, owner.nonce); + assert.equal((await initialize(config)).nonce, owner.nonce, 'Default named dev-stack resume remains unchanged.'); +}); +test('a signalled Chrome fails immediately and bounded startup diagnostics redact control endpoints', async () => { + let calls = 0; + await assert.rejects(eventually(() => { calls++; assertBrowserRunning({ exitCode: null, signalCode: 'SIGTRAP' }); }, 'Virtual Chrome'), /SIGTRAP/); + assert.equal(calls, 1, 'A dead child must not consume the full startup wait.'); + assert.doesNotThrow(() => assertBrowserRunning({ exitCode: null, signalCode: null })); + const text = safeBrowserStderr('x'.repeat(8000) + ' DevTools listening on ws://127.0.0.1:1234/devtools/browser/private-endpoint token=private-token\n'); + assert(text.length <= 6000); assert(!text.includes('private-endpoint')); assert(!text.includes('private-token')); assert(!text.includes('ws://')); +}); From 46e34e5b7e0cab01ac1ed4fbf0a0ba2fc92a5a27 Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 17 Oct 2026 20:51:17 +0200 Subject: [PATCH 13/27] fix(dev): use native Darwin stty and retain browser diagnostics --- .github/workflows/dev-stack.yml | 4 ++ docs/development-stack.md | 12 +++++- packages/cli-host/src/node-pty-spawn.ts | 3 +- .../cli-host/tests/node-pty-spawn.test.ts | 43 +++++++++++++++++++ scripts/dev-stack/acceptance.mjs | 2 +- scripts/dev-stack/browser-proof.mjs | 24 ++++++----- scripts/dev-stack/capability-proof.mjs | 7 ++- scripts/dev-stack/pty-capability.mjs | 39 ++++++++++++++--- scripts/tests/dev-stack.test.mjs | 7 ++- 9 files changed, 119 insertions(+), 22 deletions(-) create mode 100644 packages/cli-host/tests/node-pty-spawn.test.ts diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml index 25f38a688..f9f410bbc 100644 --- a/.github/workflows/dev-stack.yml +++ b/.github/workflows/dev-stack.yml @@ -128,6 +128,10 @@ jobs: apps/api/tests/instance-storage.test.ts apps/api/tests/dev-oauth-storage.test.ts apps/api/tests/dev-docx-isolation.test.ts apps/api/tests/dev-incidents-storage.test.ts + - name: Native macOS CLI host regressions + if: runner.os == 'macOS' + run: npm test --workspace @space/cli-host + - name: Full stock build run: npm run build diff --git a/docs/development-stack.md b/docs/development-stack.md index 55ebc99ab..da7c28980 100644 --- a/docs/development-stack.md +++ b/docs/development-stack.md @@ -116,11 +116,13 @@ permissions, pins, environment isolation, redaction, PID reuse and failed comman These tests do not establish a working Docker stack or browser. `.github/workflows/dev-stack.yml` prepares only standard public `ubuntu-latest` -and `macos-15-intel` runners and checks the exact source. Both run all 50 launcher +and `macos-15-intel` runners and checks the exact source. Both run all 51 launcher regressions, `npm run check`, `npm run build` and `npm run hygiene:preflight`. Ubuntu also runs the full unmodified `npm test` suite. Native macOS runs all 13 private-instance API regressions in the four `instance-storage`/`dev-*-storage` and `dev-docx-isolation` test files, followed by the same complete real-stack proof. +macOS also runs the CLI-host regressions, including its native `/bin/stty` selection; +Linux keeps the existing `/usr/bin/stty` command. The broader native-macOS stock suites retain upstream limitations, including the Linux-only descriptor filesystem backend and `/proc` metadata helper; those suites are not claimed to pass on macOS. Their assertions and the Linux full-suite gate @@ -133,6 +135,10 @@ the exact pinned OpenCode native optional-package binary with `--version` and a real, zero-provider `printf` PTY. It never executes the npm postinstall or its network fallback. The manifest versions, physical private path, executable and actual version must match; a missing/mismatched binary fails with a bounded receipt. +The early check uses the CLI-host workspace's normal standalone build, then its +actual compiled PTY spawn entry for another exact-output `printf` control. It +records whether the selected native `stty` path is already executable. This does +not substitute for the later actual OpenCode/browser interaction. On Darwin only, a real failed `posix_spawnp` probe plus the exact published node-pty1.1.0 helper hash, mode0644, checkout confinement, ownership and single link can authorize restoring that one helper's execute bits. The failure, modes, @@ -148,6 +154,10 @@ full-size ownership/secret nonces remain unchanged. This avoids the Linux Chrome singleton socket's small Unix path budget while keeping HOME/TMPDIR/profile in that private state. Startup failures retain bounded redacted stderr, binary version, exit/signal and selected paths; a terminated child fails immediately. +Failures after the CDP session opens retain the same selected browser snapshot, +socket state and failed CDP method before closing Chrome. Request parameters, +cookies, session URLs and environments are excluded. A missing `stty` message and +a CDP cookie timeout are distinct observations, without an assumed shared cause. After those gates, `scripts/dev-stack/lifecycle-proof.mjs` starts a real stack with synthetic installed ports4910/4911 occupied by an independent process. It exercises diff --git a/packages/cli-host/src/node-pty-spawn.ts b/packages/cli-host/src/node-pty-spawn.ts index 47f18bfad..f283dcafa 100644 --- a/packages/cli-host/src/node-pty-spawn.ts +++ b/packages/cli-host/src/node-pty-spawn.ts @@ -1,3 +1,4 @@ +import { platform } from "node:os"; import { spawn } from "node-pty"; import type { CliHostSpawn } from "./types.js"; @@ -9,7 +10,7 @@ import type { CliHostSpawn } from "./types.js"; * and app-level exits (Ctrl-D, /exit) are unaffected. */ export const spawnNodePty: CliHostSpawn = (spec) => - spawn("/bin/sh", ["-c", '/usr/bin/stty -isig; exec "$@"', "space-cli-host", spec.command, ...spec.args], { + spawn("/bin/sh", ["-c", `${platform() === "darwin" ? "/bin/stty" : "/usr/bin/stty"} -isig; exec "$@"`, "space-cli-host", spec.command, ...spec.args], { name: "xterm-256color", cols: spec.cols, rows: spec.rows, diff --git a/packages/cli-host/tests/node-pty-spawn.test.ts b/packages/cli-host/tests/node-pty-spawn.test.ts new file mode 100644 index 000000000..b352baf63 --- /dev/null +++ b/packages/cli-host/tests/node-pty-spawn.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { platform, spawn } = vi.hoisted(() => ({ platform: vi.fn(), spawn: vi.fn() })); +vi.mock("node:os", () => ({ platform })); +vi.mock("node-pty", () => ({ spawn })); +import { spawnNodePty } from "../src/node-pty-spawn.js"; + +const spec = { command: "/private/cli", args: ["literal $HOME; no shell expansion"], cwd: "/private/workspace", env: { HOME: "/private/home" }, cols: 120, rows: 32 }; +const options = { name: "xterm-256color", cols: spec.cols, rows: spec.rows, cwd: spec.cwd, env: spec.env }; +beforeEach(() => { vi.resetAllMocks(); }); + +describe("native CLI PTY interrupt setup", () => { + it("uses Darwin's absolute stty while preserving the pane cwd, environment and literal argv", () => { + platform.mockReturnValue("darwin"); + const terminal = { pid: 123 }; spawn.mockReturnValue(terminal); + expect(spawnNodePty(spec)).toBe(terminal); + expect(spawn).toHaveBeenCalledExactlyOnceWith("/bin/sh", ["-c", '/bin/stty -isig; exec "$@"', "space-cli-host", spec.command, ...spec.args], options); + }); + + it("keeps the original Linux shell command and argument forwarding unchanged", () => { + platform.mockReturnValue("linux"); + spawnNodePty(spec); + expect(spawn).toHaveBeenCalledExactlyOnceWith("/bin/sh", ["-c", '/usr/bin/stty -isig; exec "$@"', "space-cli-host", spec.command, ...spec.args], options); + }); + + it("rejects the historical missing Darwin path in a virtualized boundary and accepts the real entry's selection", () => { + platform.mockReturnValue("darwin"); + // Synthetic path availability, not an actual native-platform execution claim. + spawn.mockImplementation((_command: string, args: string[]) => { + if (!args[1].startsWith("/bin/stty ")) throw Object.assign(new Error("missing Darwin stty"), { code: "ENOENT" }); + return { pid: 456 }; + }); + expect(() => spawn("/bin/sh", ["-c", '/usr/bin/stty -isig; exec "$@"'], options)).toThrow("missing Darwin stty"); + expect(spawnNodePty(spec).pid).toBe(456); + }); + + it("propagates a real spawn boundary failure without a PATH or alternate-command retry", () => { + platform.mockReturnValue("darwin"); + const error = Object.assign(new Error("probe spawn failed"), { code: "EACCES" }); spawn.mockImplementation(() => { throw error; }); + expect(() => spawnNodePty(spec)).toThrow(error); + expect(spawn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/scripts/dev-stack/acceptance.mjs b/scripts/dev-stack/acceptance.mjs index 18e169c62..95bacff8e 100644 --- a/scripts/dev-stack/acceptance.mjs +++ b/scripts/dev-stack/acceptance.mjs @@ -123,6 +123,6 @@ try { } catch (error) { // Selected synthetic state only. Never serialize a session response, ticket, // cookie, websocket URL, environment or real provider/owner credentials. - if (interactive) await writeFile(join(artifacts, 'opencode-failure.json'), JSON.stringify({ source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, native, terminal: interactive.diagnostic([cookie, cookie.split('=').slice(1).join('='), password, token, ticketToken, csrf?.csrfToken]), browser: await browser?.terminalState().catch(() => null), browserStartup: error.browserStartup }, null, 2) + '\n', { mode: 0o600 }); + if (interactive) await writeFile(join(artifacts, 'opencode-failure.json'), JSON.stringify({ source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, native, terminal: interactive.diagnostic([cookie, cookie.split('=').slice(1).join('='), password, token, ticketToken, csrf?.csrfToken]), browser: await browser?.terminalState().catch(() => null), browserStartup: error.browserStartup, browserRuntime: error.browserRuntime || browser?.diagnostic('interactive-proof') }, null, 2) + '\n', { mode: 0o600 }); throw error; } finally { terminal?.close(); await browser?.close(); } diff --git a/scripts/dev-stack/browser-proof.mjs b/scripts/dev-stack/browser-proof.mjs index c6b549a41..f74a896f6 100644 --- a/scripts/dev-stack/browser-proof.mjs +++ b/scripts/dev-stack/browser-proof.mjs @@ -15,6 +15,9 @@ export function assertBrowserRunning(child, startError) { export function safeBrowserStderr(text) { return String(text).replace(/wss?:\/\/[^\s"']+/gi, '[redacted websocket endpoint]').replace(/((?:token|password|secret|cookie)=)[^\s"']+/gi, '$1[redacted]').slice(-6000); } +export function selectedBrowserDiagnostic({ profile, tmpdir, binaryVersion, versionError, child, startError, stderr, socket, failedCdpMethod }, phase) { + return { platform: process.platform, phase, profile, tmpdir, binaryVersion, versionError, exitCode: child.exitCode, signalCode: child.signalCode, startErrorCode: startError?.code ?? null, cdpSocketState: socket?.readyState ?? null, failedCdpMethod: failedCdpMethod ?? null, stderr: safeBrowserStderr(stderr) }; +} export async function typeTerminalFilter(call, text) { assert(typeof text === 'string' && /^[a-z0-9]{1,64}$/.test(text), 'Only the synthetic local palette filter is permitted.'); @@ -41,7 +44,8 @@ async function openProofBrowser(state) { child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString()).slice(-8192); }); child.once('error', error => { startError = error; }); const identity = new Promise(done => { child.once('spawn', () => { void processIdentity(child.pid).then(value => done({ pid: child.pid, identity: value }), () => done(null)); }); child.once('error', () => done(null)); }); - let socket, next = 1; const pending = new Map(); + let socket, next = 1, failedCdpMethod = null; const pending = new Map(); + const diagnostic = phase => selectedBrowserDiagnostic({ profile, tmpdir: env.TMPDIR, binaryVersion, versionError, child, startError, stderr, socket, failedCdpMethod }, phase); async function close() { if (socket?.readyState === WebSocket.OPEN) socket.close(); const original = await identity; @@ -51,19 +55,19 @@ async function openProofBrowser(state) { const [port, path] = (await eventually(async () => { assertBrowserRunning(child, startError); return (await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).trim(); }, 'Chrome CDP startup')).split('\n'); socket = new WebSocket(`ws://127.0.0.1:${port}${path}`); await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); }); - socket.on('message', data => { const response = JSON.parse(data.toString()); const item = pending.get(response.id); if (item) { pending.delete(response.id); clearTimeout(item.timer); if (response.error) item.reject(new Error(response.error.message)); else item.resolve(response.result); } }); + socket.on('message', data => { const response = JSON.parse(data.toString()); const item = pending.get(response.id); if (item) { pending.delete(response.id); clearTimeout(item.timer); if (response.error) { failedCdpMethod = item.method; item.reject(new Error(response.error.message)); } else item.resolve(response.result); } }); const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { - const id = next++, timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP ${method} timed out.`)); }, 30000); - pending.set(id, { resolve, reject, timer }); socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); + const id = next++, timer = setTimeout(() => { pending.delete(id); failedCdpMethod = method; reject(new Error(`CDP ${method} timed out.`)); }, 30000); + pending.set(id, { resolve, reject, timer, method }); socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }); const version = await send('Browser.getVersion'); const target = await send('Target.createTarget', { url: 'about:blank' }); const { sessionId } = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); const call = (method, params) => send(method, params, sessionId); const evaluate = async expression => { const result = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (result.exceptionDetails) throw new Error('Browser evaluation failed.'); return result.result.value; }; - return { call, evaluate, close, version: { product: version.product, protocolVersion: version.protocolVersion, binaryVersion } }; + return { call, evaluate, close, diagnostic, version: { product: version.product, protocolVersion: version.protocolVersion, binaryVersion } }; } catch (error) { - error.browserStartup = { platform: process.platform, profile, tmpdir: env.TMPDIR, binaryVersion, versionError, exitCode: child.exitCode, signalCode: child.signalCode, startErrorCode: startError?.code ?? null, stderr: safeBrowserStderr(stderr) }; + error.browserStartup = diagnostic('startup'); await close(); throw error; } } @@ -90,11 +94,11 @@ export async function browserKeyboardCapability({ state, artifacts, challenge }) assert.equal(await evaluate(`document.getElementById('filter').value === ${JSON.stringify(challenge)}`), true, 'Actual Chrome must receive every serial character in exact order.'); proof.perCharacter = { eventCount: challenge.length, maxUtf16UnitsPerEvent: 1, deliveredExactText: true, enterOrPasteSent: false }; proof.ok = true; return proof; - } catch (error) { proof.error = { name: error.name, message: error.message.slice(0, 300) }; if (error.browserStartup) proof.startup = error.browserStartup; throw error; + } catch (error) { proof.error = { name: error.name, message: error.message.slice(0, 300) }; if (error.browserStartup) proof.startup = error.browserStartup; else if (browser) proof.runtime = browser.diagnostic('early-blank-page-keyboard'); throw error; } finally { await mkdir(artifacts, { recursive: true }); await writeFile(join(artifacts, 'early-browser-keyboard-capability.json'), JSON.stringify(proof, null, 2) + '\n', { mode: 0o600 }); await browser?.close(); } } export async function browserProof({ state, origin, cookie, artifacts, paneId }) { - const { call, evaluate, close } = await openProofBrowser(state); + const { call, evaluate, close, diagnostic } = await openProofBrowser(state); try { await call('Page.enable'); await call('Network.enable'); const index = cookie.indexOf('='); @@ -116,6 +120,6 @@ export async function browserProof({ state, origin, cookie, artifacts, paneId }) await call('Input.dispatchKeyEvent', { type: 'keyDown', ...params }); await call('Input.dispatchKeyEvent', { type: 'keyUp', ...params }); } }; - return { evaluate, terminalState, key, async screenshot(name) { assertBrowserTerminal(await terminalState(), paneId); await writeFile(join(artifacts, name), Buffer.from((await call('Page.captureScreenshot', { format: 'png' })).data, 'base64')); }, close }; - } catch (error) { await close(); throw error; } + return { evaluate, terminalState, key, diagnostic, async screenshot(name) { assertBrowserTerminal(await terminalState(), paneId); await writeFile(join(artifacts, name), Buffer.from((await call('Page.captureScreenshot', { format: 'png' })).data, 'base64')); }, close }; + } catch (error) { error.browserRuntime = diagnostic('application-browser'); await close(); throw error; } } diff --git a/scripts/dev-stack/capability-proof.mjs b/scripts/dev-stack/capability-proof.mjs index 81c8785b2..62842e4e3 100644 --- a/scripts/dev-stack/capability-proof.mjs +++ b/scripts/dev-stack/capability-proof.mjs @@ -7,7 +7,7 @@ import { isAbsolute, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { extractPins, initialize, loadOwner, locations, secrets, serviceEnvironment } from './state.mjs'; import { verifyOpenCode } from './opencode-runtime.mjs'; -import { verifyPtyCapability } from './pty-capability.mjs'; +import { verifyCliHostPtyCapability, verifyPtyCapability } from './pty-capability.mjs'; import { run } from './resources.mjs'; import { browserKeyboardCapability } from './browser-proof.mjs'; @@ -23,6 +23,11 @@ const proof = { source: process.env.SPACE_DEV_CANDIDATE, platform: process.platf try { await browserKeyboardCapability({ state: config.state, artifacts, challenge: `spaceproof${randomBytes(8).toString('hex')}` }); await verifyPtyCapability(root, config.state, { inherited: env, evidence: join(artifacts, 'early-pty-capability.json') }); + // This workspace has only Node/node-pty runtime imports and its normal tsc build + // needs no other workspace dist. Exercise the real entry, not a copied shell. + proof.stage = 'build-and-probe-cli-host-entry'; + await run('npm', ['run', 'build', '--workspace', '@space/cli-host'], { cwd: root, env, output: text => process.stdout.write(text) }); + await verifyCliHostPtyCapability(root, config.state, { inherited: env, evidence: join(artifacts, 'early-cli-host-pty-capability.json') }); const pins = extractPins(await readFile(join(root, 'packages/run-spaceapp/templates/compose.yml'), 'utf8'), await readFile(join(root, 'Dockerfile'), 'utf8')); proof.expectedVersion = pins.opencode; proof.stage = 'install-private-pinned-package'; await run('npm', ['install', '--prefix', join(config.state, 'cli/vendor/opencode'), '--ignore-scripts', '--no-audit', '--no-fund', `opencode-ai@${pins.opencode}`], { cwd: root, env, output: text => process.stdout.write(text) }); diff --git a/scripts/dev-stack/pty-capability.mjs b/scripts/dev-stack/pty-capability.mjs index d740991b4..c0ace1df1 100644 --- a/scripts/dev-stack/pty-capability.mjs +++ b/scripts/dev-stack/pty-capability.mjs @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import { constants } from 'node:fs'; -import { lstat, mkdir, open, readFile, writeFile } from 'node:fs/promises'; +import { access, lstat, mkdir, open, readFile, writeFile } from 'node:fs/promises'; import { dirname, join, relative, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { confinedFile, fileHash } from './opencode-runtime.mjs'; import { run } from './resources.mjs'; @@ -68,19 +68,44 @@ export async function verifyPtyCapability(root, state, { inherited = {}, evidenc } finally { await mkdir(dirname(evidence), { recursive: true, mode: 0o700 }); await writeFile(evidence, JSON.stringify(proof, null, 2) + '\n', { mode: 0o600 }); } } -if (process.argv[1] && resolve(process.argv[1]) === self && process.argv[2] === '--probe') { +export async function verifyCliHostPtyCapability(root, state, { inherited = {}, evidence = join(state, 'logs', 'cli-host-pty-capability.json'), invoke = run } = {}) { + const selectedPath = process.platform === 'darwin' ? '/bin/stty' : '/usr/bin/stty'; + const executable = async file => { try { await access(file, constants.X_OK); return true; } catch { return false; } }; + const proof = { source: process.env.SPACE_DEV_CANDIDATE, capability: 'actual compiled CLI host zero-provider printf PTY', platform: process.platform, compiledEntry: 'packages/cli-host/dist/node-pty-spawn.js', stty: { selectedPath, selectedExecutable: await executable(selectedPath), historicalUsrBinExecutable: await executable('/usr/bin/stty') }, ok: false, modelInference: false }; + const home = join(state, 'cli-host-pty-capability'); let output = ''; + try { + assert.equal(proof.stty.selectedExecutable, true, 'The explicit native stty must already exist; no install or PATH fallback.'); + await confinedFile(root, join(root, proof.compiledEntry)); + await mkdir(home, { recursive: true, mode: 0o700 }); + const text = await invoke(process.execPath, [self, '--probe-cli-host', root, home], { cwd: root, env: { PATH: inherited.PATH || '/usr/local/bin:/usr/bin:/bin', HOME: home, TMPDIR: home, LANG: 'en_US.UTF-8', TERM: 'xterm-256color' }, timeout: 15000, output: chunk => { output = (output + chunk).slice(-4096); } }); + const result = JSON.parse(text.trim()); assert.equal(result.capability, 'actual-cli-host-printf'); assert.equal(result.ok, true); assert.equal(result.productionEntry, true); + proof.result = result; proof.ok = true; return proof; + } catch (error) { proof.error = { name: error.name, code: error.code ?? null, signal: error.signal ?? null }; proof.diagnostic = output.slice(-4096); throw error; + } finally { await mkdir(dirname(evidence), { recursive: true, mode: 0o700 }); await writeFile(evidence, JSON.stringify(proof, null, 2) + '\n', { mode: 0o600 }); } +} + +if (process.argv[1] && resolve(process.argv[1]) === self && ['--probe', '--probe-cli-host'].includes(process.argv[2])) { const root = resolve(process.argv[3]), home = resolve(process.argv[4]); + const cliHost = process.argv[2] === '--probe-cli-host', capability = cliHost ? 'actual-cli-host-printf' : 'node-pty-printf'; try { - const require = createRequire(join(root, 'package.json')), pty = require('node-pty'); - const term = pty.spawn('/bin/sh', ['-c', `printf '${marker}'`], { name: 'xterm-256color', cols: 80, rows: 24, cwd: home, env: { PATH: process.env.PATH, HOME: home, TMPDIR: home, TERM: 'xterm-256color' } }); + const options = { name: 'xterm-256color', cols: 80, rows: 24, cwd: home, env: { PATH: process.env.PATH, HOME: home, TMPDIR: home, TERM: 'xterm-256color' } }; + let term; + if (cliHost) { + const file = await confinedFile(root, join(root, 'packages/cli-host/dist/node-pty-spawn.js')); + const { spawnNodePty } = await import(pathToFileURL(file).href); + term = spawnNodePty({ command: '/bin/sh', args: ['-c', `printf '${marker}'`], ...options }); + } else { + const require = createRequire(join(root, 'package.json')), pty = require('node-pty'); + term = pty.spawn('/bin/sh', ['-c', `printf '${marker}'`], options); + } let output = ''; await new Promise((done, reject) => { const timer = setTimeout(() => { term.kill(); reject(new Error('Synthetic PTY timed out.')); }, 10000); term.onData(data => { output += data; }); term.onExit(({ exitCode }) => { clearTimeout(timer); if (exitCode === 0 && output === marker) done(); else reject(new Error('Synthetic PTY output/exit mismatch.')); }); }); - console.log(JSON.stringify({ capability: 'node-pty-printf', ok: true, exitCode: 0, exactOutput: true, modelInference: false })); + console.log(JSON.stringify({ capability, ok: true, exitCode: 0, exactOutput: true, productionEntry: cliHost, modelInference: false })); } catch (error) { - console.log(JSON.stringify({ capability: 'node-pty-printf', ok: false, error: { name: error.name, message: error.message.slice(0, 500) } })); process.exitCode = 1; + console.log(JSON.stringify({ capability, ok: false, error: { name: error.name, message: error.message.slice(0, 500) } })); process.exitCode = 1; } } diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs index 1bec8d871..e201cf4ef 100644 --- a/scripts/tests/dev-stack.test.mjs +++ b/scripts/tests/dev-stack.test.mjs @@ -12,9 +12,14 @@ import { confinedFile, nativePackage, opencodeEnvironment, resolveOpenCode, veri import { InteractiveTerminalProof, assertBrowserTerminal } from '../dev-stack/terminal-proof.mjs'; import { knownHelperRepair } from '../dev-stack/pty-capability.mjs'; import { isPublicExportPath } from '../public-export.mjs'; -import { assertBrowserRunning, eventually, safeBrowserStderr, typeTerminalFilter } from '../dev-stack/browser-proof.mjs'; +import { assertBrowserRunning, eventually, safeBrowserStderr, selectedBrowserDiagnostic, typeTerminalFilter } from '../dev-stack/browser-proof.mjs'; const root = fileURLToPath(new URL('../..', import.meta.url)); +test('post-CDP diagnostics retain selected failure state and redact endpoints without exposing request parameters', () => { + const selected = selectedBrowserDiagnostic({ profile: '/private/profile', tmpdir: '/private/tmp', binaryVersion: 'Synthetic Chrome', versionError: null, child: { exitCode: null, signalCode: 'SIGTRAP', env: { secret: 'must-not-appear' } }, startError: null, stderr: 'cookie=private-cookie ws://127.0.0.1/private-session', socket: { readyState: 3, url: 'must-not-appear' }, failedCdpMethod: 'Network.setCookie', params: { value: 'must-not-appear' } }, 'application-browser'); + assert.equal(selected.failedCdpMethod, 'Network.setCookie'); assert.equal(selected.phase, 'application-browser'); assert.equal(selected.cdpSocketState, 3); assert.equal(selected.signalCode, 'SIGTRAP'); + assert(!JSON.stringify(selected).includes('must-not-appear')); assert(!selected.stderr.includes('private-cookie')); assert(!selected.stderr.includes('private-session')); +}); const pins = extractPins(await readFile(join(root, 'packages/run-spaceapp/templates/compose.yml'), 'utf8'), await readFile(join(root, 'Dockerfile'), 'utf8')); async function sandbox(t) { const parent = await realpath(tmpdir()); From fafa02e7845d8605d4b992dad606574f102bace4 Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 17 Oct 2026 21:10:10 +0200 Subject: [PATCH 14/27] test(dev): verify current ANSI screens and early cookie capability --- docs/development-stack.md | 21 ++++- scripts/dev-stack/acceptance.mjs | 31 ++++--- scripts/dev-stack/browser-proof.mjs | 34 +++++++- scripts/dev-stack/terminal-proof.mjs | 66 +++++++++++++- scripts/tests/dev-stack.test.mjs | 125 ++++++++++++++++++++++++++- 5 files changed, 254 insertions(+), 23 deletions(-) diff --git a/docs/development-stack.md b/docs/development-stack.md index da7c28980..dd6a9a73f 100644 --- a/docs/development-stack.md +++ b/docs/development-stack.md @@ -116,7 +116,7 @@ permissions, pins, environment isolation, redaction, PID reuse and failed comman These tests do not establish a working Docker stack or browser. `.github/workflows/dev-stack.yml` prepares only standard public `ubuntu-latest` -and `macos-15-intel` runners and checks the exact source. Both run all 51 launcher +and `macos-15-intel` runners and checks the exact source. Both run all 61 launcher regressions, `npm run check`, `npm run build` and `npm run hygiene:preflight`. Ubuntu also runs the full unmodified `npm test` suite. Native macOS runs all 13 private-instance API regressions in the four `instance-storage`/`dev-*-storage` @@ -147,8 +147,14 @@ are not repaired or hidden; no installed/global dependency path is changed. The same early step uses the hosted browser's isolated blank page to record its actual version and long-character-event behavior, then verifies exact delivery of the synthetic filter through awaited single-character events. It uses no -app/account session, paste, Enter, provider or model. The actual terminal proof -uses that same bounded typing method while retaining every live/menu/filter check. +app/account session, paste, Enter, provider or model. +Before typing, the same private blank-page browser sets/reads/deletes a synthetic +loopback HttpOnly/SameSite=Lax cookie through CDP, checking its exact attributes +and deletion readback. No URL is fetched or navigated. Failure retains its phase +and selected pre-cleanup diagnostics; cleanup cannot replace the original error. +This early capability does not replace normal application setup/authentication. +The actual terminal proof uses that same bounded typing method while retaining +every live/menu/filter check. Its capability state uses a short random name and refuses an existing directory; full-size ownership/secret nonces remain unchanged. This avoids the Linux Chrome singleton socket's small Unix path budget while keeping HOME/TMPDIR/profile in @@ -170,6 +176,15 @@ command menu, a unique local filter produces the matching empty-result state, Escape dismisses it, and Ctrl+P opens a fresh menu again. Only new output after each input counts. No Enter, provider login or model prompt is sent. The private provider HOME/XDG roots do not replace the pane's requested working directory. +Screen predicates use the already locked headless terminal renderer, matching +the canonical API projection. They await the complete observed ANSI stream at +the current session's generation-bound geometry, latched at the pre-input +baseline. Truncated/oversized replay, unknown geometry or any later dimension/ +generation change fails explicitly. A known unfiltered menu must precede the +fresh challenge; current full value and empty-result state must follow new output. +Dismissal must remove the dialog/challenge before reopening. Historical or erased +text never counts. Selected synthetic frames are retained with cross-event secret +redaction, projected transition screens and a best-effort failure screenshot. It changes a CSS canary and observes real HMR without a navigation, then changes API/shared-package source and observes rebuilt output plus new API/worker processes and restored readiness. All changed diff --git a/scripts/dev-stack/acceptance.mjs b/scripts/dev-stack/acceptance.mjs index 95bacff8e..86a2ed201 100644 --- a/scripts/dev-stack/acceptance.mjs +++ b/scripts/dev-stack/acceptance.mjs @@ -68,18 +68,22 @@ try { // Exercise real terminal keyboard input through the actual matching browser pane. // No Enter or provider/model command is sent: only a local palette and filter. const deadline = Date.now() + 120000; - const waitInteractive = async (predicate, label) => eventually(async () => { interactive.assertHealthy(); return interactive.live(await currentSession()) && predicate(); }, label, Math.max(1, deadline - Date.now())); - let barrier = interactive.barrier(); await browser.key('Ctrl+P'); - await waitInteractive(() => interactive.menu(barrier), 'Fresh pinned OpenCode command menu after Ctrl+P'); + const waitInteractive = async (predicate, label) => eventually(async () => { interactive.assertHealthy(); const response = await currentSession(); return interactive.live(response) && await predicate(response); }, label, Math.max(1, deadline - Date.now())); + let transition = await waitInteractive(response => interactive.beginScreenTransition('open', response), 'Known closed-menu pre-input screen'); + await browser.key('Ctrl+P'); + await waitInteractive(response => interactive.screenTransition(transition, response), 'Fresh pinned OpenCode command menu after Ctrl+P'); await browser.screenshot('opencode-command-menu.png'); const challenge = `spaceproof${randomBytes(8).toString('hex')}`; - barrier = interactive.barrier(); await browser.key('text', challenge); - await waitInteractive(() => interactive.challenge(barrier, challenge), 'Fresh unique palette filter response'); + transition = await waitInteractive(response => interactive.beginScreenTransition('filter', response, challenge), 'Known unfiltered menu without the new challenge'); + await browser.key('text', challenge); + await waitInteractive(response => interactive.screenTransition(transition, response), 'Fresh unique palette filter response on the current terminal screen'); await browser.screenshot('opencode-filter-response.png'); - barrier = interactive.barrier(); await browser.key('Escape'); - await waitInteractive(() => interactive.sequence > barrier, 'Fresh terminal response to palette dismissal'); - barrier = interactive.barrier(); await browser.key('Ctrl+P'); - await waitInteractive(() => interactive.menu(barrier), 'Fresh command menu after dismissal and reopening'); + transition = await waitInteractive(response => interactive.beginScreenTransition('dismiss', response, challenge), 'Known current filtered menu before dismissal'); + await browser.key('Escape'); + await waitInteractive(response => interactive.screenTransition(transition, response), 'Fresh screen with the menu and challenge dismissed'); + transition = await waitInteractive(response => interactive.beginScreenTransition('open', response), 'Known closed-menu screen before reopening'); + await browser.key('Ctrl+P'); + await waitInteractive(response => interactive.screenTransition(transition, response), 'Fresh command menu after dismissal and reopening'); const browserState = await browser.terminalState(); assertBrowserTerminal(browserState, pane.id); await browser.screenshot('opencode-pane.png'); interactive.seal(await currentSession()); @@ -123,6 +127,11 @@ try { } catch (error) { // Selected synthetic state only. Never serialize a session response, ticket, // cookie, websocket URL, environment or real provider/owner credentials. - if (interactive) await writeFile(join(artifacts, 'opencode-failure.json'), JSON.stringify({ source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, native, terminal: interactive.diagnostic([cookie, cookie.split('=').slice(1).join('='), password, token, ticketToken, csrf?.csrfToken]), browser: await browser?.terminalState().catch(() => null), browserStartup: error.browserStartup, browserRuntime: error.browserRuntime || browser?.diagnostic('interactive-proof') }, null, 2) + '\n', { mode: 0o600 }); + const failureBrowser = error.browserRuntime || browser?.diagnostic('interactive-proof'); + if (browser) await browser.failureScreenshot().catch(() => {}); + if (interactive) await writeFile(join(artifacts, 'opencode-failure.json'), JSON.stringify({ source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, native, terminal: interactive.diagnostic([cookie, cookie.split('=').slice(1).join('='), password, token, ticketToken, csrf?.csrfToken]), browser: await browser?.terminalState().catch(() => null), browserStartup: error.browserStartup, browserRuntime: failureBrowser }, null, 2) + '\n', { mode: 0o600 }); throw error; -} finally { terminal?.close(); await browser?.close(); } +} finally { + try { if (interactive) await writeFile(join(artifacts, 'opencode-terminal-stream.json'), JSON.stringify({ source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, ...interactive.streamEvidence([cookie, cookie.split('=').slice(1).join('='), password, token, ticketToken, csrf?.csrfToken]) }, null, 2) + '\n', { mode: 0o600 }); } + finally { terminal?.close(); await browser?.close(); } +} diff --git a/scripts/dev-stack/browser-proof.mjs b/scripts/dev-stack/browser-proof.mjs index f74a896f6..a567b639e 100644 --- a/scripts/dev-stack/browser-proof.mjs +++ b/scripts/dev-stack/browser-proof.mjs @@ -18,6 +18,28 @@ export function safeBrowserStderr(text) { export function selectedBrowserDiagnostic({ profile, tmpdir, binaryVersion, versionError, child, startError, stderr, socket, failedCdpMethod }, phase) { return { platform: process.platform, phase, profile, tmpdir, binaryVersion, versionError, exitCode: child.exitCode, signalCode: child.signalCode, startErrorCode: startError?.code ?? null, cdpSocketState: socket?.readyState ?? null, failedCdpMethod: failedCdpMethod ?? null, stderr: safeBrowserStderr(stderr) }; } +export async function syntheticCookieCapability(call, value, diagnostic = () => null) { + assert(typeof value === 'string' && /^[a-z0-9]{1,64}$/.test(value), 'Only the synthetic local challenge is permitted.'); + const url = 'http://127.0.0.1/', name = 'spaceapp-dev-proof'; + let phase = 'enable', failure; + try { + await call('Network.enable'); phase = 'set'; + assert.equal((await call('Network.setCookie', { url, name, value, httpOnly: true, sameSite: 'Lax' })).success, true, 'Synthetic cookie set must report success.'); + phase = 'get'; const jar = await call('Network.getCookies', { urls: [url] }), matching = jar.cookies?.filter(cookie => cookie.name === name); + assert.equal(matching?.length, 1, 'Exactly one synthetic cookie must be read back.'); + assert(matching[0].value === value && matching[0].httpOnly === true && matching[0].sameSite === 'Lax' && matching[0].domain === '127.0.0.1', 'Synthetic cookie value and native attributes must match.'); + } catch (error) { error.browserRuntime = diagnostic(`synthetic-cookie-${phase}`); failure = error; } + try { + phase = 'delete'; await call('Network.deleteCookies', { url, name }); + phase = 'delete-readback'; const jar = await call('Network.getCookies', { urls: [url] }); + assert(Array.isArray(jar.cookies) && !jar.cookies.some(cookie => cookie.name === name), 'Synthetic cookie deletion must be read back.'); + } catch (error) { + if (failure) failure.cookieCleanupError = { name: error.name, phase }; + else { error.browserRuntime = diagnostic(`synthetic-cookie-${phase}`); failure = error; } + } + if (failure) throw failure; + return { syntheticLoopbackOnly: true, httpOnly: true, sameSite: 'Lax', exactSetGetMatch: true, deletionReadback: true, navigationOrFetchSent: false, valueRecorded: false }; +} export async function typeTerminalFilter(call, text) { assert(typeof text === 'string' && /^[a-z0-9]{1,64}$/.test(text), 'Only the synthetic local palette filter is permitted.'); @@ -72,11 +94,14 @@ async function openProofBrowser(state) { } } export async function browserKeyboardCapability({ state, artifacts, challenge }) { - const proof = { source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, challengeLength: challenge.length, baseline: null, perCharacter: null, ok: false }; + const proof = { source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, challengeLength: challenge.length, phase: 'browser-startup', cookie: null, baseline: null, perCharacter: null, ok: false }; let browser; try { browser = await openProofBrowser(state); const { call, evaluate } = browser; proof.browser = browser.version; + proof.phase = 'synthetic-loopback-cookie'; + proof.cookie = await syntheticCookieCapability(call, challenge, browser.diagnostic); + proof.phase = 'blank-page-keyboard'; // Task-owned about:blank only; no account, external page, app session or model. await call('Page.enable'); await eventually(() => evaluate('Boolean(document.body)'), 'Synthetic blank-page readiness', 10000); @@ -93,8 +118,8 @@ export async function browserKeyboardCapability({ state, artifacts, challenge }) await typeTerminalFilter(call, challenge); assert.equal(await evaluate(`document.getElementById('filter').value === ${JSON.stringify(challenge)}`), true, 'Actual Chrome must receive every serial character in exact order.'); proof.perCharacter = { eventCount: challenge.length, maxUtf16UnitsPerEvent: 1, deliveredExactText: true, enterOrPasteSent: false }; - proof.ok = true; return proof; - } catch (error) { proof.error = { name: error.name, message: error.message.slice(0, 300) }; if (error.browserStartup) proof.startup = error.browserStartup; else if (browser) proof.runtime = browser.diagnostic('early-blank-page-keyboard'); throw error; + proof.phase = 'complete'; proof.ok = true; return proof; + } catch (error) { proof.error = { name: error.name, message: error.message.slice(0, 300) }; if (error.browserStartup) proof.startup = error.browserStartup; else if (browser) proof.runtime = error.browserRuntime || browser.diagnostic(proof.phase); if (error.cookieCleanupError) proof.cookieCleanupError = error.cookieCleanupError; throw error; } finally { await mkdir(artifacts, { recursive: true }); await writeFile(join(artifacts, 'early-browser-keyboard-capability.json'), JSON.stringify(proof, null, 2) + '\n', { mode: 0o600 }); await browser?.close(); } } export async function browserProof({ state, origin, cookie, artifacts, paneId }) { @@ -120,6 +145,7 @@ export async function browserProof({ state, origin, cookie, artifacts, paneId }) await call('Input.dispatchKeyEvent', { type: 'keyDown', ...params }); await call('Input.dispatchKeyEvent', { type: 'keyUp', ...params }); } }; - return { evaluate, terminalState, key, diagnostic, async screenshot(name) { assertBrowserTerminal(await terminalState(), paneId); await writeFile(join(artifacts, name), Buffer.from((await call('Page.captureScreenshot', { format: 'png' })).data, 'base64')); }, close }; + const capture = async name => writeFile(join(artifacts, name), Buffer.from((await call('Page.captureScreenshot', { format: 'png' })).data, 'base64')); + return { evaluate, terminalState, key, diagnostic, async screenshot(name) { assertBrowserTerminal(await terminalState(), paneId); await capture(name); }, async failureScreenshot() { await capture('opencode-failure.png'); }, close }; } catch (error) { error.browserRuntime = diagnostic('application-browser'); await close(); throw error; } } diff --git a/scripts/dev-stack/terminal-proof.mjs b/scripts/dev-stack/terminal-proof.mjs index 1e710cf78..3de5c4ac7 100644 --- a/scripts/dev-stack/terminal-proof.mjs +++ b/scripts/dev-stack/terminal-proof.mjs @@ -1,11 +1,14 @@ import assert from 'node:assert/strict'; import { stripVTControlCharacters } from 'node:util'; +import headless from '@xterm/headless'; const menuTitles = ['Switch session', 'New session', 'Switch model', 'Switch agent', 'Connect provider', 'View status', 'Switch theme', 'Help', 'Exit the app']; +const maximumReplayBytes = 8 * 1024 * 1024; +const fullMenu = text => text.includes('Commands') && menuTitles.filter(title => text.includes(title)).length >= 2; // Exact 1.18.4 references are recorded in the validation evidence. A brand name, // rendered terminal rectangle, or historical RUNNING flag is never UI proof. export class InteractiveTerminalProof { - constructor(sessionId) { this.sessionId = sessionId; this.wsState = 'CONNECTING'; this.status = null; this.events = []; this.sequence = 0; this.fatal = null; this.sealed = false; this.session = null; } + constructor(sessionId) { this.sessionId = sessionId; this.wsState = 'CONNECTING'; this.status = null; this.events = []; this.sequence = 0; this.fatal = null; this.sealed = false; this.session = null; this.frames = []; this.replayBytes = 0; this.completeReplay = false; this.geometry = null; this.screenRecords = []; this.activeTransition = null; } fail(message) { if (!this.sealed && !this.fatal) this.fatal = message; } open() { this.wsState = 'OPEN'; } error(code = 'WEBSOCKET_ERROR') { this.fail(`Terminal WebSocket error (${String(code).slice(0, 80)}).`); } @@ -13,10 +16,14 @@ export class InteractiveTerminalProof { message(event) { if (this.sealed) return; if (event.type === 'error') this.fail(`Terminal error (${String(event.code || 'UNKNOWN').slice(0, 80)}).`); - if (event.type === 'status') { this.status = event.status; this.exitCode = event.exitCode ?? null; if (['ERROR', 'EXITED'].includes(event.status) || this.exitCode !== null) this.fail(`Terminal status/exit evidence ${event.status}.`); } + if (event.type === 'status') { this.status = event.status; this.exitCode = event.exitCode ?? null; if (event.replayContinuity === 'TRUNCATED') this.fail('Terminal replay is truncated; a tail cannot prove the current screen.'); else if (event.replayContinuity === 'COMPLETE') this.completeReplay = true; if (['ERROR', 'EXITED'].includes(event.status) || this.exitCode !== null) this.fail(`Terminal status/exit evidence ${event.status}.`); } if (event.type === 'output') { - const text = stripVTControlCharacters(String(event.data)); + const data = String(event.data), size = Buffer.byteLength(data, 'utf8'); + if (this.replayBytes + size > maximumReplayBytes) { this.fail('Complete terminal replay exceeds the projection limit; refusing a partial screen.'); return; } + this.replayBytes += size; + const text = stripVTControlCharacters(data); this.events.push({ sequence: ++this.sequence, text }); + this.frames.push({ sequence: this.sequence, data }); while (this.events.length > 256) this.events.shift(); const recent = this.events.map(item => item.text).join('').slice(-8000); if (/MODULE_NOT_FOUND|Cannot find module|postinstall[^\n]*not|CLI process exited|Pinned state-local OpenCode could not start/i.test(recent)) this.fail('Terminal output reports a CLI bootstrap/exit failure.'); @@ -36,7 +43,58 @@ export class InteractiveTerminalProof { fresh(after) { return this.events.filter(event => event.sequence > after).map(event => event.text).join(''); } menu(after) { this.assertHealthy(); const text = this.fresh(after); return text.includes('Commands') && menuTitles.filter(title => text.includes(title)).length >= 2; } challenge(after, value) { this.assertHealthy(); const text = this.fresh(after); return text.includes(value) && text.includes('No results found'); } - seal(response) { assert.equal(this.live(response), true, 'UI success requires a final fresh live API/WS session.'); this.sealed = true; } + async screen(response) { + if (!this.live(response)) return null; + const geometry = response.session.terminalGeometry; + if (!geometry || typeof geometry.generationId !== 'string' || !geometry.generationId.length || geometry.generationId.length > 160 || !Number.isInteger(geometry.cols) || geometry.cols < 2 || geometry.cols > 400 || !Number.isInteger(geometry.rows) || geometry.rows < 2 || geometry.rows > 200) this.fail('A current generation-bound terminal geometry is required.'); + this.assertHealthy(); + if (!this.completeReplay) this.fail('A complete terminal replay has not been established.'); + if (this.geometry && (geometry.generationId !== this.geometry.generationId || geometry.cols !== this.geometry.cols || geometry.rows !== this.geometry.rows)) this.fail('Terminal generation or geometry changed after the pre-input baseline.'); + this.assertHealthy(); + const sequence = this.sequence, terminal = new headless.Terminal({ cols: geometry.cols, rows: geometry.rows, scrollback: 0, allowProposedApi: true }); + try { + // Match the canonical room-terminal-screen projection: full observed ANSI + // replay, awaited write callback and active screen, never a stripped tail. + await new Promise(done => terminal.write(this.frames.map(frame => frame.data).join(''), done)); + const buffer = terminal.buffer.active, lines = []; + for (let index = 0; index < buffer.length; index++) { + const line = buffer.getLine(index), text = line?.translateToString(!buffer.getLine(index + 1)?.isWrapped) || ''; + if (line?.isWrapped && lines.length) lines[lines.length - 1] += text; else lines.push(text); + } + this.assertHealthy(); + if (this.sequence !== sequence) return null; + this.lastScreen = { sequence, geometry: { generationId: geometry.generationId, cols: geometry.cols, rows: geometry.rows }, text: lines.join('\n') }; + return this.lastScreen; + } finally { terminal.dispose(); } + } + async beginScreenTransition(kind, response, challenge = null) { + assert(['open', 'filter', 'dismiss'].includes(kind), 'Unknown terminal transition.'); + if (kind !== 'open') assert(typeof challenge === 'string' && /^[a-z0-9]{1,64}$/.test(challenge), 'A fresh synthetic challenge is required.'); + assert.equal(this.activeTransition, null, 'A previous terminal transition remains unverified.'); + const screen = await this.screen(response); if (!screen) return null; + const filtered = screen.text.includes('Commands') && screen.text.includes('No results found') && challenge && screen.text.includes(challenge); + const valid = kind === 'open' ? !screen.text.includes('Commands') && !screen.text.includes('No results found') : kind === 'filter' ? fullMenu(screen.text) && !screen.text.includes(challenge) && !screen.text.includes('No results found') : filtered; + if (!valid) return null; + this.geometry ??= screen.geometry; + const token = Object.freeze({ kind, challenge, sequence: screen.sequence, geometry: Object.freeze({ ...screen.geometry }) }); + this.screenRecords.push({ phase: `before-${kind}`, ...screen }); this.activeTransition = token; return token; + } + async screenTransition(token, response) { + assert.equal(token, this.activeTransition, 'Only the current pre-input transition can be verified.'); + const screen = await this.screen(response); if (!screen || screen.sequence <= token.sequence) return null; + const valid = token.kind === 'open' ? fullMenu(screen.text) && !screen.text.includes('No results found') : token.kind === 'filter' ? screen.text.includes('Commands') && screen.text.includes(token.challenge) && screen.text.includes('No results found') : !screen.text.includes('Commands') && !screen.text.includes(token.challenge) && !screen.text.includes('No results found'); + if (!valid) return null; + this.screenRecords.push({ phase: `after-${token.kind}`, ...screen }); this.activeTransition = null; return screen; + } + streamEvidence(redactions = []) { + const clean = value => { let text = value; for (const secret of redactions.filter(Boolean).sort((a, b) => b.length - a.length)) text = text.split(secret).join('*'.repeat(secret.length)); return text.replace(/[?&](?:token|sessionToken|access_token)=[^\s&]+/gi, match => '*'.repeat(match.length)); }; + // Redact the complete stream before splitting at its original event offsets, + // so a generated secret split across WebSocket events cannot escape masking. + const content = clean(this.frames.map(frame => frame.data).join('')); let offset = 0; + const frames = this.frames.map(frame => { const data = content.slice(offset, offset + frame.data.length); offset += frame.data.length; return { sequence: frame.sequence, data }; }); + return { expectedSessionId: this.sessionId, completeReplay: this.completeReplay, originalReplayBytes: this.replayBytes, maximumReplayBytes, geometry: this.geometry, frames, screens: this.screenRecords.map(screen => ({ ...screen, text: clean(screen.text) })), currentScreen: this.lastScreen ? { ...this.lastScreen, text: clean(this.lastScreen.text) } : null }; + } + seal(response) { assert.equal(this.live(response), true, 'UI success requires a final fresh live API/WS session.'); assert.equal(this.activeTransition, null, 'A terminal transition remains unverified.'); if (this.geometry) assert.deepEqual(response.session.terminalGeometry, this.geometry, 'Final terminal geometry must match the pre-input baseline.'); this.sealed = true; } diagnostic(redactions = []) { let excerpt = this.events.map(event => event.text).join('').slice(-6000); for (const secret of redactions.filter(Boolean).sort((a, b) => b.length - a.length)) excerpt = excerpt.split(secret).join('[redacted]'); diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs index e201cf4ef..39d03fbc8 100644 --- a/scripts/tests/dev-stack.test.mjs +++ b/scripts/tests/dev-stack.test.mjs @@ -12,9 +12,49 @@ import { confinedFile, nativePackage, opencodeEnvironment, resolveOpenCode, veri import { InteractiveTerminalProof, assertBrowserTerminal } from '../dev-stack/terminal-proof.mjs'; import { knownHelperRepair } from '../dev-stack/pty-capability.mjs'; import { isPublicExportPath } from '../public-export.mjs'; -import { assertBrowserRunning, eventually, safeBrowserStderr, selectedBrowserDiagnostic, typeTerminalFilter } from '../dev-stack/browser-proof.mjs'; +import { assertBrowserRunning, eventually, safeBrowserStderr, selectedBrowserDiagnostic, syntheticCookieCapability, typeTerminalFilter } from '../dev-stack/browser-proof.mjs'; const root = fileURLToPath(new URL('../..', import.meta.url)); +function syntheticCookieFixture({ mutate = cookie => cookie, retainAfterDelete = false } = {}) { + let cookie; const calls = []; + const call = async (method, params) => { + calls.push({ method, params }); + if (method === 'Network.enable') return {}; + if (method === 'Network.setCookie') { cookie = mutate({ ...params, domain: '127.0.0.1' }); return { success: true }; } + if (method === 'Network.getCookies') return { cookies: cookie ? [cookie] : [] }; + if (method === 'Network.deleteCookies') { if (!retainAfterDelete) cookie = null; return {}; } + throw new Error('Unexpected synthetic cookie command'); + }; + return { call, calls }; +} +test('early cookie capability sets and reads exact synthetic HttpOnly Lax attributes then verifies deletion', async () => { + const fixture = syntheticCookieFixture(), value = 'spaceproof0123456789abcdef'; + const proof = await syntheticCookieCapability(fixture.call, value); + assert.deepEqual(fixture.calls.map(x => x.method), ['Network.enable', 'Network.setCookie', 'Network.getCookies', 'Network.deleteCookies', 'Network.getCookies']); + assert.deepEqual(fixture.calls[1].params, { url: 'http://127.0.0.1/', name: 'spaceapp-dev-proof', value, httpOnly: true, sameSite: 'Lax' }); + assert.equal(proof.exactSetGetMatch, true); assert.equal(proof.deletionReadback, true); assert.equal(proof.navigationOrFetchSent, false); assert.equal(proof.valueRecorded, false); +}); +test('wrong stored cookie value or attributes fail while the synthetic cookie is still deleted', async () => { + for (const override of [{ value: 'wrong' }, { httpOnly: false }, { sameSite: 'None' }, { domain: 'other.invalid' }]) { + const fixture = syntheticCookieFixture({ mutate: cookie => ({ ...cookie, ...override }) }); + await assert.rejects(syntheticCookieCapability(fixture.call, 'spaceproof0123456789abcdef'), /value and native attributes/); + assert.equal(fixture.calls.filter(x => x.method === 'Network.deleteCookies').length, 1); + assert.equal(fixture.calls.at(-1).method, 'Network.getCookies'); + } +}); +test('cookie deletion requires an empty readback and never converts a retained cookie into success', async () => { + const fixture = syntheticCookieFixture({ retainAfterDelete: true }); + await assert.rejects(syntheticCookieCapability(fixture.call, 'spaceproof0123456789abcdef'), /deletion must be read back/); + assert.equal(fixture.calls.filter(x => x.method === 'Network.setCookie').length, 1); +}); +test('cookie cleanup retains the original timeout and pre-cleanup failure snapshot without a retry or parameters', async () => { + const original = new Error('CDP Network.setCookie timed out.'), calls = []; + const call = async method => { calls.push(method); if (method === 'Network.enable') return {}; if (method === 'Network.setCookie') throw original; throw new TypeError('synthetic cleanup failed'); }; + await assert.rejects(syntheticCookieCapability(call, 'spaceproof0123456789abcdef', phase => ({ phase, failedCdpMethod: 'Network.setCookie' })), error => error === original); + assert.deepEqual(calls, ['Network.enable', 'Network.setCookie', 'Network.deleteCookies']); + assert.deepEqual(original.browserRuntime, { phase: 'synthetic-cookie-set', failedCdpMethod: 'Network.setCookie' }); + assert.deepEqual(original.cookieCleanupError, { name: 'TypeError', phase: 'delete' }); +}); test('post-CDP diagnostics retain selected failure state and redact endpoints without exposing request parameters', () => { const selected = selectedBrowserDiagnostic({ profile: '/private/profile', tmpdir: '/private/tmp', binaryVersion: 'Synthetic Chrome', versionError: null, child: { exitCode: null, signalCode: 'SIGTRAP', env: { secret: 'must-not-appear' } }, startError: null, stderr: 'cookie=private-cookie ws://127.0.0.1/private-session', socket: { readyState: 3, url: 'must-not-appear' }, failedCdpMethod: 'Network.setCookie', params: { value: 'must-not-appear' } }, 'application-browser'); assert.equal(selected.failedCdpMethod, 'Network.setCookie'); assert.equal(selected.phase, 'application-browser'); assert.equal(selected.cdpSocketState, 3); assert.equal(selected.signalCode, 'SIGTRAP'); @@ -465,3 +505,86 @@ test('a signalled Chrome fails immediately and bounded startup diagnostics redac const text = safeBrowserStderr('x'.repeat(8000) + ' DevTools listening on ws://127.0.0.1:1234/devtools/browser/private-endpoint token=private-token\n'); assert(text.length <= 6000); assert(!text.includes('private-endpoint')); assert(!text.includes('private-token')); assert(!text.includes('ws://')); }); + +const screenGeometry = { generationId: 'synthetic-host-generation', cols: 100, rows: 20 }; +const screenSession = (geometry = screenGeometry) => ({ session: { ...liveSession().session, terminalGeometry: geometry } }); +const menuFrame = '\x1b[2J\x1b[HCommands\r\nSearch: \r\nSwitch model\r\nConnect provider'; +const closedFrame = '\x1b[2J\x1b[HAsk anything...\r\nctrl+p commands'; +function screenProof() { const proof = liveProof(); proof.message({ type: 'status', status: 'RUNNING', replayContinuity: 'COMPLETE' }); return proof; } + +test('complete ANSI screen projection proves fresh menu filter dismissal and reopen while the old contiguous predicate rejects redraw deltas', async () => { + const proof = screenProof(), response = screenSession(), value = 'spaceproof0123456789abcdef'; + proof.message({ type: 'output', data: closedFrame }); + let transition = await proof.beginScreenTransition('open', response); + assert.equal(await proof.screenTransition(transition, response), null, 'No output after the input barrier cannot pass.'); + proof.message({ type: 'output', data: menuFrame }); assert(await proof.screenTransition(transition, response)); + transition = await proof.beginScreenTransition('filter', response, value); const barrier = transition.sequence; + // Synthetic ANSI cursor-addressing control, not a recovered historical frame. + for (const data of ['\x1b[2;9Hspace', '\x1b[3;1H\x1b[JNo results found', '\x1b[2;1', '4Hproof0123', '\x1b[5;1Hunrelated redraw', '\x1b[2;23H456789abcdef']) proof.message({ type: 'output', data }); + assert.equal(proof.challenge(barrier, value), false, 'The historical stripped-text predicate cannot reconstruct these deltas.'); + const filtered = await proof.screenTransition(transition, response); assert(filtered?.text.includes(value)); assert(filtered.text.includes('No results found')); + transition = await proof.beginScreenTransition('dismiss', response, value); + proof.message({ type: 'output', data: closedFrame }); assert(await proof.screenTransition(transition, response)); + transition = await proof.beginScreenTransition('open', response); + proof.message({ type: 'output', data: menuFrame }); assert(await proof.screenTransition(transition, response)); + await assert.rejects(proof.screenTransition(transition, response), /current pre-input transition/); + proof.seal(response); assert.equal(proof.sealed, true); +}); + +test('a stale preexisting or wrong current challenge cannot satisfy the projected screen proof', async () => { + const value = 'spaceproof0123456789abcdef', response = screenSession(), stale = screenProof(); + stale.message({ type: 'output', data: `${menuFrame}\r\n${value}` }); + assert.equal(await stale.beginScreenTransition('filter', response, value), null); + stale.message({ type: 'output', data: '\x1b[8;1Hunrelated fresh output' }); + assert.equal(await stale.beginScreenTransition('filter', response, value), null); + const wrong = screenProof(); wrong.message({ type: 'output', data: menuFrame }); + const transition = await wrong.beginScreenTransition('filter', response, value); + wrong.message({ type: 'output', data: '\x1b[2J\x1b[HCommands\r\nspaceproof0123456789abcdeg\r\nNo results found' }); + assert.equal(await wrong.screenTransition(transition, response), null); + wrong.message({ type: 'output', data: '\x1b[8;1Hunrelated fresh output' }); + assert.equal(await wrong.screenTransition(transition, response), null); + wrong.message({ type: 'output', data: `\x1b[2J\x1b[HCommands\r\n${value}\r\nNo results found` }); + wrong.message({ type: 'output', data: '\x1b[2;1H\x1b[2Kwrong-current-value' }); + assert.equal(wrong.challenge(transition.sequence, value), true, 'The historical concatenated predicate accepts a value already erased from the screen.'); + assert.equal(await wrong.screenTransition(transition, response), null, 'Only the current visible full challenge can pass.'); +}); + +test('projection refuses missing invalid and post-baseline changed generation or geometry', async () => { + for (const geometry of [null, { ...screenGeometry, generationId: '' }, { ...screenGeometry, cols: 0 }, { ...screenGeometry, cols: 401 }, { ...screenGeometry, rows: 201 }, { ...screenGeometry, rows: 2.5 }]) { + const proof = screenProof(); proof.message({ type: 'output', data: closedFrame }); + await assert.rejects(proof.screen(screenSession(geometry)), /generation-bound terminal geometry/); + } + for (const change of [{ generationId: 'different-host-generation' }, { cols: 101 }, { rows: 21 }]) { + const proof = screenProof(); proof.message({ type: 'output', data: closedFrame }); + await proof.beginScreenTransition('open', screenSession()); proof.message({ type: 'output', data: menuFrame }); + await assert.rejects(proof.screen(screenSession({ ...screenGeometry, ...change })), /generation or geometry changed/); + } +}); + +test('missing truncated or oversized replay fails closed instead of projecting a retained tail', async () => { + const missing = liveProof(); missing.message({ type: 'output', data: menuFrame }); + await assert.rejects(missing.screen(screenSession()), /complete terminal replay/); + const truncated = screenProof(); truncated.message({ type: 'status', status: 'RUNNING', replayContinuity: 'TRUNCATED' }); + truncated.message({ type: 'status', status: 'RUNNING', replayContinuity: 'COMPLETE' }); + await assert.rejects(truncated.screen(screenSession()), /replay is truncated/); + const oversized = screenProof(); oversized.message({ type: 'output', data: 'x'.repeat(8 * 1024 * 1024 + 1) }); + await assert.rejects(oversized.screen(screenSession()), /exceeds the projection limit/); + assert.equal(oversized.frames.length, 0); +}); + +test('an error or close during awaited screen projection cannot approve a live transition', async () => { + for (const fail of [proof => proof.error('SYNTHETIC_ERROR'), proof => proof.close(1006), proof => proof.message({ type: 'status', status: 'EXITED', exitCode: 0 })]) { + const proof = screenProof(); proof.message({ type: 'output', data: closedFrame }); + const projecting = proof.screen(screenSession()); fail(proof); + await assert.rejects(projecting); + } +}); + +test('retained full synthetic frames redact generated secrets across event boundaries without changing sequence offsets', () => { + const proof = screenProof(); + proof.message({ type: 'output', data: 'before-private-' }); proof.message({ type: 'output', data: 'cookie-after ?tok' }); proof.message({ type: 'output', data: 'en=private-query\n' }); + const value = proof.streamEvidence(['private-cookie']), text = value.frames.map(frame => frame.data).join(''); + assert(!text.includes('private-cookie')); assert(!text.includes('private-query')); + assert.deepEqual(value.frames.map(frame => [frame.sequence, frame.data.length]), proof.frames.map(frame => [frame.sequence, frame.data.length])); + assert.equal(value.completeReplay, true); +}); From 19c1286175b426c9c0a3716630db8e77f05223ab Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 17 Oct 2026 21:34:13 +0200 Subject: [PATCH 15/27] test(dev): isolate the Darwin CI browser keychain --- docs/development-stack.md | 10 +++++++++- scripts/dev-stack/browser-proof.mjs | 16 +++++++++++----- scripts/tests/dev-stack.test.mjs | 12 +++++++++++- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/docs/development-stack.md b/docs/development-stack.md index dd6a9a73f..668272595 100644 --- a/docs/development-stack.md +++ b/docs/development-stack.md @@ -116,7 +116,7 @@ permissions, pins, environment isolation, redaction, PID reuse and failed comman These tests do not establish a working Docker stack or browser. `.github/workflows/dev-stack.yml` prepares only standard public `ubuntu-latest` -and `macos-15-intel` runners and checks the exact source. Both run all 61 launcher +and `macos-15-intel` runners and checks the exact source. Both run all 62 launcher regressions, `npm run check`, `npm run build` and `npm run hygiene:preflight`. Ubuntu also runs the full unmodified `npm test` suite. Native macOS runs all 13 private-instance API regressions in the four `instance-storage`/`dev-*-storage` @@ -153,6 +153,14 @@ loopback HttpOnly/SameSite=Lax cookie through CDP, checking its exact attributes and deletion readback. No URL is fetched or navigated. Failure retains its phase and selected pre-cleanup diagnostics; cleanup cannot replace the original error. This early capability does not replace normal application setup/authentication. +Only on Darwin with CI exactly `true`, that fresh private proof-browser process +requests Chromium's documented `--use-mock-keychain` test switch. It selects the +in-process test keychain instead of the real macOS keychain, avoiding test dialogs. +Linux and non-CI argument lists stay unchanged; no owner-browser setting, OS +keychain, application authentication or other browser flag is changed. Receipts +disclose this configuration. Earlier Mac cookie timeouts remain cause-unconfirmed; +the exact cookie roundtrip and all actual application/TUI/lifecycle gates still +must pass at the new source. The actual terminal proof uses that same bounded typing method while retaining every live/menu/filter check. Its capability state uses a short random name and refuses an existing directory; diff --git a/scripts/dev-stack/browser-proof.mjs b/scripts/dev-stack/browser-proof.mjs index a567b639e..df67f0388 100644 --- a/scripts/dev-stack/browser-proof.mjs +++ b/scripts/dev-stack/browser-proof.mjs @@ -15,8 +15,13 @@ export function assertBrowserRunning(child, startError) { export function safeBrowserStderr(text) { return String(text).replace(/wss?:\/\/[^\s"']+/gi, '[redacted websocket endpoint]').replace(/((?:token|password|secret|cookie)=)[^\s"']+/gi, '$1[redacted]').slice(-6000); } -export function selectedBrowserDiagnostic({ profile, tmpdir, binaryVersion, versionError, child, startError, stderr, socket, failedCdpMethod }, phase) { - return { platform: process.platform, phase, profile, tmpdir, binaryVersion, versionError, exitCode: child.exitCode, signalCode: child.signalCode, startErrorCode: startError?.code ?? null, cdpSocketState: socket?.readyState ?? null, failedCdpMethod: failedCdpMethod ?? null, stderr: safeBrowserStderr(stderr) }; +export function proofBrowserArguments(profile, { platform = process.platform, ci = process.env.CI } = {}) { + // Only a fresh task-owned proof profile reaches this helper. Chromium documents + // this Mac test switch to avoid real-keychain dialogs; no owner browser changes. + return ['--headless=new', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check', '--disable-background-networking', ...(platform === 'darwin' && ci === 'true' ? ['--use-mock-keychain'] : []), '--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank']; +} +export function selectedBrowserDiagnostic({ profile, tmpdir, binaryVersion, versionError, child, startError, stderr, socket, failedCdpMethod, ciMockKeychainConfigured = false }, phase) { + return { platform: process.platform, phase, profile, tmpdir, binaryVersion, versionError, ciMockKeychainConfigured, exitCode: child.exitCode, signalCode: child.signalCode, startErrorCode: startError?.code ?? null, cdpSocketState: socket?.readyState ?? null, failedCdpMethod: failedCdpMethod ?? null, stderr: safeBrowserStderr(stderr) }; } export async function syntheticCookieCapability(call, value, diagnostic = () => null) { assert(typeof value === 'string' && /^[a-z0-9]{1,64}$/.test(value), 'Only the synthetic local challenge is permitted.'); @@ -61,13 +66,14 @@ async function openProofBrowser(state) { let binaryVersion = null, versionError = null; try { binaryVersion = (await execute(binary, ['--version'], { env, timeout: 10000, maxBuffer: 4096, killSignal: 'SIGKILL', encoding: 'utf8' })).stdout.trim().slice(0, 160); } catch (error) { versionError = { name: error.name, code: error.code ?? null, signal: error.signal ?? null }; } - const child = spawn(binary, ['--headless=new', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check', '--disable-background-networking', '--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'], env }); + const args = proofBrowserArguments(profile), ciMockKeychainConfigured = args.includes('--use-mock-keychain'); + const child = spawn(binary, args, { stdio: ['ignore', 'ignore', 'pipe'], env }); let startError, stderr = ''; child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString()).slice(-8192); }); child.once('error', error => { startError = error; }); const identity = new Promise(done => { child.once('spawn', () => { void processIdentity(child.pid).then(value => done({ pid: child.pid, identity: value }), () => done(null)); }); child.once('error', () => done(null)); }); let socket, next = 1, failedCdpMethod = null; const pending = new Map(); - const diagnostic = phase => selectedBrowserDiagnostic({ profile, tmpdir: env.TMPDIR, binaryVersion, versionError, child, startError, stderr, socket, failedCdpMethod }, phase); + const diagnostic = phase => selectedBrowserDiagnostic({ profile, tmpdir: env.TMPDIR, binaryVersion, versionError, child, startError, stderr, socket, failedCdpMethod, ciMockKeychainConfigured }, phase); async function close() { if (socket?.readyState === WebSocket.OPEN) socket.close(); const original = await identity; @@ -87,7 +93,7 @@ async function openProofBrowser(state) { const { sessionId } = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); const call = (method, params) => send(method, params, sessionId); const evaluate = async expression => { const result = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (result.exceptionDetails) throw new Error('Browser evaluation failed.'); return result.result.value; }; - return { call, evaluate, close, diagnostic, version: { product: version.product, protocolVersion: version.protocolVersion, binaryVersion } }; + return { call, evaluate, close, diagnostic, version: { product: version.product, protocolVersion: version.protocolVersion, binaryVersion, ciMockKeychainConfigured } }; } catch (error) { error.browserStartup = diagnostic('startup'); await close(); throw error; diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs index 39d03fbc8..82ab60df3 100644 --- a/scripts/tests/dev-stack.test.mjs +++ b/scripts/tests/dev-stack.test.mjs @@ -12,9 +12,19 @@ import { confinedFile, nativePackage, opencodeEnvironment, resolveOpenCode, veri import { InteractiveTerminalProof, assertBrowserTerminal } from '../dev-stack/terminal-proof.mjs'; import { knownHelperRepair } from '../dev-stack/pty-capability.mjs'; import { isPublicExportPath } from '../public-export.mjs'; -import { assertBrowserRunning, eventually, safeBrowserStderr, selectedBrowserDiagnostic, syntheticCookieCapability, typeTerminalFilter } from '../dev-stack/browser-proof.mjs'; +import { assertBrowserRunning, eventually, proofBrowserArguments, safeBrowserStderr, selectedBrowserDiagnostic, syntheticCookieCapability, typeTerminalFilter } from '../dev-stack/browser-proof.mjs'; const root = fileURLToPath(new URL('../..', import.meta.url)); +test('only Darwin CI proof browsers request a mock keychain and every other browser argument stays unchanged', () => { + const profile = '/private/.dev-stack-proof/browser-proof'; + const original = ['--headless=new', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check', '--disable-background-networking', '--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank']; + for (const platform of ['linux', 'win32', 'darwin']) for (const ci of ['false', '', 'TRUE', '1', true, null]) assert.deepEqual(proofBrowserArguments(profile, { platform, ci }), original); + for (const platform of ['linux', 'win32']) assert.deepEqual(proofBrowserArguments(profile, { platform, ci: 'true' }), original); + const permitted = proofBrowserArguments(profile, { platform: 'darwin', ci: 'true' }); + assert.equal(permitted.filter(arg => arg === '--use-mock-keychain').length, 1); + assert.deepEqual(permitted.filter(arg => arg !== '--use-mock-keychain'), original); + assert.equal(permitted.some(arg => arg.includes('password-store')), false); +}); function syntheticCookieFixture({ mutate = cookie => cookie, retainAfterDelete = false } = {}) { let cookie; const calls = []; const call = async (method, params) => { From aa03e60a637827ddac54a835a9178a609e4dfa63 Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 17 Oct 2026 22:11:02 +0200 Subject: [PATCH 16/27] docs: document full source stack startup in contributing --- CONTRIBUTING.md | 48 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a62ca855c..63a317f50 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,10 +27,50 @@ packaging requires its normal toolchain and separate platform acceptance. Dependency installation may use the network; the smoke command below does not need a provider account, API key, Docker, PostgreSQL or Temporal. -For the real source API/web/worker/CLI stack with isolated Docker dependencies, -normal owner setup and hot reload, use `npm run dev:stack`. See the -[development stack guide](docs/development-stack.md) for prerequisites, ports, -private state, down/reset, and the complete Linux/Intel-macOS browser/lifecycle proof. +## Run the full app from source + +Use the Node/npm prerequisites above and a working Docker Engine or Docker +Desktop with Compose V2. On macOS, Docker Desktop or an existing Colima context +must already work. On Windows 11, run these commands inside an existing WSL2 +Linux environment with Docker integration. The launcher does not install these +tools or change OS settings. + +From the checkout root: + +```sh +ELECTRON_SKIP_BINARY_DOWNLOAD=1 npm ci +npm run dev:stack +``` + +The command starts the isolated PostgreSQL/Temporal containers, migrations, +source API, Vite web app, worker and CLI host. Open the printed web URL, create +your local owner with the one-time setup token, then sign in and open Getting +Started and an OpenCode pane. No provider account or API key is needed to reach +the terminal UI. Web changes hot-reload; API changes restart the API; changes +to worker/shared packages rebuild and restart the dependent source services. + +Defaults are API `4810`, web `4811`, PostgreSQL `54329` and Temporal `47233`, +all bound to `127.0.0.1`. Use `SPACE_DEV_API_PORT`, `SPACE_DEV_WEB_PORT`, +`SPACE_DEV_POSTGRES_PORT` and `SPACE_DEV_TEMPORAL_PORT` to select unused ports. +Choose `SPACE_DEV_STATE_DIR=.dev-stack-` for a separate instance; the +default is `.dev-stack/`. Installed ports `4910`/`4911` are rejected. + +Keep the same state/port environment when stopping or resetting: + +```sh +npm run dev:stack -- down +npm run dev:stack -- reset +``` + +`down` preserves the private database/state; `reset` also removes only that +state and its owned resources. For an occupied port, choose an unused port +before first startup. If Docker is unavailable or cleanup fails, restore the +original Docker context and retry `down` or `reset`; retain the ownership +manifest instead of deleting it. A required process exit is reported by name +and stops the stack. Never share the setup token or generated state directory. + +See the [development stack guide](docs/development-stack.md) for alternate-port +examples, private-state details, troubleshooting and the Linux/Intel-macOS proof. ## First executed task, without a provider From be842511a6e34271488291670cdbebb704537457 Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 17 Oct 2026 22:24:52 +0200 Subject: [PATCH 17/27] Wait for browser-owned terminal geometry before UI proof --- docs/development-stack.md | 11 ++- scripts/dev-stack/acceptance.mjs | 25 +++++-- scripts/dev-stack/browser-proof.mjs | 2 +- scripts/dev-stack/terminal-proof.mjs | 70 ++++++++++++++++--- scripts/tests/dev-stack.test.mjs | 101 +++++++++++++++++++++++++++ 5 files changed, 190 insertions(+), 19 deletions(-) diff --git a/docs/development-stack.md b/docs/development-stack.md index 668272595..8db4de8a6 100644 --- a/docs/development-stack.md +++ b/docs/development-stack.md @@ -116,7 +116,7 @@ permissions, pins, environment isolation, redaction, PID reuse and failed comman These tests do not establish a working Docker stack or browser. `.github/workflows/dev-stack.yml` prepares only standard public `ubuntu-latest` -and `macos-15-intel` runners and checks the exact source. Both run all 62 launcher +and `macos-15-intel` runners and checks the exact source. Both run all 69 launcher regressions, `npm run check`, `npm run build` and `npm run hygiene:preflight`. Ubuntu also runs the full unmodified `npm test` suite. Native macOS runs all 13 private-instance API regressions in the four `instance-storage`/`dev-*-storage` @@ -187,7 +187,14 @@ provider HOME/XDG roots do not replace the pane's requested working directory. Screen predicates use the already locked headless terminal renderer, matching the canonical API projection. They await the complete observed ANSI stream at the current session's generation-bound geometry, latched at the pre-input -baseline. Truncated/oversized replay, unknown geometry or any later dimension/ +baseline. Before opening the browser, the proof pins the first live process +generation. It then waits for the matching pane's existing measured DOM grid, +completed initial fit and loaded fonts to agree with fresh API geometry in two +DOM/API/DOM samples at least 400 ms apart. Only dimensions may settle before the +first baseline; the process generation cannot change. This is a bounded startup +observation, not a promise of future layout stability. Every key checks the same +browser grid, and selected expected/observed geometry is retained on failure. +Truncated/oversized replay, unknown geometry or any later dimension/ generation change fails explicitly. A known unfiltered menu must precede the fresh challenge; current full value and empty-result state must follow new output. Dismissal must remove the dialog/challenge before reopening. Historical or erased diff --git a/scripts/dev-stack/acceptance.mjs b/scripts/dev-stack/acceptance.mjs index 86a2ed201..9365490db 100644 --- a/scripts/dev-stack/acceptance.mjs +++ b/scripts/dev-stack/acceptance.mjs @@ -62,29 +62,44 @@ try { const currentSession = () => request(`/api/panes/${encodeURIComponent(pane.id)}/cli/session`); await eventually(async () => { interactive.assertHealthy(); - return interactive.live(await currentSession()) && interactive.sequence > 0; + const response = await currentSession(); + if (!interactive.live(response)) return false; + interactive.pinGeneration(response); + return interactive.sequence > 0; }, 'Current matching OpenCode API/WS session'); browser = await browserProof({ state: config.state, origin, cookie, artifacts, paneId: pane.id }); // Exercise real terminal keyboard input through the actual matching browser pane. // No Enter or provider/model command is sent: only a local palette and filter. const deadline = Date.now() + 120000; const waitInteractive = async (predicate, label) => eventually(async () => { interactive.assertHealthy(); const response = await currentSession(); return interactive.live(response) && await predicate(response); }, label, Math.max(1, deadline - Date.now())); + // The observer's initial grid is not the browser's measured grid. Wait for + // the existing DOM coordinator and actual host/API acknowledgment before the + // one pre-input latch; every later generation/grid change remains fatal. + await eventually(async () => { + const before = await browser.terminalState(), response = await currentSession(), after = await browser.terminalState(); + return interactive.acknowledgeBrowserGeometry(pane.id, before, response, after); + }, 'Stable matching browser grid acknowledged by the live API', Math.max(1, deadline - Date.now())); + const key = async (name, text) => { + interactive.assertBrowserGeometry(await browser.terminalState(), pane.id, `before-key-${name}`); + await browser.key(name, text); + }; let transition = await waitInteractive(response => interactive.beginScreenTransition('open', response), 'Known closed-menu pre-input screen'); - await browser.key('Ctrl+P'); + await key('Ctrl+P'); await waitInteractive(response => interactive.screenTransition(transition, response), 'Fresh pinned OpenCode command menu after Ctrl+P'); await browser.screenshot('opencode-command-menu.png'); const challenge = `spaceproof${randomBytes(8).toString('hex')}`; transition = await waitInteractive(response => interactive.beginScreenTransition('filter', response, challenge), 'Known unfiltered menu without the new challenge'); - await browser.key('text', challenge); + await key('text', challenge); await waitInteractive(response => interactive.screenTransition(transition, response), 'Fresh unique palette filter response on the current terminal screen'); await browser.screenshot('opencode-filter-response.png'); transition = await waitInteractive(response => interactive.beginScreenTransition('dismiss', response, challenge), 'Known current filtered menu before dismissal'); - await browser.key('Escape'); + await key('Escape'); await waitInteractive(response => interactive.screenTransition(transition, response), 'Fresh screen with the menu and challenge dismissed'); transition = await waitInteractive(response => interactive.beginScreenTransition('open', response), 'Known closed-menu screen before reopening'); - await browser.key('Ctrl+P'); + await key('Ctrl+P'); await waitInteractive(response => interactive.screenTransition(transition, response), 'Fresh command menu after dismissal and reopening'); const browserState = await browser.terminalState(); assertBrowserTerminal(browserState, pane.id); + interactive.assertBrowserGeometry(browserState, pane.id, 'final-browser-seal'); await browser.screenshot('opencode-pane.png'); interactive.seal(await currentSession()); await writeFile(join(artifacts, 'opencode-interactive-proof.json'), JSON.stringify({ source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, native, browser: browserState, terminal: interactive.diagnostic([cookie, ticket.token, password, token]), challenge, transitions: ['Ctrl+P -> fresh Commands and registered menu entries', 'unique palette filter -> fresh matching response', 'Escape -> fresh response', 'Ctrl+P -> fresh full command menu'], modelInference: false }, null, 2) + '\n', { mode: 0o600 }); diff --git a/scripts/dev-stack/browser-proof.mjs b/scripts/dev-stack/browser-proof.mjs index df67f0388..54327c1fc 100644 --- a/scripts/dev-stack/browser-proof.mjs +++ b/scripts/dev-stack/browser-proof.mjs @@ -139,7 +139,7 @@ export async function browserProof({ state, origin, cookie, artifacts, paneId }) await eventually(() => evaluate('document.body.innerText.toLowerCase().includes("getting started")'), 'Actual Getting Started web room'); await evaluate(`(() => { const room = [...document.querySelectorAll('button,[role="button"]')].find(x => /getting started/i.test(x.textContent || x.getAttribute('title') || '')); room?.click(); return Boolean(room); })()`); const paneExpression = `document.querySelector('[data-space-pane-id="' + CSS.escape(${JSON.stringify(paneId)}) + '"]')`; - const terminalState = () => evaluate(`(() => { const pane = ${paneExpression}, terminal = pane?.querySelector('.terminal-pane'); if (!terminal) return null; return { paneId: pane.dataset.spacePaneId, status: terminal.dataset.terminalStatus, hasSession: terminal.dataset.terminalHasSession, hasWebsocket: terminal.dataset.terminalHasWebsocket, hasError: terminal.dataset.terminalHasError, width: terminal.querySelector('.xterm-screen')?.getBoundingClientRect().width || 0 }; })()`); + const terminalState = () => evaluate(`(() => { const pane = ${paneExpression}, terminal = pane?.querySelector('.terminal-pane'); if (!terminal) return null; const hosts = terminal.querySelectorAll('[data-terminal-cols][data-terminal-rows]'), host = hosts.length === 1 ? hosts[0] : null; return { paneId: pane.dataset.spacePaneId, status: terminal.dataset.terminalStatus, hasSession: terminal.dataset.terminalHasSession, hasWebsocket: terminal.dataset.terminalHasWebsocket, hasError: terminal.dataset.terminalHasError, width: terminal.querySelector('.xterm-screen')?.getBoundingClientRect().width || 0, geometry: host ? { cols: Number(host.dataset.terminalCols), rows: Number(host.dataset.terminalRows), initialFitPending: host.dataset.terminalInitialGeometry === 'pending', fontsLoaded: document.fonts?.status === 'loaded' } : null }; })()`); await eventually(async () => { const current = await terminalState(); assertBrowserTerminal(current, paneId); return current; }, 'Matching attached error-free terminal in the actual browser'); await mkdir(artifacts, { recursive: true }); const key = async (name, text) => { diff --git a/scripts/dev-stack/terminal-proof.mjs b/scripts/dev-stack/terminal-proof.mjs index 3de5c4ac7..98c027cf3 100644 --- a/scripts/dev-stack/terminal-proof.mjs +++ b/scripts/dev-stack/terminal-proof.mjs @@ -5,10 +5,15 @@ import headless from '@xterm/headless'; const menuTitles = ['Switch session', 'New session', 'Switch model', 'Switch agent', 'Connect provider', 'View status', 'Switch theme', 'Help', 'Exit the app']; const maximumReplayBytes = 8 * 1024 * 1024; const fullMenu = text => text.includes('Commands') && menuTitles.filter(title => text.includes(title)).length >= 2; +const validGrid = value => value && Number.isInteger(value.cols) && value.cols >= 2 && value.cols <= 400 && Number.isInteger(value.rows) && value.rows >= 2 && value.rows <= 200; +const sameGrid = (left, right) => left?.cols === right?.cols && left?.rows === right?.rows; +const sameGeometry = (left, right) => sameGrid(left, right) && left?.generationId === right?.generationId; +const selectedGeometry = value => value ? { generationId: typeof value.generationId === 'string' && /^[a-z0-9:_-]{1,160}$/i.test(value.generationId) ? value.generationId : null, cols: Number.isInteger(value.cols) && value.cols >= 2 && value.cols <= 400 ? value.cols : null, rows: Number.isInteger(value.rows) && value.rows >= 2 && value.rows <= 200 ? value.rows : null } : null; +const selectedBrowserGrid = value => value ? { cols: selectedGeometry(value).cols, rows: selectedGeometry(value).rows, initialFitPending: value.initialFitPending === true, fontsLoaded: value.fontsLoaded === true } : null; // Exact 1.18.4 references are recorded in the validation evidence. A brand name, // rendered terminal rectangle, or historical RUNNING flag is never UI proof. export class InteractiveTerminalProof { - constructor(sessionId) { this.sessionId = sessionId; this.wsState = 'CONNECTING'; this.status = null; this.events = []; this.sequence = 0; this.fatal = null; this.sealed = false; this.session = null; this.frames = []; this.replayBytes = 0; this.completeReplay = false; this.geometry = null; this.screenRecords = []; this.activeTransition = null; } + constructor(sessionId) { this.sessionId = sessionId; this.wsState = 'CONNECTING'; this.status = null; this.events = []; this.sequence = 0; this.fatal = null; this.sealed = false; this.session = null; this.frames = []; this.replayBytes = 0; this.completeReplay = false; this.geometry = null; this.screenRecords = []; this.activeTransition = null; this.expectedGenerationId = null; this.initialGeometry = null; this.geometryCandidate = null; this.browserGeometry = null; this.geometryObservation = null; } fail(message) { if (!this.sealed && !this.fatal) this.fatal = message; } open() { this.wsState = 'OPEN'; } error(code = 'WEBSOCKET_ERROR') { this.fail(`Terminal WebSocket error (${String(code).slice(0, 80)}).`); } @@ -39,17 +44,56 @@ export class InteractiveTerminalProof { } return this.wsState === 'OPEN' && this.status === 'RUNNING' && session.status === 'RUNNING'; } + observeGeometry(response, phase) { + const geometry = response?.session?.terminalGeometry; + this.geometryObservation = { phase, sequence: this.sequence, expected: selectedGeometry(this.geometry || this.browserGeometry?.geometry || (this.expectedGenerationId ? { generationId: this.expectedGenerationId } : null)), observedAPI: selectedGeometry(geometry) }; + if (!validGrid(geometry) || typeof geometry.generationId !== 'string' || !/^[a-z0-9:_-]{1,160}$/i.test(geometry.generationId)) this.fail('A current generation-bound terminal geometry is required.'); + this.assertHealthy(); + if (this.geometry && !sameGeometry(geometry, this.geometry)) this.fail('Terminal generation or geometry changed after the pre-input baseline.'); + if (this.expectedGenerationId && geometry.generationId !== this.expectedGenerationId) this.fail('Terminal generation changed before the pre-input baseline.'); + this.assertHealthy(); + return { generationId: geometry.generationId, cols: geometry.cols, rows: geometry.rows }; + } + pinGeneration(response) { + if (!this.live(response)) return false; + const geometry = this.observeGeometry(response, 'before-browser-startup'); + this.expectedGenerationId ??= geometry.generationId; + this.initialGeometry ??= geometry; + return true; + } + acknowledgeBrowserGeometry(paneId, before, response, after, now = Date.now()) { + this.assertHealthy(); + assert(this.expectedGenerationId, 'Pin the first live generation before starting the browser.'); + assert.equal(this.browserGeometry, null, 'The browser geometry acknowledgment cannot be resampled.'); + assert.equal(this.geometry, null, 'The pre-input baseline cannot be resampled.'); + assert.equal(this.activeTransition, null, 'No keyboard transition may precede browser geometry acknowledgment.'); + assert(Number.isFinite(now), 'The bounded geometry sample clock must be finite.'); + if (!this.live(response)) { this.geometryCandidate = null; return null; } + const geometry = this.observeGeometry(response, 'browser-api-pre-input'); + assertBrowserTerminal(before, paneId); assertBrowserTerminal(after, paneId); + this.geometryObservation.DOMBefore = selectedBrowserGrid(before.geometry); this.geometryObservation.DOMAfter = selectedBrowserGrid(after.geometry); + const ready = value => validGrid(value) && value.initialFitPending === false && value.fontsLoaded === true; + if (!ready(before.geometry) || !ready(after.geometry) || !sameGrid(before.geometry, after.geometry) || !sameGrid(after.geometry, geometry)) { this.geometryCandidate = null; return null; } + if (!this.geometryCandidate || !sameGeometry(this.geometryCandidate.geometry, geometry)) { this.geometryCandidate = { geometry, since: now }; return null; } + if (now - this.geometryCandidate.since < 400) return null; + this.browserGeometry = Object.freeze({ paneId, geometry: Object.freeze({ ...geometry }), acknowledgedSamples: 2, stableForMs: now - this.geometryCandidate.since, sequence: this.sequence }); + return this.browserGeometry; + } + assertBrowserGeometry(state, paneId, phase) { + this.assertHealthy(); assert.equal(paneId, this.browserGeometry?.paneId, 'The browser must remain the acknowledged pane.'); assertBrowserTerminal(state, paneId); + const expected = this.geometry || this.browserGeometry?.geometry; + this.geometryObservation = { phase, sequence: this.sequence, expected: selectedGeometry(expected), observedAPI: this.geometryObservation?.observedAPI ?? null, observedDOM: selectedBrowserGrid(state.geometry) }; + if (!expected || !validGrid(state.geometry) || state.geometry.initialFitPending !== false || state.geometry.fontsLoaded !== true || !sameGrid(state.geometry, expected)) this.fail('The browser terminal grid differs from the acknowledged pre-input baseline.'); + this.assertHealthy(); + } barrier() { this.assertHealthy(); return this.sequence; } fresh(after) { return this.events.filter(event => event.sequence > after).map(event => event.text).join(''); } menu(after) { this.assertHealthy(); const text = this.fresh(after); return text.includes('Commands') && menuTitles.filter(title => text.includes(title)).length >= 2; } challenge(after, value) { this.assertHealthy(); const text = this.fresh(after); return text.includes(value) && text.includes('No results found'); } - async screen(response) { + async screen(response, phase = 'screen-projection') { if (!this.live(response)) return null; - const geometry = response.session.terminalGeometry; - if (!geometry || typeof geometry.generationId !== 'string' || !geometry.generationId.length || geometry.generationId.length > 160 || !Number.isInteger(geometry.cols) || geometry.cols < 2 || geometry.cols > 400 || !Number.isInteger(geometry.rows) || geometry.rows < 2 || geometry.rows > 200) this.fail('A current generation-bound terminal geometry is required.'); - this.assertHealthy(); + const geometry = this.observeGeometry(response, phase); if (!this.completeReplay) this.fail('A complete terminal replay has not been established.'); - if (this.geometry && (geometry.generationId !== this.geometry.generationId || geometry.cols !== this.geometry.cols || geometry.rows !== this.geometry.rows)) this.fail('Terminal generation or geometry changed after the pre-input baseline.'); this.assertHealthy(); const sequence = this.sequence, terminal = new headless.Terminal({ cols: geometry.cols, rows: geometry.rows, scrollback: 0, allowProposedApi: true }); try { @@ -71,7 +115,11 @@ export class InteractiveTerminalProof { assert(['open', 'filter', 'dismiss'].includes(kind), 'Unknown terminal transition.'); if (kind !== 'open') assert(typeof challenge === 'string' && /^[a-z0-9]{1,64}$/.test(challenge), 'A fresh synthetic challenge is required.'); assert.equal(this.activeTransition, null, 'A previous terminal transition remains unverified.'); - const screen = await this.screen(response); if (!screen) return null; + if (this.expectedGenerationId && !this.browserGeometry) this.fail('Browser/API geometry acknowledgment is required before the first input baseline.'); + this.assertHealthy(); + const screen = await this.screen(response, `before-${kind}`); if (!screen) return null; + if (this.browserGeometry && !sameGeometry(screen.geometry, this.browserGeometry.geometry)) this.fail('Terminal geometry changed after browser/API pre-input acknowledgment.'); + this.assertHealthy(); const filtered = screen.text.includes('Commands') && screen.text.includes('No results found') && challenge && screen.text.includes(challenge); const valid = kind === 'open' ? !screen.text.includes('Commands') && !screen.text.includes('No results found') : kind === 'filter' ? fullMenu(screen.text) && !screen.text.includes(challenge) && !screen.text.includes('No results found') : filtered; if (!valid) return null; @@ -81,7 +129,7 @@ export class InteractiveTerminalProof { } async screenTransition(token, response) { assert.equal(token, this.activeTransition, 'Only the current pre-input transition can be verified.'); - const screen = await this.screen(response); if (!screen || screen.sequence <= token.sequence) return null; + const screen = await this.screen(response, `after-${token.kind}`); if (!screen || screen.sequence <= token.sequence) return null; const valid = token.kind === 'open' ? fullMenu(screen.text) && !screen.text.includes('No results found') : token.kind === 'filter' ? screen.text.includes('Commands') && screen.text.includes(token.challenge) && screen.text.includes('No results found') : !screen.text.includes('Commands') && !screen.text.includes(token.challenge) && !screen.text.includes('No results found'); if (!valid) return null; this.screenRecords.push({ phase: `after-${token.kind}`, ...screen }); this.activeTransition = null; return screen; @@ -92,14 +140,14 @@ export class InteractiveTerminalProof { // so a generated secret split across WebSocket events cannot escape masking. const content = clean(this.frames.map(frame => frame.data).join('')); let offset = 0; const frames = this.frames.map(frame => { const data = content.slice(offset, offset + frame.data.length); offset += frame.data.length; return { sequence: frame.sequence, data }; }); - return { expectedSessionId: this.sessionId, completeReplay: this.completeReplay, originalReplayBytes: this.replayBytes, maximumReplayBytes, geometry: this.geometry, frames, screens: this.screenRecords.map(screen => ({ ...screen, text: clean(screen.text) })), currentScreen: this.lastScreen ? { ...this.lastScreen, text: clean(this.lastScreen.text) } : null }; + return { expectedSessionId: this.sessionId, completeReplay: this.completeReplay, originalReplayBytes: this.replayBytes, maximumReplayBytes, geometry: this.geometry, initialGeometry: selectedGeometry(this.initialGeometry), browserGeometry: this.browserGeometry, geometryObservation: this.geometryObservation, frames, screens: this.screenRecords.map(screen => ({ ...screen, text: clean(screen.text) })), currentScreen: this.lastScreen ? { ...this.lastScreen, text: clean(this.lastScreen.text) } : null }; } - seal(response) { assert.equal(this.live(response), true, 'UI success requires a final fresh live API/WS session.'); assert.equal(this.activeTransition, null, 'A terminal transition remains unverified.'); if (this.geometry) assert.deepEqual(response.session.terminalGeometry, this.geometry, 'Final terminal geometry must match the pre-input baseline.'); this.sealed = true; } + seal(response) { assert.equal(this.live(response), true, 'UI success requires a final fresh live API/WS session.'); assert.equal(this.activeTransition, null, 'A terminal transition remains unverified.'); if (this.geometry) assert.deepEqual(this.observeGeometry(response, 'final-api-seal'), this.geometry, 'Final terminal geometry must match the pre-input baseline.'); this.sealed = true; } diagnostic(redactions = []) { let excerpt = this.events.map(event => event.text).join('').slice(-6000); for (const secret of redactions.filter(Boolean).sort((a, b) => b.length - a.length)) excerpt = excerpt.split(secret).join('[redacted]'); excerpt = excerpt.replace(/([?&](?:token|sessionToken|access_token)=)[^\s&]+/gi, '$1[redacted]'); - return { expectedSessionId: this.sessionId, currentSession: this.session, websocket: { state: this.wsState, status: this.status, exitCode: this.exitCode ?? null, closeCode: this.closeCode ?? null, fatal: this.fatal }, outputEvents: this.sequence, sealed: this.sealed, syntheticTerminalExcerpt: excerpt }; + return { expectedSessionId: this.sessionId, currentSession: this.session, websocket: { state: this.wsState, status: this.status, exitCode: this.exitCode ?? null, closeCode: this.closeCode ?? null, fatal: this.fatal }, outputEvents: this.sequence, sealed: this.sealed, initialGeometry: selectedGeometry(this.initialGeometry), browserGeometry: this.browserGeometry, geometryObservation: this.geometryObservation, syntheticTerminalExcerpt: excerpt }; } } export function assertBrowserTerminal(state, paneId) { diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs index 82ab60df3..6fd0b08ae 100644 --- a/scripts/tests/dev-stack.test.mjs +++ b/scripts/tests/dev-stack.test.mjs @@ -598,3 +598,104 @@ test('retained full synthetic frames redact generated secrets across event bound assert.deepEqual(value.frames.map(frame => [frame.sequence, frame.data.length]), proof.frames.map(frame => [frame.sequence, frame.data.length])); assert.equal(value.completeReplay, true); }); + +const browserGrid = (cols = 153, rows = 46) => ({ paneId: 'tested-pane', status: 'attached', hasSession: 'true', hasWebsocket: 'true', hasError: 'false', width: 1105, geometry: { cols, rows, initialFitPending: false, fontsLoaded: true } }); +const initialObserverGeometry = { generationId: 'same-original-generation', cols: 120, rows: 32 }; +function browserGridProof() { const proof = screenProof(); proof.message({ type: 'output', data: closedFrame }); proof.pinGeneration(screenSession(initialObserverGeometry)); return proof; } +function acknowledgedGridProof() { + const proof = browserGridProof(), state = browserGrid(), response = screenSession({ ...initialObserverGeometry, cols: 153, rows: 46 }); + assert.equal(proof.acknowledgeBrowserGeometry('tested-pane', state, response, state, 0), null); + assert(proof.acknowledgeBrowserGeometry('tested-pane', state, response, state, 400)); + return { proof, state, response }; +} + +test('browser measured grid waits for actual API acknowledgment before the first latch instead of adopting the observer grid', async () => { + const historical = screenProof(); historical.message({ type: 'output', data: closedFrame }); + const historicalTransition = await historical.beginScreenTransition('open', screenSession(initialObserverGeometry)); + historical.message({ type: 'output', data: menuFrame }); + await assert.rejects(historical.screenTransition(historicalTransition, screenSession({ ...initialObserverGeometry, cols: 153, rows: 46 })), /generation or geometry changed/, 'The old pre-input ordering reproduces the same guard family under a synthetic delayed resize.'); + const premature = browserGridProof(); + await assert.rejects(premature.beginScreenTransition('open', screenSession(initialObserverGeometry)), /acknowledgment is required/); + const proof = browserGridProof(), state = browserGrid(), observer = screenSession(initialObserverGeometry), response = screenSession({ ...initialObserverGeometry, cols: 153, rows: 46 }); + assert.equal(proof.acknowledgeBrowserGeometry('tested-pane', state, observer, state, 0), null, 'The browser target153x46 is not acknowledged by observer120x32.'); + assert.equal(proof.geometry, null); assert.equal(proof.activeTransition, null); + assert.equal(proof.acknowledgeBrowserGeometry('tested-pane', state, response, state, 400), null); + assert.equal(proof.acknowledgeBrowserGeometry('tested-pane', state, response, state, 799), null, 'Two immediate agreeing reads cannot replace the400ms observation.'); + const ack = proof.acknowledgeBrowserGeometry('tested-pane', state, response, state, 800); + assert.equal(ack.stableForMs, 400); assert.equal(ack.acknowledgedSamples, 2); assert.deepEqual(proof.initialGeometry, initialObserverGeometry); + const transition = await proof.beginScreenTransition('open', response); assert.deepEqual(proof.geometry, response.session.terminalGeometry); + proof.assertBrowserGeometry(state, 'tested-pane', 'before-key-Ctrl+P'); proof.message({ type: 'output', data: menuFrame }); + assert(await proof.screenTransition(transition, response)); +}); + +test('a changing DOM sandwich or stale API clears startup samples and only pre-input dimensions may settle', () => { + const proof = browserGridProof(), first = browserGrid(), second = browserGrid(150, 44), firstResponse = screenSession({ ...initialObserverGeometry, cols: 153, rows: 46 }), secondResponse = screenSession({ ...initialObserverGeometry, cols: 150, rows: 44 }); + assert.equal(proof.acknowledgeBrowserGeometry('tested-pane', first, firstResponse, first, 0), null); + assert.equal(proof.acknowledgeBrowserGeometry('tested-pane', first, firstResponse, second, 400), null); + assert.equal(proof.acknowledgeBrowserGeometry('tested-pane', second, firstResponse, second, 800), null); + assert.equal(proof.acknowledgeBrowserGeometry('tested-pane', second, secondResponse, second, 1200), null); + assert.equal(proof.acknowledgeBrowserGeometry('tested-pane', second, secondResponse, second, 1599), null); + assert.equal(proof.acknowledgeBrowserGeometry('tested-pane', second, secondResponse, second, 1600).geometry.cols, 150); + assert.equal(proof.geometry, null, 'Acknowledgment is not a screen/input latch.'); + assert.throws(() => proof.acknowledgeBrowserGeometry('tested-pane', second, secondResponse, second, 2000), /cannot be resampled/); +}); + +test('missing malformed pending or font-loading browser grids never establish readiness', () => { + for (const geometry of [null, {}, { cols: 0, rows: 46 }, { cols: 401, rows: 46 }, { cols: 153, rows: 201 }, { cols: 153.5, rows: 46 }, { cols: 153, rows: 46 }, { ...browserGrid().geometry, initialFitPending: true }, { ...browserGrid().geometry, fontsLoaded: false }]) { + const proof = browserGridProof(), state = { ...browserGrid(), geometry }, response = screenSession({ ...initialObserverGeometry, cols: 153, rows: 46 }); + assert.equal(proof.acknowledgeBrowserGeometry('tested-pane', state, response, state, 0), null); + assert.equal(proof.acknowledgeBrowserGeometry('tested-pane', state, response, state, 400), null); + assert.equal(proof.browserGeometry, null); assert.equal(proof.geometry, null); + } + for (const change of [{ paneId: 'foreign-pane' }, { status: 'closed' }, { hasError: 'true' }, { width: 0 }]) { + const proof = browserGridProof(), state = { ...browserGrid(), ...change }; + assert.throws(() => proof.acknowledgeBrowserGeometry('tested-pane', state, screenSession(initialObserverGeometry), state, 0)); + assert.equal(proof.browserGeometry, null); + } +}); + +test('first live generation is pinned before browser startup and cannot roll over during dimension rendezvous', () => { + for (const geometry of [{ ...initialObserverGeometry, generationId: 'replacement-generation' }, null, { ...initialObserverGeometry, generationId: '' }, { ...initialObserverGeometry, cols: 401 }]) { + const proof = browserGridProof(), state = browserGrid(); + assert.throws(() => proof.acknowledgeBrowserGeometry('tested-pane', state, screenSession(geometry), state, 400), /generation/); + assert.equal(proof.expectedGenerationId, initialObserverGeometry.generationId); assert.equal(proof.browserGeometry, null); + } + const replaced = browserGridProof(), state = browserGrid(), response = screenSession(initialObserverGeometry); response.session.sessionId = 'replacement-session'; + assert.throws(() => replaced.acknowledgeBrowserGeometry('tested-pane', state, response, state, 0), /Fresh API/); + const unpinned = screenProof(); assert.throws(() => unpinned.acknowledgeBrowserGeometry('tested-pane', state, screenSession(initialObserverGeometry), state, 0), /Pin the first live generation/); +}); + +test('acknowledged geometry cannot change before the first latch or after it and every keyboard grid is checked', async () => { + const before = acknowledgedGridProof(); + await assert.rejects(before.proof.beginScreenTransition('open', screenSession({ ...before.response.session.terminalGeometry, cols: 154 })), /changed after browser\/API/); + for (const change of [{ cols: 154 }, { rows: 47 }, { generationId: 'replacement-generation' }]) { + const { proof, response } = acknowledgedGridProof(); await proof.beginScreenTransition('open', response); proof.message({ type: 'output', data: menuFrame }); + await assert.rejects(proof.screen(screenSession({ ...response.session.terminalGeometry, ...change })), /generation or geometry changed/); + assert.equal(proof.diagnostic().geometryObservation.observedAPI.cols, change.cols || 153); + } + for (const change of [{ cols: 154 }, { rows: 47 }, { initialFitPending: true }, { fontsLoaded: false }]) { + const { proof, state, response } = acknowledgedGridProof(); await proof.beginScreenTransition('open', response); + assert.throws(() => proof.assertBrowserGeometry({ ...state, geometry: { ...state.geometry, ...change } }, 'tested-pane', 'before-key-filter'), /browser terminal grid differs/); + } + const foreign = acknowledgedGridProof(); + assert.throws(() => foreign.proof.assertBrowserGeometry({ ...foreign.state, paneId: 'foreign-pane' }, 'foreign-pane', 'before-key-filter'), /remain the acknowledged pane/); +}); + +test('rendezvous retains error close and truncated replay failures instead of resetting them into readiness', () => { + for (const fail of [proof => proof.error('CONTROL_FAILURE'), proof => proof.close(1006), proof => proof.message({ type: 'status', status: 'EXITED', exitCode: 0 }), proof => proof.message({ type: 'status', status: 'RUNNING', replayContinuity: 'TRUNCATED' })]) { + const proof = browserGridProof(), state = browserGrid(); fail(proof); + assert.throws(() => proof.acknowledgeBrowserGeometry('tested-pane', state, screenSession(initialObserverGeometry), state, 400)); + assert.equal(proof.browserGeometry, null); assert.equal(proof.geometry, null); + } +}); + +test('geometry failure evidence retains only bounded expected and observed fields without raw session or DOM secrets', async () => { + const { proof, response } = acknowledgedGridProof(); await proof.beginScreenTransition('open', response); + const changed = screenSession({ ...response.session.terminalGeometry, cols: 154 }); changed.session.websocket = { token: 'private-token', url: 'ws://private-url/' }; changed.session.password = 'private-password'; + await assert.rejects(proof.screen(changed, 'after-open'), /generation or geometry changed/); + const value = proof.diagnostic(), evidence = proof.streamEvidence(), encoded = JSON.stringify({ value, evidence }); + assert.deepEqual(value.geometryObservation, { phase: 'after-open', sequence: proof.sequence, expected: response.session.terminalGeometry, observedAPI: changed.session.terminalGeometry }); + assert(!encoded.includes('private-token')); assert(!encoded.includes('private-url')); assert(!encoded.includes('private-password')); + assert.deepEqual(evidence.initialGeometry, initialObserverGeometry); assert.deepEqual(evidence.browserGeometry.geometry, response.session.terminalGeometry); + const clock = browserGridProof(), state = browserGrid(); assert.throws(() => clock.acknowledgeBrowserGeometry('tested-pane', state, screenSession(initialObserverGeometry), state, NaN), /clock must be finite/); +}); From a4476b75c81c468aa815a8c75c94683f52f4300d Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 17 Oct 2026 23:07:13 +0200 Subject: [PATCH 18/27] fix(dev): scope readiness probes to service generations --- docs/development-stack.md | 5 +- scripts/dev-stack/readiness.mjs | 48 +++++++++++++++++ scripts/dev-stack/supervisor.mjs | 29 ++++++----- scripts/public-export.mjs | 1 + scripts/tests/dev-stack.test.mjs | 89 ++++++++++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 13 deletions(-) create mode 100644 scripts/dev-stack/readiness.mjs diff --git a/docs/development-stack.md b/docs/development-stack.md index 8db4de8a6..7fd2969c6 100644 --- a/docs/development-stack.md +++ b/docs/development-stack.md @@ -47,6 +47,9 @@ them from this checkout. Vite handles web HMR directly, including WebSocket prox to the chosen API port. A required child exiting unexpectedly produces a named error and stops the stack. Sustained readiness loss also fails instead of leaving an apparently healthy partial stack. +Intentional reloads invalidate earlier readiness probes. The replacement receives +the same bounded startup checks after compilation and launch, then the normal +four-loss readiness policy once it is ready; old responses cannot change that state. On failure, bounded logs and health-probe output from owned containers are redacted before cleanup. This capture does not expose container environments and a failed diagnostic cannot skip the normal ownership checks or cleanup. @@ -116,7 +119,7 @@ permissions, pins, environment isolation, redaction, PID reuse and failed comman These tests do not establish a working Docker stack or browser. `.github/workflows/dev-stack.yml` prepares only standard public `ubuntu-latest` -and `macos-15-intel` runners and checks the exact source. Both run all 69 launcher +and `macos-15-intel` runners and checks the exact source. Both run all 76 launcher regressions, `npm run check`, `npm run build` and `npm run hygiene:preflight`. Ubuntu also runs the full unmodified `npm test` suite. Native macOS runs all 13 private-instance API regressions in the four `instance-storage`/`dev-*-storage` diff --git a/scripts/dev-stack/readiness.mjs b/scripts/dev-stack/readiness.mjs new file mode 100644 index 000000000..8669e7887 --- /dev/null +++ b/scripts/dev-stack/readiness.mjs @@ -0,0 +1,48 @@ +// Readiness belongs to one deliberately launched service generation. Compilation +// pauses it; a late response from a stopped generation cannot affect its successor. +export class ReadinessEpoch { + #sequence = 0; + #current = null; + + pause() { this.#sequence++; this.#current = null; } + + start(now = Date.now()) { + if (!Number.isFinite(now)) throw new Error('Readiness clock must be finite.'); + this.#current = { epoch: ++this.#sequence, deadline: now + 180000, lastReady: null, failures: 0, probe: null, observed: false }; + } + + beginProbe() { + const current = this.#current; + if (!current || current.probe) return null; + current.observed = false; + return current.probe = Object.freeze({ epoch: current.epoch }); + } + + settle(probe, outcome, now = Date.now()) { + const current = this.#current; + if (!probe || !current || current.probe !== probe || current.observed) return null; + if (!['ready', 'unavailable', 'unreachable'].includes(outcome) || !Number.isFinite(now)) throw new Error('Invalid readiness observation.'); + current.observed = true; + const ready = outcome === 'ready', previouslyReady = current.lastReady !== null; + const changed = current.lastReady !== ready; + let fatal = false; + if (ready) current.failures = 0; + else if (outcome === 'unreachable') { + current.failures++; + fatal = current.failures >= (previouslyReady ? 4 : 30); + } else if (previouslyReady) fatal = ++current.failures >= 4; + else fatal = now > current.deadline; + if (ready || (outcome !== 'unreachable' && previouslyReady)) current.lastReady = ready; + return Object.freeze({ epoch: current.epoch, outcome, changed, fatal, failures: current.failures }); + } + + release(probe) { + const current = this.#current; + if (current?.probe === probe) { current.probe = null; current.observed = false; } + } + + diagnostic() { + const current = this.#current; + return current ? { epoch: current.epoch, deadline: current.deadline, lastReady: current.lastReady, failures: current.failures, checking: current.probe !== null } : { epoch: this.#sequence, paused: true }; + } +} diff --git a/scripts/dev-stack/supervisor.mjs b/scripts/dev-stack/supervisor.mjs index c1fea8727..4b855637d 100644 --- a/scripts/dev-stack/supervisor.mjs +++ b/scripts/dev-stack/supervisor.mjs @@ -9,6 +9,7 @@ import { delay, descendants, LaunchBarrier, lineRedactor, processIdentity, sameP import { assertOwnedDocker, compose, dockerEnvironment, removeOwnedDocker, run } from './resources.mjs'; import { verifyOpenCode } from './opencode-runtime.mjs'; import { verifyPtyCapability } from './pty-capability.mjs'; +import { ReadinessEpoch } from './readiness.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); const say = text => process.stdout.write(`[dev-stack] ${text}\n`); @@ -55,6 +56,7 @@ export async function start(config, inherited = process.env) { const secret = await secrets(config), redactions = [secret.password, encodeURIComponent(secret.password), secret.session, secret.token]; const env = serviceEnvironment(config, owner, secret, inherited), children = new Map(), watchers = []; const launches = new LaunchBarrier(); + const readiness = new ReadinessEpoch(); let stopping = false, restarting = false, queued = false, startupComplete = false, statusTimer, readyTimer, debounce; const cancellation = new AbortController(); let activeCommand = Promise.resolve(), writeTail = Promise.resolve(), stopTail = Promise.resolve(); @@ -78,6 +80,7 @@ export async function start(config, inherited = process.env) { }; async function shutdown(code = 0) { if (stopping) return; stopping = true; + readiness.pause(); cancellation.abort(); clearTimeout(debounce); clearInterval(statusTimer); clearInterval(readyTimer); for (const w of watchers) w.close(); try { await activeCommand.catch(() => {}); await launches.stop(); await stopChildren(); await removeOwnedDocker(config, owner, { output: log, secrets: redactions, diagnose: code !== 0 }); await writeTail; await unlink(config.supervisor); } @@ -108,12 +111,15 @@ export async function start(config, inherited = process.env) { if (stopping) return; if (restarting) { queued = true; return; } restarting = true; + readiness.pause(); try { const names = kind === 'api' ? ['api'] : ['api', 'worker', 'cli-host']; await stopChildren(names); if (kind !== 'api') await command(['run', 'build']); if (stopping) return; if (kind === 'api') await launch('api', ['--import', 'tsx', 'apps/api/src/server.ts']); else await sourceServices(); + if (stopping) return; + readiness.start(); say(`${kind} source change rebuilt/restarted; waiting for readiness.`); } catch (error) { say(`Reload failed: ${error.message}`); await shutdown(1); } finally { restarting = false; if (queued && !stopping) { queued = false; void rebuild('packages'); } } @@ -146,22 +152,21 @@ export async function start(config, inherited = process.env) { for (const [dir, kind] of [['apps/api/src', 'api'], ['apps/worker/src', 'packages'], ['packages', 'packages']]) { watchers.push(watch(join(config.root, dir), { recursive: true }, (_event, file) => { if (file && !/(?:^|[\\/])(?:node_modules|dist|\.git)(?:[\\/]|$)/.test(file) && /\.(?:ts|tsx|js|mjs|json)$/.test(file)) change(kind); })); } - let lastReady = null, readinessFailures = 0, checkingReady = false; - const readyDeadline = Date.now() + 180000; + readiness.start(); readyTimer = setInterval(async () => { - if (restarting || stopping || checkingReady) return; - checkingReady = true; + if (restarting || stopping) return; + const probe = readiness.beginProbe(); if (!probe) return; + let result, failed = 'API readiness'; try { const response = await fetch(`http://127.0.0.1:${owner.ports.api}/readyz`, { signal: AbortSignal.timeout(6000) }); const body = await response.json(), ready = response.ok && body.ok === true; - const failed = Object.entries(body.dependencies || {}).filter(([key, value]) => ['database', 'worker', 'cliHost'].includes(key) && ['ERROR', 'UNAVAILABLE', 'STOPPED'].includes(value)).map(([key]) => key).join(', ') || 'API/workflows'; - if (ready !== lastReady) say(ready ? 'readyz ok: workflows and source services are ready.' : `readyz failed: ${failed} unavailable; inspect its service log.`); - // Give normal cold startup time. Once ready, a sustained dependency loss is fatal. - if (ready) readinessFailures = 0; - else if ((lastReady !== null && ++readinessFailures >= 4) || Date.now() > readyDeadline) { say(`Required service failure: ${failed}; stopping the stack.`); await shutdown(1); } - if (ready || lastReady !== null) lastReady = ready; - } catch { if (lastReady !== false) say('readyz failed: API is not responding.'); if (++readinessFailures >= 30 || (lastReady !== null && readinessFailures >= 4)) { say('Required service failure: API readiness; stopping the stack.'); await shutdown(1); } } - finally { checkingReady = false; } + failed = Object.entries(body.dependencies || {}).filter(([key, value]) => ['database', 'worker', 'cliHost'].includes(key) && ['ERROR', 'UNAVAILABLE', 'STOPPED'].includes(value)).map(([key]) => key).join(', ') || 'API/workflows'; + result = readiness.settle(probe, ready ? 'ready' : 'unavailable'); + } catch { result = readiness.settle(probe, 'unreachable'); } + finally { readiness.release(probe); } + if (!result) return; // Completion/finally of a deliberately stopped epoch is inert. + if (result.changed) say(result.outcome === 'ready' ? 'readyz ok: workflows and source services are ready.' : result.outcome === 'unreachable' ? 'readyz failed: API is not responding.' : `readyz failed: ${failed} unavailable; inspect its service log.`); + if (result.fatal) { say(`Required service failure: ${failed}; readiness generation${result.epoch}, failures${result.failures}; stopping the stack.`); await shutdown(1); } }, 4000); statusTimer = setInterval(() => { if (startupComplete && !stopping) void recordChildren().catch(() => { void shutdown(1); }); }, 3000); } catch (error) { say(error.message); await shutdown(1); } diff --git a/scripts/public-export.mjs b/scripts/public-export.mjs index 913d8017f..3c48ec73d 100644 --- a/scripts/public-export.mjs +++ b/scripts/public-export.mjs @@ -84,6 +84,7 @@ const publicScripts = new Set([ "scripts/dev-stack/processes.mjs", "scripts/dev-stack/resources.mjs", "scripts/dev-stack/supervisor.mjs", + "scripts/dev-stack/readiness.mjs", "scripts/dev-stack/opencode-wrapper.mjs", "scripts/dev-stack/opencode-runtime.mjs", "scripts/dev-stack/pty-capability.mjs", diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs index 6fd0b08ae..eaf422bb4 100644 --- a/scripts/tests/dev-stack.test.mjs +++ b/scripts/tests/dev-stack.test.mjs @@ -699,3 +699,92 @@ test('geometry failure evidence retains only bounded expected and observed field assert.deepEqual(evidence.initialGeometry, initialObserverGeometry); assert.deepEqual(evidence.browserGeometry.geometry, response.session.terminalGeometry); const clock = browserGridProof(), state = browserGrid(); assert.throws(() => clock.acknowledgeBrowserGeometry('tested-pane', state, screenSession(initialObserverGeometry), state, NaN), /clock must be finite/); }); + +const { ReadinessEpoch } = await import('../dev-stack/readiness.mjs'); +function readinessObservation(policy, outcome, now) { + const probe = policy.beginProbe(); assert(probe, 'Only an active generation can be probed.'); + try { return policy.settle(probe, outcome, now); } finally { policy.release(probe); } +} + +test('initial readiness preserves the existing HTTP startup deadline and unreachable attempt bound', () => { + const waiting = new ReadinessEpoch(); waiting.start(1000); + assert.equal(readinessObservation(waiting, 'unavailable', 181000).fatal, false); + assert.equal(readinessObservation(waiting, 'unavailable', 181001).fatal, true); + const unreachable = new ReadinessEpoch(); unreachable.start(0); + for (let i = 1; i < 30; i++) assert.equal(readinessObservation(unreachable, 'unreachable', i * 4000).fatal, false); + assert.equal(readinessObservation(unreachable, 'unreachable', 120000).fatal, true); + const reset = new ReadinessEpoch(); reset.start(0); + readinessObservation(reset, 'unreachable', 4000); assert.equal(readinessObservation(reset, 'ready', 8000).failures, 0); +}); + +test('intentional API restart clears the prior ready state and failures before the replacement is observed', () => { + const policy = new ReadinessEpoch(); policy.start(0); readinessObservation(policy, 'ready', 1000); + for (let i = 1; i <= 3; i++) assert.equal(readinessObservation(policy, 'unreachable', 1000 + i * 4000).fatal, false); + policy.pause(); assert.equal(policy.beginProbe(), null); policy.start(600000); + assert.deepEqual({ lastReady: policy.diagnostic().lastReady, failures: policy.diagnostic().failures }, { lastReady: null, failures: 0 }); + assert.equal(readinessObservation(policy, 'unreachable', 604000).fatal, false); + assert.equal(readinessObservation(policy, 'unreachable', 608000).fatal, false); + assert.equal(readinessObservation(policy, 'ready', 612000).failures, 0); +}); + +test('a long shared-package compilation consumes no replacement generation startup allowance', () => { + const policy = new ReadinessEpoch(); policy.start(0); readinessObservation(policy, 'ready', 1000); + policy.pause(); assert.equal(policy.beginProbe(), null); assert.equal(policy.diagnostic().paused, true); + policy.start(900000); assert.equal(policy.diagnostic().deadline, 1080000); + assert.equal(readinessObservation(policy, 'unavailable', 1079999).fatal, false); + assert.equal(readinessObservation(policy, 'unavailable', 1080001).fatal, true); + policy.pause(); policy.start(2000000); + for (let i = 1; i <= 30; i++) assert.equal(readinessObservation(policy, 'unreachable', 2000000 + i * 4000).fatal, i === 30); +}); + +test('a late prior-epoch failure and finally cannot poison or unlock the current generation probe', async () => { + const policy = new ReadinessEpoch(); policy.start(0); + const old = policy.beginProbe(); let rejectOld; + const pending = new Promise(resolvePromise => { rejectOld = resolvePromise; }).then(() => { + try { return policy.settle(old, 'unreachable', 700000); } finally { policy.release(old); } + }); + policy.pause(); policy.start(600000); const current = policy.beginProbe(); rejectOld(); + assert.equal(await pending, null); assert.equal(policy.diagnostic().failures, 0); + assert.equal(policy.diagnostic().checking, true); assert.equal(policy.beginProbe(), null); + assert.equal(policy.settle(current, 'ready', 700000).fatal, false); policy.release(current); + assert.equal(policy.diagnostic().lastReady, true); +}); + +test('a late prior-epoch success cannot certify a replacement or erase its startup failures', async () => { + const policy = new ReadinessEpoch(); policy.start(0); const old = policy.beginProbe(); let resolveOld; + const pending = new Promise(resolvePromise => { resolveOld = resolvePromise; }).then(() => { + try { return policy.settle(old, 'ready', 900000); } finally { policy.release(old); } + }); + policy.pause(); policy.start(600000); readinessObservation(policy, 'unreachable', 604000); + const current = policy.beginProbe(); resolveOld(); assert.equal(await pending, null); + assert.equal(policy.diagnostic().lastReady, null); assert.equal(policy.diagnostic().failures, 1); + assert.equal(policy.diagnostic().checking, true); + assert.equal(policy.settle(current, 'unavailable', 780001).fatal, true); policy.release(current); +}); + +test('a ready replacement still fails after four sustained losses and a healthy response resets them', () => { + for (const outcome of ['unavailable', 'unreachable']) { + const policy = new ReadinessEpoch(); policy.start(0); readinessObservation(policy, 'ready', 1000); + for (let i = 1; i <= 3; i++) assert.equal(readinessObservation(policy, outcome, 1000000 + i * 4000).fatal, false); + assert.equal(readinessObservation(policy, 'ready', 1020000).failures, 0); + for (let i = 1; i <= 4; i++) assert.equal(readinessObservation(policy, outcome, 1020000 + i * 4000).fatal, i === 4); + } +}); + +test('only one matching readiness probe can settle and release each generation', () => { + const policy = new ReadinessEpoch(); assert.equal(policy.beginProbe(), null); + assert.throws(() => policy.start(NaN), /clock must be finite/); policy.start(0); + assert.equal(policy.settle(null, 'ready', 0), null); + const probe = policy.beginProbe(); assert.equal(policy.beginProbe(), null); + assert.equal(policy.settle({ epoch: probe.epoch }, 'ready', 0), null); + assert.throws(() => policy.settle(probe, 'unexpected', 0), /Invalid readiness/); + assert.throws(() => policy.settle(probe, 'ready', NaN), /Invalid readiness/); + assert.equal(policy.settle(probe, 'unreachable', 4000).failures, 1); + assert.equal(policy.settle(probe, 'unreachable', 8000), null); + assert.equal(policy.diagnostic().failures, 1); policy.release(probe); + const successor = policy.beginProbe(); assert(successor); policy.release(probe); + assert.equal(policy.beginProbe(), null, 'A late finally cannot release a newer probe in the same epoch.'); + policy.pause(); assert.equal(policy.settle(successor, 'ready', 9000), null); policy.release(successor); + assert.equal(policy.diagnostic().paused, true); + assert(isPublicExportPath('scripts/dev-stack/readiness.mjs')); +}); From 4d5bda9c04639af281a883d3b9e611d63c8cabc2 Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sun, 18 Oct 2026 00:11:46 +0200 Subject: [PATCH 19/27] fix: limit hot reload builds to source services --- docs/development-stack.md | 10 +++-- scripts/dev-stack/source-build.mjs | 21 ++++++++++ scripts/dev-stack/supervisor.mjs | 5 ++- scripts/public-export.mjs | 1 + scripts/tests/dev-stack.test.mjs | 66 ++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 scripts/dev-stack/source-build.mjs diff --git a/docs/development-stack.md b/docs/development-stack.md index 7fd2969c6..0bbe5f620 100644 --- a/docs/development-stack.md +++ b/docs/development-stack.md @@ -42,11 +42,15 @@ it requires an explicitly configured direct LibreOffice command and private work directory. The launcher does not install that optional tool. Changes in `apps/api/src` restart the API. Worker or shared-package source changes -stop the dependent source services, run the normal complete build, and restart -them from this checkout. Vite handles web HMR directly, including WebSocket proxying +stop the dependent source services, rebuild their shared dependencies and restart +them from this checkout. For these service reloads, the build follows the canonical +shared-workspace order through the API and worker, then normalizes build permissions. +The running Vite server serves web source and handles HMR, including WebSocket proxying to the chosen API port. A required child exiting unexpectedly produces a named error and stops the stack. Sustained readiness loss also fails instead of leaving an apparently healthy partial stack. +Initial startup and stock validation retain the complete root build, including +demo generation and production web assets. Intentional reloads invalidate earlier readiness probes. The replacement receives the same bounded startup checks after compilation and launch, then the normal four-loss readiness policy once it is ready; old responses cannot change that state. @@ -119,7 +123,7 @@ permissions, pins, environment isolation, redaction, PID reuse and failed comman These tests do not establish a working Docker stack or browser. `.github/workflows/dev-stack.yml` prepares only standard public `ubuntu-latest` -and `macos-15-intel` runners and checks the exact source. Both run all 76 launcher +and `macos-15-intel` runners and checks the exact source. Both run all 80 launcher regressions, `npm run check`, `npm run build` and `npm run hygiene:preflight`. Ubuntu also runs the full unmodified `npm test` suite. Native macOS runs all 13 private-instance API regressions in the four `instance-storage`/`dev-*-storage` diff --git a/scripts/dev-stack/source-build.mjs b/scripts/dev-stack/source-build.mjs new file mode 100644 index 000000000..73d72282a --- /dev/null +++ b/scripts/dev-stack/source-build.mjs @@ -0,0 +1,21 @@ +// Match the canonical root build order for the source services. Vite already +// serves web source; production web bundling and demo generation belong to the +// unchanged complete startup/stock build, not each service hot reload. +export const sourceReloadWorkspaces = Object.freeze([ + '@space/contracts', '@space/icons', '@space/streaming', + '@space/opencode-control', '@space/memory-graph', '@space/browser-host', + '@space/cli-host', '@space/codex-app-server', '@space/runtime', '@space/db', + '@space/gateway', '@space/api', '@space/worker', +]); +export const sourceReloadBuildPlan = Object.freeze([ + ...sourceReloadWorkspaces.map(name => Object.freeze({ executable: 'npm', args: Object.freeze(['run', 'build', '-w', name]) })), + Object.freeze({ executable: 'node', args: Object.freeze(['scripts/fix-build-permissions.mjs']) }), +]); + +export async function buildSourceServices(invoke, isStopping) { + for (const step of sourceReloadBuildPlan) { + if (isStopping()) throw new Error('Source rebuild cancelled before relaunch.'); + await invoke(step.executable, step.args); + } + if (isStopping()) throw new Error('Source rebuild cancelled before relaunch.'); +} diff --git a/scripts/dev-stack/supervisor.mjs b/scripts/dev-stack/supervisor.mjs index 4b855637d..4a820d4fb 100644 --- a/scripts/dev-stack/supervisor.mjs +++ b/scripts/dev-stack/supervisor.mjs @@ -10,6 +10,7 @@ import { assertOwnedDocker, compose, dockerEnvironment, removeOwnedDocker, run } import { verifyOpenCode } from './opencode-runtime.mjs'; import { verifyPtyCapability } from './pty-capability.mjs'; import { ReadinessEpoch } from './readiness.mjs'; +import { buildSourceServices } from './source-build.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); const say = text => process.stdout.write(`[dev-stack] ${text}\n`); @@ -63,7 +64,7 @@ export async function start(config, inherited = process.env) { const perform = fn => { activeCommand = fn(); return activeCommand; }; const recordChildren = () => { record.children = [...children.values()].map(x => x.record); const text = JSON.stringify(record); writeTail = writeTail.then(() => privateWrite(config.supervisor, text)); return writeTail; }; const log = chunk => process.stdout.write(chunk); - async function command(args) { return perform(() => run('npm', args, { cwd: config.root, env, secrets: redactions, output: log, signal: cancellation.signal })); } + async function command(args, executable = 'npm') { return perform(() => run(executable, args, { cwd: config.root, env, secrets: redactions, output: log, signal: cancellation.signal })); } const stopChildren = (names = [...children.keys()]) => { const job = stopTail.then(async () => { for (const name of names) { @@ -115,7 +116,7 @@ export async function start(config, inherited = process.env) { try { const names = kind === 'api' ? ['api'] : ['api', 'worker', 'cli-host']; await stopChildren(names); - if (kind !== 'api') await command(['run', 'build']); + if (kind !== 'api') await buildSourceServices((executable, args) => command(args, executable === 'node' ? process.execPath : 'npm'), () => stopping); if (stopping) return; if (kind === 'api') await launch('api', ['--import', 'tsx', 'apps/api/src/server.ts']); else await sourceServices(); if (stopping) return; diff --git a/scripts/public-export.mjs b/scripts/public-export.mjs index 3c48ec73d..765871e21 100644 --- a/scripts/public-export.mjs +++ b/scripts/public-export.mjs @@ -85,6 +85,7 @@ const publicScripts = new Set([ "scripts/dev-stack/resources.mjs", "scripts/dev-stack/supervisor.mjs", "scripts/dev-stack/readiness.mjs", + "scripts/dev-stack/source-build.mjs", "scripts/dev-stack/opencode-wrapper.mjs", "scripts/dev-stack/opencode-runtime.mjs", "scripts/dev-stack/pty-capability.mjs", diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs index eaf422bb4..bcc1791b0 100644 --- a/scripts/tests/dev-stack.test.mjs +++ b/scripts/tests/dev-stack.test.mjs @@ -788,3 +788,69 @@ test('only one matching readiness probe can settle and release each generation', assert.equal(policy.diagnostic().paused, true); assert(isPublicExportPath('scripts/dev-stack/readiness.mjs')); }); + +const { sourceReloadWorkspaces, sourceReloadBuildPlan, buildSourceServices } = await import('../dev-stack/source-build.mjs'); + +test('source reload preserves canonical workspace order and the complete API worker CLI dependency closure', async () => { + const { readdir } = await import('node:fs/promises'); + const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')); + const canonical = [...manifest.scripts.build.matchAll(/npm run build -w (@space\/[a-z-]+)/g)].map(x => x[1]); + const packages = new Map(); + for (const directory of ['packages', 'apps']) for (const item of await readdir(join(root, directory), { withFileTypes: true })) { + if (!item.isDirectory()) continue; + const value = await readFile(join(root, directory, item.name, 'package.json'), 'utf8').then(JSON.parse).catch(error => { if (error.code === 'ENOENT') return null; throw error; }); + if (value) packages.set(value.name, value); + } + const required = new Set(); + const include = name => { + if (required.has(name)) return; required.add(name); + const value = packages.get(name); assert(value, `Missing required workspace ${name}`); + for (const dependency of Object.keys({ ...value.dependencies, ...value.optionalDependencies, ...value.devDependencies }).filter(x => x.startsWith('@space/'))) include(dependency); + }; + for (const name of ['@space/api', '@space/worker', '@space/cli-host']) include(name); + assert.deepEqual(sourceReloadWorkspaces, canonical.filter(name => name !== '@space/web')); + assert.deepEqual(sourceReloadWorkspaces.filter(name => required.has(name)), canonical.filter(name => required.has(name))); + for (const name of required) { + assert(sourceReloadWorkspaces.includes(name), `Reload omits ${name}`); + const value = packages.get(name); + for (const dependency of Object.keys({ ...value.dependencies, ...value.optionalDependencies, ...value.devDependencies }).filter(x => x.startsWith('@space/'))) assert(sourceReloadWorkspaces.indexOf(dependency) < sourceReloadWorkspaces.indexOf(name), `${dependency} must be built before ${name}`); + } + assert(sourceReloadWorkspaces.includes('@space/icons')); assert.equal(sourceReloadWorkspaces.length, required.size + 1); + assert(isPublicExportPath('scripts/dev-stack/source-build.mjs')); +}); + +test('hot service builds finish strictly in canonical order with permissions before a replacement can launch', async () => { + const calls = []; let active = 0, launched = false; + const completion = buildSourceServices(async (executable, args) => { + assert.equal(++active, 1, 'Builds must not overlap.'); assert.equal(launched, false); + calls.push({ executable, args: [...args] }); await new Promise(resolvePromise => setImmediate(resolvePromise)); active--; + }, () => false).then(() => { launched = true; }); + assert.equal(launched, false); await completion; assert.equal(launched, true); + assert.deepEqual(calls, [...sourceReloadWorkspaces.map(name => ({ executable: 'npm', args: ['run', 'build', '-w', name] })), { executable: 'node', args: ['scripts/fix-build-permissions.mjs'] }]); + assert(Object.isFrozen(sourceReloadBuildPlan)); assert(sourceReloadBuildPlan.every(step => Object.isFrozen(step) && Object.isFrozen(step.args))); + assert.equal(calls.some(step => step.args.includes('@space/web') || step.args.includes('scripts/build-demo-projects.mjs')), false); + assert.equal(calls.some(step => step.executable === 'npm' && step.args.length === 2), false, 'The frozen old full-root invocation would rebuild production web/demos on this path.'); +}); + +test('failure in any shared service or permission build prevents all later commands and relaunch', async () => { + for (let failureAt = 0; failureAt < sourceReloadBuildPlan.length; failureAt++) { + const failure = new Error(`synthetic build failure ${failureAt}`), calls = []; let launched = false; + await assert.rejects(buildSourceServices(async (executable, args) => { + calls.push({ executable, args }); if (calls.length - 1 === failureAt) throw failure; + }, () => false).then(() => { launched = true; }), error => error === failure); + assert.equal(calls.length, failureAt + 1); assert.equal(launched, false); + } +}); + +test('source rebuild cancellation before during and after commands never permits a later step or relaunch', async () => { + const calls = []; await assert.rejects(buildSourceServices(async () => { calls.push('unexpected'); }, () => true), /cancelled before relaunch/); assert.equal(calls.length, 0); + let stopping = false, release, launched = false; + const pending = buildSourceServices(async executable => { + calls.push(executable); await new Promise(resolvePromise => { release = resolvePromise; }); + }, () => stopping).then(() => { launched = true; }); + stopping = true; release(); await assert.rejects(pending, /cancelled before relaunch/); + assert.equal(calls.length, 1); assert.equal(launched, false); + stopping = false; let finished = 0; + await assert.rejects(buildSourceServices(async () => { if (++finished === sourceReloadBuildPlan.length) stopping = true; }, () => stopping).then(() => { launched = true; }), /cancelled before relaunch/); + assert.equal(finished, sourceReloadBuildPlan.length); assert.equal(launched, false); +}); From c6f7fa1f400be80e28943e099a19346484f38c23 Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sun, 18 Oct 2026 01:50:35 +0200 Subject: [PATCH 20/27] docs: document source development stack in changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4801b206..04b7be71f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ Semantic Versioning. ## [Unreleased] +### Added + +- A source development stack (`npm run dev:stack`) with isolated local state, + service hot reload, and documented setup, stop, and reset commands. + ## [1.1.6] - 2026-10-16 Source: sanitized Space app @ eebf761963 (4362 files). Adds install telemetry From ec57e6beb4992329a8c571b7c5e846c8321bb0da Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sun, 18 Oct 2026 02:49:53 +0200 Subject: [PATCH 21/27] fix: address source development stack review findings --- .github/workflows/dev-stack.yml | 5 +- CHANGELOG.md | 7 ++ apps/api/src/cli-agent-files.ts | 16 +++- apps/api/src/instance-storage.ts | 13 ++- apps/api/src/space-control.ts | 5 +- apps/api/tests/dev-docx-isolation.test.ts | 13 ++- apps/api/tests/instance-storage.test.ts | 12 ++- .../tests/space-control-governance.test.ts | 28 +++++++ docs/development-stack.md | 18 ++--- package-lock.json | 1 + package.json | 1 + scripts/dev-stack/acceptance.mjs | 2 +- scripts/dev-stack/lifecycle-proof.mjs | 6 +- scripts/dev-stack/pty-capability.mjs | 2 +- scripts/dev-stack/reload-debounce.mjs | 20 +++++ scripts/dev-stack/resources.mjs | 14 +++- scripts/dev-stack/supervisor.mjs | 16 ++-- scripts/public-export.mjs | 1 + scripts/tests/dev-stack.test.mjs | 79 +++++++++++++++++++ 19 files changed, 228 insertions(+), 31 deletions(-) create mode 100644 scripts/dev-stack/reload-debounce.mjs diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml index f9f410bbc..62194deba 100644 --- a/.github/workflows/dev-stack.yml +++ b/.github/workflows/dev-stack.yml @@ -7,6 +7,8 @@ on: - "scripts/dev-stack/**" - "scripts/tests/dev-stack.test.mjs" - "apps/web/vite.config.ts" + - "apps/web/**" + - ".nvmrc" - "packages/**" - "apps/api/**" - "apps/worker/**" @@ -19,7 +21,7 @@ on: workflow_dispatch: inputs: expected_source: - description: "Optional exact40hex reviewed source SHA; mismatch fails before executing project code" + description: "Optional exact 40-character reviewed source SHA; mismatch fails before executing project code" type: string required: false @@ -127,6 +129,7 @@ jobs: node node_modules/vitest/vitest.mjs run --config apps/api/vitest.config.ts apps/api/tests/instance-storage.test.ts apps/api/tests/dev-oauth-storage.test.ts apps/api/tests/dev-docx-isolation.test.ts apps/api/tests/dev-incidents-storage.test.ts + apps/api/tests/space-control-governance.test.ts - name: Native macOS CLI host regressions if: runner.os == 'macOS' diff --git a/CHANGELOG.md b/CHANGELOG.md index 04b7be71f..f48f21fdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ Semantic Versioning. ## [Unreleased] +### Fixed + +- Preserve package reload priority and recovery before Compose setup in the source + development stack. +- Validate isolated storage roots and preserve typed DOCX errors and MCP diagnostics + in new development instances. + ### Added - A source development stack (`npm run dev:stack`) with isolated local state, diff --git a/apps/api/src/cli-agent-files.ts b/apps/api/src/cli-agent-files.ts index 7f04b41a7..670040e9b 100644 --- a/apps/api/src/cli-agent-files.ts +++ b/apps/api/src/cli-agent-files.ts @@ -201,6 +201,11 @@ export function resolveAgentFileMimeType(originalFilename: string, declaredMimeT return "application/octet-stream"; } +/** + * Rebuild a DOCX package through an explicit direct command or the stable installed broker. + * Dev work-root rejection becomes a typed normalization error before writes or child execution; + * conversion uses a private request directory, which is removed after success or failure. + */ export async function normalizeAgentFileDocx( input: AgentFileDocxNormalizerInput, options: NormalizeAgentFileDocxOptions = {} @@ -213,9 +218,14 @@ export async function normalizeAgentFileDocx( if (dev && !directCommand) { throw new AgentFileDocxNormalizationError("Dev DOCX normalization requires an explicit direct LibreOffice command; the installed privileged broker is not used."); } - const temporaryRoot = dev - ? instanceStorageDirectory("agent-docx-work", options.temporaryRoot ?? process.env.SPACE_AGENT_FILE_DOCX_WORK_ROOT) - : options.temporaryRoot ?? (directCommand ? tmpdir() : process.env.SPACE_AGENT_FILE_DOCX_WORK_ROOT ?? "/opt/spaceapp/var/agent-docx-work"); + let temporaryRoot: string; + try { + temporaryRoot = dev + ? instanceStorageDirectory("agent-docx-work", options.temporaryRoot ?? process.env.SPACE_AGENT_FILE_DOCX_WORK_ROOT) + : options.temporaryRoot ?? (directCommand ? tmpdir() : process.env.SPACE_AGENT_FILE_DOCX_WORK_ROOT ?? "/opt/spaceapp/var/agent-docx-work"); + } catch (error) { + throw new AgentFileDocxNormalizationError(error instanceof Error ? error.message : undefined); + } await mkdir(temporaryRoot, { recursive: true, mode: 0o700 }); const workingRoot = await mkdtemp(join(temporaryRoot, "request-")); const inputDirectory = join(workingRoot, "input"); diff --git a/apps/api/src/instance-storage.ts b/apps/api/src/instance-storage.ts index 5f03dd6c0..3f4bf2ba6 100644 --- a/apps/api/src/instance-storage.ts +++ b/apps/api/src/instance-storage.ts @@ -1,12 +1,21 @@ import { isAbsolute, relative, resolve, sep, join } from "node:path"; import { resolveSpaceInstance } from "@space/contracts"; +/** Resolve a dev root disjoint from the installed tree; ancestors and descendants fail before any storage access. */ function privateInstallRoot(env: NodeJS.ProcessEnv): string { const configured = env.SPACE_INSTALL_ROOT; - if (!configured || !isAbsolute(configured) || resolve(configured) === resolve("/opt/spaceapp")) { + if (!configured || !isAbsolute(configured)) { throw new Error("Dev service storage requires a private absolute SPACE_INSTALL_ROOT."); } - return resolve(configured); + const root = resolve(configured), installed = resolve("/opt/spaceapp"); + const contains = (parent: string, target: string) => { + const inside = relative(parent, target); + return inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside); + }; + if (contains(root, installed) || contains(installed, root)) { + throw new Error("Dev service storage requires a private absolute SPACE_INSTALL_ROOT."); + } + return root; } /** Keep installed defaults; a dev instance's mutable service data stays under its own install root. */ diff --git a/apps/api/src/space-control.ts b/apps/api/src/space-control.ts index bad00d6a2..a2806e138 100644 --- a/apps/api/src/space-control.ts +++ b/apps/api/src/space-control.ts @@ -1575,6 +1575,7 @@ export function createSpaceControl(options:{store:SpaceStore;repository:ControlR }; } + /** Run selected bounded probes, retain skips as WARN, and record failures privately without replacing their outcome if logging fails. */ async function testMcpTools(actor: ControlActor, targetRoomId?: string, params?: { tools?: string[]; fast?: boolean }) { let effectiveRoomId = targetRoomId; if (!effectiveRoomId) { @@ -1800,7 +1801,9 @@ export function createSpaceControl(options:{store:SpaceStore;repository:ControlR failedTools: failedTools.map(f => ({ tool: f.tool, error: f.error })), agentHandoffPrompt }) + "\n"; - appendFileSync(join(instanceStorageDirectory("live-voice-logs"), "debug-incidents.jsonl"), line); + const directory = instanceStorageDirectory("live-voice-logs"); + await fsPromises.mkdir(directory, { recursive: true, mode: 0o700 }); + appendFileSync(join(directory, "debug-incidents.jsonl"), line); } catch {} } diff --git a/apps/api/tests/dev-docx-isolation.test.ts b/apps/api/tests/dev-docx-isolation.test.ts index 348d467ea..52b457ece 100644 --- a/apps/api/tests/dev-docx-isolation.test.ts +++ b/apps/api/tests/dev-docx-isolation.test.ts @@ -1,6 +1,6 @@ import { join, relative, isAbsolute } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { normalizeAgentFileDocx } from "../src/cli-agent-files.js"; +import { AgentFileDocxNormalizationError, normalizeAgentFileDocx } from "../src/cli-agent-files.js"; const boundary = vi.hoisted(() => ({ directories: [] as string[], writes: [] as string[], commands: [] as { command: string; args: string[]; env?: NodeJS.ProcessEnv }[] })); // Every filesystem/process boundary is virtual, including the historical installed @@ -60,6 +60,17 @@ describe("dev DOCX normalization isolation", () => { expect(boundary.directories).toEqual([]); expect(boundary.commands).toEqual([]); }); + it.each([ + { install, temporaryRoot: "relative" }, + { install, temporaryRoot: "/opt/spaceapp/var/agent-docx-work" }, + { install: undefined, temporaryRoot: undefined }, + { install: "/opt", temporaryRoot: undefined } + ])("maps private work-root rejection to the DOCX error before any side effect: %j", async ({ install: configured, temporaryRoot }) => { + vi.stubEnv("SPACE_INSTALL_ROOT", configured); + await expect(normalizeAgentFileDocx(input, { command: "/synthetic/libreoffice", temporaryRoot })).rejects.toBeInstanceOf(AgentFileDocxNormalizationError); + expect(boundary.directories).toEqual([]); expect(boundary.writes).toEqual([]); expect(boundary.commands).toEqual([]); + }); + it("preserves the stable work directory and broker arguments without executing either", async () => { vi.stubEnv("SPACE_INSTANCE", "stable"); await normalizeAgentFileDocx(input); diff --git a/apps/api/tests/instance-storage.test.ts b/apps/api/tests/instance-storage.test.ts index bf6b2e1c3..bf0d98f0d 100644 --- a/apps/api/tests/instance-storage.test.ts +++ b/apps/api/tests/instance-storage.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promis import { tmpdir } from "node:os"; import { join, relative, resolve, sep } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { instanceStorageDirectory } from "../src/instance-storage.js"; +import { instanceSecretDirectories, instanceStorageDirectory } from "../src/instance-storage.js"; import { PluginsService } from "../src/plugins-service.js"; const temporaryRoots: string[] = []; @@ -54,6 +54,16 @@ describe("instance-local service storage", () => { expect(instanceStorageDirectory("personal-source-control", credentials, first)).toBe(credentials); }); + it("rejects install roots on either side of the installed storage boundary", () => { + for (const SPACE_INSTALL_ROOT of ["/", "/opt", "/opt/spaceapp", "/opt/spaceapp/var", "/opt/spaceapp/../spaceapp/var"]) { + const env = { SPACE_INSTANCE: "dev", SPACE_INSTALL_ROOT }; + expect(() => instanceStorageDirectory("plugins", "/opt/spaceapp/var/plugins", env)).toThrow(/private absolute/); + expect(() => instanceSecretDirectories(env)).toThrow(/private absolute/); + } + const root = resolve("/opt/spaceapp-independent"); + expect(instanceStorageDirectory("plugins", undefined, { SPACE_INSTANCE: "dev", SPACE_INSTALL_ROOT: root })).toBe(join(root, "var", "plugins")); + }); + it("persists real dev plugin changes privately without syncing operator skill links", async () => { const root = await fixture(); const install = join(root, "install"), operator = join(root, "operator-skills"); diff --git a/apps/api/tests/space-control-governance.test.ts b/apps/api/tests/space-control-governance.test.ts index 80c92291c..fc878ccd6 100644 --- a/apps/api/tests/space-control-governance.test.ts +++ b/apps/api/tests/space-control-governance.test.ts @@ -3,6 +3,9 @@ import { InMemorySpaceStore } from "@space/runtime"; import { InMemoryControlRepository } from "@space/db"; import { controlExecuteSchema, SPACE_CONTROL_VERSION } from "@space/contracts"; import { createSpaceControl } from "../src/space-control.js"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; const actor = { id: "operator:one", role: "ADMIN" as const }; @@ -264,6 +267,31 @@ describe("Space Control Governance & Security", () => { expect(result).toMatchObject({ status: "PARTIAL", passedCount: 0, failedCount: 0, warnedCount: 2 }); }); + it("persists failed MCP-probe incidents when the private log directory does not yet exist", async () => { + const root = await mkdtemp(join(tmpdir(), "space-mcp-incident-")); + const install = join(root, "install"); + const file = join(install, "var", "live-voice-logs", "debug-incidents.jsonl"); + const removeFixture = async () => { + const inside = relative(resolve(tmpdir()), resolve(root)); + if (!inside.startsWith("space-mcp-incident-") || inside.includes(sep) || isAbsolute(inside)) throw new Error("Refusing cleanup outside the private MCP fixture."); + await rm(root, { recursive: true, force: true }); + }; + vi.stubEnv("SPACE_INSTANCE", "dev"); vi.stubEnv("SPACE_INSTALL_ROOT", install); + try { + await expect(stat(join(install, "var", "live-voice-logs"))).rejects.toMatchObject({ code: "ENOENT" }); + const c = fixture({ inspectExtra: async () => { throw new Error("Synthetic MCP probe failure"); } }); + const result = await c.service.testMcpTools(actor, c.room.id, { tools: ["space_inspect:SYSTEM_HEALTH"] }); + expect(result).toMatchObject({ status: "PARTIAL", testedCount: 1, passedCount: 0, failedCount: 1 }); + expect(JSON.parse(await readFile(file, "utf8"))).toMatchObject({ + incidentId: result.incidentId, operation: "space_test_mcp_tools", roomId: c.room.id, failedCount: 1, + failedTools: [{ tool: "space_inspect:SYSTEM_HEALTH", error: "Synthetic MCP probe failure" }] + }); + } finally { + vi.unstubAllEnvs(); + await removeFixture(); + } + }); + it("does not enqueue transient commands or claim success for a disconnected client", async () => { const c = fixture({ queueDisconnected: true }); const result = await c.execute([{ kind: "playback", operation: "next" }]); diff --git a/docs/development-stack.md b/docs/development-stack.md index 0bbe5f620..a625e2135 100644 --- a/docs/development-stack.md +++ b/docs/development-stack.md @@ -8,9 +8,9 @@ not an installed operator instance or a desktop packaging command. ## Prerequisites and startup -Use Node22 from `.nvmrc`, npm, Docker Engine/Desktop with ComposeV2, and the normal +Use Node 22 from `.nvmrc`, npm, Docker Engine/Desktop with Compose V2, and the normal native build tools for `node-pty` if its prebuilt package is unavailable. On macOS, -an existing Docker Desktop or Colima context must already work. On Windows11 use +an existing Docker Desktop or Colima context must already work. On Windows 11 use an existing WSL2 Linux environment with Docker integration; native Windows is not a Unix-socket runtime. The launcher never installs Docker/WSL, requests elevation, changes OS features or reboots. WSL2 instructions describe the supported route; @@ -60,8 +60,8 @@ diagnostic cannot skip the normal ownership checks or cleanup. ## State, ports and privacy -Defaults are API4810, web4811, PostgreSQL54329 and Temporal47233. All listeners bind -to127.0.0.1. Installed defaults4910/4911 are rejected. To run a second checkout or +Defaults are API `4810`, web `4811`, PostgreSQL `54329` and Temporal `47233`. All listeners bind +to `127.0.0.1`. Installed defaults `4910`/`4911` are rejected. To run a second checkout or separate instance, choose four distinct unused ports and a different direct child state directory: @@ -85,8 +85,8 @@ configuration/agent if that context requires it. Generated state defaults to `.dev-stack/` and is ignored by Git. Only recognizable direct children `.dev-stack` or `.dev-stack-` are allowed; linked state/control -paths are rejected. The state directory is0700 and generated secrets/manifests/ -Compose configuration are0600. The PostgreSQL password is in the private generated +paths are rejected. The state directory is `0700` and generated secrets/manifests/ +Compose configuration are `0600`. The PostgreSQL password is in the private generated Compose file as container environment, avoiding weaker world-readable bind-mounted secrets. A Docker administrator can inspect container environment; this does not isolate a secret from the host/Docker administrator. Never print `docker compose @@ -147,7 +147,7 @@ actual compiled PTY spawn entry for another exact-output `printf` control. It records whether the selected native `stty` path is already executable. This does not substitute for the later actual OpenCode/browser interaction. On Darwin only, a real failed `posix_spawnp` probe plus the exact published -node-pty1.1.0 helper hash, mode0644, checkout confinement, ownership and single +node-pty 1.1.0 helper hash, mode `0644`, checkout confinement, ownership and single link can authorize restoring that one helper's execute bits. The failure, modes, unchanged bytes and successful second real probe are retained. Other failures are not repaired or hidden; no installed/global dependency path is changed. @@ -181,7 +181,7 @@ cookies, session URLs and environments are excluded. A missing `stty` message an a CDP cookie timeout are distinct observations, without an assumed shared cause. After those gates, `scripts/dev-stack/lifecycle-proof.mjs` starts a real stack with -synthetic installed ports4910/4911 occupied by an independent process. It exercises +synthetic installed ports `4910`/`4911` occupied by an independent process. It exercises HTTP owner claim and sign-in, invalidates the consumed token, creates the actual Getting Started/OpenCode pane, observes the real PTY UI, and renders that pane in the runner’s existing headless Chrome. Its current matching API/WS session must @@ -231,7 +231,7 @@ node scripts/dev-stack/lifecycle-proof.mjs ``` Only use a disposable development host: the proof deliberately edits/restores -three source files and binds4910/4911. It refuses pre-existing proof state and never +three source files and binds `4910`/`4911`. It refuses pre-existing proof state and never adopts an installed instance. Hosted transcripts certify their actual platform and commit only; no unperformed Windows, provider-inference or desktop-install result is implied. diff --git a/package-lock.json b/package-lock.json index 8fb65e91c..979258193 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^5.1.2", + "@xterm/headless": "^5.5.0", "eslint": "10.11.0", "eslint-plugin-react-hooks": "7.1.1", "fastify": "5.12.5", diff --git a/package.json b/package.json index af270f67e..7fd92d9c9 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^5.1.2", + "@xterm/headless": "^5.5.0", "eslint": "10.11.0", "eslint-plugin-react-hooks": "7.1.1", "fastify": "5.12.5", diff --git a/scripts/dev-stack/acceptance.mjs b/scripts/dev-stack/acceptance.mjs index 9365490db..645843d1f 100644 --- a/scripts/dev-stack/acceptance.mjs +++ b/scripts/dev-stack/acceptance.mjs @@ -34,7 +34,7 @@ try { await eventually(async () => { const response = await fetch(`${api}/readyz`, { signal: AbortSignal.timeout(3000) }); const value = await response.json(); return response.ok && value.ok === true; }, 'Workflows-enabled readyz'); marker('real readiness'); assert.equal((await (await fetch(`${origin}/readyz`)).json()).ok, true); marker('Vite forwards to the isolated API port'); for (const name of ['postgres-password', 'session-secret', 'setup-token']) assert.equal((await stat(join(config.state, 'secrets', name))).mode & 0o777, 0o600); - assert.equal((await stat(config.compose)).mode & 0o777, 0o600); marker('all generated secret/config files0600'); + assert.equal((await stat(config.compose)).mode & 0o777, 0o600); marker('all generated secret/config files 0600'); token = await readFile(join(config.state, 'secrets/setup-token'), 'utf8'); password = randomBytes(24).toString('hex'); const email = 'dev-stack-owner@example.invalid'; const claimed = await request('/api/setup/claim', { token, email, password }); assert.equal(claimed.isAuthenticated, true); diff --git a/scripts/dev-stack/lifecycle-proof.mjs b/scripts/dev-stack/lifecycle-proof.mjs index 585bb2552..befcfedea 100644 --- a/scripts/dev-stack/lifecycle-proof.mjs +++ b/scripts/dev-stack/lifecycle-proof.mjs @@ -63,7 +63,7 @@ async function waitReady(stack) { } catch { /* Startup can legitimately take time before the API binds. */ } await delay(1000); } - throw new Error('Stack readiness exceeded15minutes; no acceptance is claimed.'); + throw new Error('Stack readiness exceeded 15 minutes; no acceptance is claimed.'); } async function processSnapshot(config) { const record = await readJson(config.supervisor), result = []; @@ -95,7 +95,7 @@ async function action(settings, verb, options = {}) { try { const sentinelSource = `const http=require('node:http'); const servers=[4910,4911].map(port=>http.createServer((_q,r)=>r.end(${JSON.stringify(nonce)})).listen(port,'127.0.0.1')); let ready=0; for(const s of servers){s.on('listening',()=>{if(++ready===2)process.send('ready')});s.on('error',()=>process.exit(1))}`; sentinel = spawn(process.execPath, ['-e', sentinelSource], { env: { PATH: process.env.PATH }, stdio: ['ignore', 'ignore', 'inherit', 'ipc'] }); - await new Promise((done, reject) => { sentinel.once('message', done); sentinel.once('error', reject); sentinel.once('exit', () => reject(new Error('Could not reserve synthetic installed ports4910/4911.'))); }); + await new Promise((done, reject) => { sentinel.once('message', done); sentinel.once('error', reject); sentinel.once('exit', () => reject(new Error('Could not reserve synthetic installed ports 4910/4911.'))); }); sentinelRecord = { pid: sentinel.pid, identity: await processIdentity(sentinel.pid) }; const pins = extractPins(await readFile(join(root, 'packages/run-spaceapp/templates/compose.yml'), 'utf8'), await readFile(join(root, 'Dockerfile'), 'utf8')); await docker(['pull', pins.postgres], { timeout: 300000 }); @@ -105,7 +105,7 @@ try { await assertSentinels(); const first = await configuration('a'), stack = await capture(first, 'normal-stack'); await waitReady(stack); const owner = await loadOwner(first.config); - mark('normal workflows-enabled startup with installed ports4910/4911 already occupied'); + mark('normal workflows-enabled startup with installed ports 4910/4911 already occupied'); const firstSecretHashes = await Promise.all(['postgres-password', 'session-secret'].map(async name => createHash('sha256').update(await readFile(join(first.config.state, 'secrets', name))).digest('hex'))); await run(process.execPath, ['scripts/dev-stack/acceptance.mjs'], { cwd: root, env: first.env, timeout: 900000, output: text => process.stdout.write(text) }); const records = await processSnapshot(first.config); diff --git a/scripts/dev-stack/pty-capability.mjs b/scripts/dev-stack/pty-capability.mjs index c0ace1df1..26279c223 100644 --- a/scripts/dev-stack/pty-capability.mjs +++ b/scripts/dev-stack/pty-capability.mjs @@ -60,7 +60,7 @@ export async function verifyPtyCapability(root, state, { inherited = {}, evidenc if (!knownHelperRepair({ ...helper, sha256, mode: info.mode & 0o777, uid: info.uid, nlink: info.nlink })) throw new Error('PTY helper changed; refusing repair.'); await confinedFile(root, file); await handle.chmod(0o755); } finally { await handle.close(); } - proof.repair = { reason: 'Actual posix_spawnp failure and exact pinned private helper0644', before: helper.mode, after: (await lstat(file)).mode & 0o777, bytesUnchanged: await fileHash(file) === helper.sha256 }; + proof.repair = { reason: 'Actual posix_spawnp failure and exact pinned private helper 0644', before: helper.mode, after: (await lstat(file)).mode & 0o777, bytesUnchanged: await fileHash(file) === helper.sha256 }; assert.equal(proof.repair.bytesUnchanged, true); if (!await probe()) throw new Error('Real printf PTY still fails after the bounded helper permission correction.'); } diff --git a/scripts/dev-stack/reload-debounce.mjs b/scripts/dev-stack/reload-debounce.mjs new file mode 100644 index 000000000..38147c554 --- /dev/null +++ b/scripts/dev-stack/reload-debounce.mjs @@ -0,0 +1,20 @@ +/** + * Coalesce source edits for 400 ms, retaining package rebuilds over API-only restarts. + * stop() cancels pending work and permanently ignores later watcher notifications. + * The supplied rebuild callback owns asynchronous failure handling. + */ +export function debounceSourceChanges(rebuild) { + let pending = null, timer, stopped = false; + return { + change(kind) { + if (stopped) return; + pending = pending === 'packages' || kind === 'packages' ? 'packages' : 'api'; + clearTimeout(timer); + timer = setTimeout(() => { + const next = pending; pending = null; + void rebuild(next); + }, 400); + }, + stop() { stopped = true; clearTimeout(timer); pending = null; }, + }; +} diff --git a/scripts/dev-stack/resources.mjs b/scripts/dev-stack/resources.mjs index 16fd42805..9e7559cbd 100644 --- a/scripts/dev-stack/resources.mjs +++ b/scripts/dev-stack/resources.mjs @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process'; +import { lstat } from 'node:fs/promises'; import { ownerLabel } from './state.mjs'; import { descendants, lineRedactor, processIdentity, sameProcess, stopRecorded } from './processes.mjs'; @@ -114,15 +115,24 @@ export async function diagnoseOwnedDocker(config, owner, { output = () => {}, se await control('docker', composeArgs(config, owner, ['logs', '--no-color', '--tail', '100', 'postgres', 'temporal']), { secrets, output: chunk => redactor.chunk(chunk) }); } finally { redactor.end(); } } +/** + * Remove only the nonce-owned Compose project, retaining volumes unless reset requests them. + * Missing Compose is recoverable only with no remaining labeled resources; unsafe files, + * ownership changes and cleanup failures propagate so the caller retains recovery state. + */ export async function removeOwnedDocker(config, owner, { volumes = false, output, secrets, diagnose = false, invoke = run, env = dockerEnvironment() } = {}) { await assertOwnedDocker(owner, invoke, env); if (diagnose) { try { await diagnoseOwnedDocker(config, owner, { output, secrets, invoke, env }); } catch { output?.('[dev-stack] Bounded diagnostics unavailable; continuing owned cleanup.\n'); } } - await invoke('docker', composeArgs(config, owner, ['down', '--remove-orphans', ...(volumes ? ['--volumes'] : [])]), { env, output, secrets, timeout: 120000 }); + const document = await lstat(config.compose).catch(error => { if (error.code === 'ENOENT') return null; throw error; }); + if (document) { + if (!document.isFile() || document.isSymbolicLink()) throw new Error('Compose document must be a regular non-symlink file; recovery state retained.'); + await invoke('docker', composeArgs(config, owner, ['down', '--remove-orphans', ...(volumes ? ['--volumes'] : [])]), { env, output, secrets, timeout: 120000 }); + } await assertOwnedDocker(owner, invoke, env); - for (const kind of ['container', 'network', ...(volumes ? ['volume'] : [])]) { + for (const kind of ['container', 'network', ...(volumes || !document ? ['volume'] : [])]) { const remaining = await invoke('docker', [kind, 'ls', ...(kind === 'container' ? ['-a'] : []), '-q', '--filter', `label=com.docker.compose.project=${owner.project}`], { env }); if (remaining.trim()) throw new Error(`Owned ${kind} remains; recovery state retained.`); } diff --git a/scripts/dev-stack/supervisor.mjs b/scripts/dev-stack/supervisor.mjs index 4a820d4fb..83f93c4c8 100644 --- a/scripts/dev-stack/supervisor.mjs +++ b/scripts/dev-stack/supervisor.mjs @@ -11,10 +11,12 @@ import { verifyOpenCode } from './opencode-runtime.mjs'; import { verifyPtyCapability } from './pty-capability.mjs'; import { ReadinessEpoch } from './readiness.mjs'; import { buildSourceServices } from './source-build.mjs'; +import { debounceSourceChanges } from './reload-debounce.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); const say = text => process.stdout.write(`[dev-stack] ${text}\n`); async function assertFreePort(port) { await new Promise((resolvePromise, reject) => { const s = createServer(); s.once('error', () => reject(new Error(`Loopback port ${port} is already in use; choose SPACE_DEV_*_PORT values.`))); s.listen(port, '127.0.0.1', () => s.close(resolvePromise)); }); } +/** Stop verified owned processes and containers; preserve database/state and propagate failures for recovery. */ export async function down(config) { const owner = await loadOwner(config); const record = await readJson(config.supervisor).catch(e => { if (e.code === 'ENOENT') return null; throw e; }); @@ -33,6 +35,7 @@ export async function down(config) { await unlink(config.supervisor).catch(e => { if (e.code !== 'ENOENT') throw e; }); say('Stopped owned services; state/volumes retained for the next start.'); } +/** After owned shutdown and nonce/path rechecks, remove this stack's volumes and private state only. */ export async function reset(config) { await assertPlainState(config); if (!await lstat(config.state).catch(e => { if (e.code === 'ENOENT') return null; throw e; })) { say('No dev-stack state to reset.'); return; } @@ -44,8 +47,9 @@ export async function reset(config) { await rm(config.state, { recursive: true }); say('Removed only this dev stack’s containers, volumes and state.'); } +/** Start isolated source services; supervise reload/readiness and keep recovery records when owned shutdown fails. */ export async function start(config, inherited = process.env) { - if (!['linux', 'darwin'].includes(process.platform)) throw new Error('Run dev:stack in Linux, macOS, or Windows11 WSL2. Native Windows is not the supported Unix-socket environment.'); + if (!['linux', 'darwin'].includes(process.platform)) throw new Error('Run dev:stack in Linux, macOS, or Windows 11 WSL2. Native Windows is not the supported Unix-socket environment.'); await run('docker', ['compose', 'version'], { env: dockerEnvironment(inherited) }); await Promise.all(Object.values(config.ports).map(assertFreePort)); const owner = await initialize(config); @@ -58,7 +62,7 @@ export async function start(config, inherited = process.env) { const env = serviceEnvironment(config, owner, secret, inherited), children = new Map(), watchers = []; const launches = new LaunchBarrier(); const readiness = new ReadinessEpoch(); - let stopping = false, restarting = false, queued = false, startupComplete = false, statusTimer, readyTimer, debounce; + let stopping = false, restarting = false, queued = false, startupComplete = false, statusTimer, readyTimer, sourceChanges; const cancellation = new AbortController(); let activeCommand = Promise.resolve(), writeTail = Promise.resolve(), stopTail = Promise.resolve(); const perform = fn => { activeCommand = fn(); return activeCommand; }; @@ -83,7 +87,7 @@ export async function start(config, inherited = process.env) { if (stopping) return; stopping = true; readiness.pause(); cancellation.abort(); - clearTimeout(debounce); clearInterval(statusTimer); clearInterval(readyTimer); for (const w of watchers) w.close(); + sourceChanges?.stop(); clearInterval(statusTimer); clearInterval(readyTimer); for (const w of watchers) w.close(); try { await activeCommand.catch(() => {}); await launches.stop(); await stopChildren(); await removeOwnedDocker(config, owner, { output: log, secrets: redactions, diagnose: code !== 0 }); await writeTail; await unlink(config.supervisor); } catch (error) { say(`Cleanup failed: ${error.message} Recovery state kept; retry down/reset.`); process.exitCode = 1; return; } say('All owned services stopped.'); process.exitCode = code; @@ -149,9 +153,9 @@ export async function start(config, inherited = process.env) { const tokenDisplay = inherited.CI || inherited.SPACE_DEV_REDACT_SETUP === 'true' ? '[redacted: private state file]' : secret.token || '[already consumed]'; say(`One-time owner setup token: ${tokenDisplay}`); say(`State ${config.state}; down stops, reset removes only owned state. Browser/companion/root services are disabled. No provider account or inference is used.`); - const change = kind => { clearTimeout(debounce); debounce = setTimeout(() => { void rebuild(kind); }, 400); }; + sourceChanges = debounceSourceChanges(rebuild); for (const [dir, kind] of [['apps/api/src', 'api'], ['apps/worker/src', 'packages'], ['packages', 'packages']]) { - watchers.push(watch(join(config.root, dir), { recursive: true }, (_event, file) => { if (file && !/(?:^|[\\/])(?:node_modules|dist|\.git)(?:[\\/]|$)/.test(file) && /\.(?:ts|tsx|js|mjs|json)$/.test(file)) change(kind); })); + watchers.push(watch(join(config.root, dir), { recursive: true }, (_event, file) => { if (file && !/(?:^|[\\/])(?:node_modules|dist|\.git)(?:[\\/]|$)/.test(file) && /\.(?:ts|tsx|js|mjs|json)$/.test(file)) sourceChanges.change(kind); })); } readiness.start(); readyTimer = setInterval(async () => { @@ -167,7 +171,7 @@ export async function start(config, inherited = process.env) { finally { readiness.release(probe); } if (!result) return; // Completion/finally of a deliberately stopped epoch is inert. if (result.changed) say(result.outcome === 'ready' ? 'readyz ok: workflows and source services are ready.' : result.outcome === 'unreachable' ? 'readyz failed: API is not responding.' : `readyz failed: ${failed} unavailable; inspect its service log.`); - if (result.fatal) { say(`Required service failure: ${failed}; readiness generation${result.epoch}, failures${result.failures}; stopping the stack.`); await shutdown(1); } + if (result.fatal) { say(`Required service failure: ${failed}; readiness generation ${result.epoch}, failures ${result.failures}; stopping the stack.`); await shutdown(1); } }, 4000); statusTimer = setInterval(() => { if (startupComplete && !stopping) void recordChildren().catch(() => { void shutdown(1); }); }, 3000); } catch (error) { say(error.message); await shutdown(1); } diff --git a/scripts/public-export.mjs b/scripts/public-export.mjs index 765871e21..6c516c340 100644 --- a/scripts/public-export.mjs +++ b/scripts/public-export.mjs @@ -86,6 +86,7 @@ const publicScripts = new Set([ "scripts/dev-stack/supervisor.mjs", "scripts/dev-stack/readiness.mjs", "scripts/dev-stack/source-build.mjs", + "scripts/dev-stack/reload-debounce.mjs", "scripts/dev-stack/opencode-wrapper.mjs", "scripts/dev-stack/opencode-runtime.mjs", "scripts/dev-stack/pty-capability.mjs", diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs index bcc1791b0..0e2fed204 100644 --- a/scripts/tests/dev-stack.test.mjs +++ b/scripts/tests/dev-stack.test.mjs @@ -13,6 +13,7 @@ import { InteractiveTerminalProof, assertBrowserTerminal } from '../dev-stack/te import { knownHelperRepair } from '../dev-stack/pty-capability.mjs'; import { isPublicExportPath } from '../public-export.mjs'; import { assertBrowserRunning, eventually, proofBrowserArguments, safeBrowserStderr, selectedBrowserDiagnostic, syntheticCookieCapability, typeTerminalFilter } from '../dev-stack/browser-proof.mjs'; +import { debounceSourceChanges } from '../dev-stack/reload-debounce.mjs'; const root = fileURLToPath(new URL('../..', import.meta.url)); test('only Darwin CI proof browsers request a mock keychain and every other browser argument stays unchanged', () => { @@ -254,6 +255,7 @@ test('owned failure diagnostics redact split secrets and select health data with }); test('diagnostics run before owned down and a diagnostic failure cannot prevent cleanup', async t => { const config = await sandbox(t), owner = await initialize(config), calls = []; + await privateWrite(config.compose, JSON.stringify(composeDocument(owner, pins, 'synthetic-password'))); let down = false, output = ''; const invoke = async (_command, args, options) => { calls.push(args); @@ -275,6 +277,83 @@ test('a foreign project resource prevents both diagnostic reads and cleanup', as await assert.rejects(removeOwnedDocker(config, owner, { diagnose: true, invoke, env: {} }), /Foreign/); assert(!calls.some(args => args.includes('logs') || args.includes('down') || args.includes('{{json .State.Health}}'))); }); + +test('pre-Compose startup state can be cleaned without invoking a missing Compose file', async t => { + const config = await sandbox(t), owner = await initialize(config), calls = []; + const invoke = async (_command, args) => { calls.push(args); assert.equal(args[1], 'ls'); return ''; }; + await removeOwnedDocker(config, owner, { volumes: true, invoke, env: {} }); + assert.equal(calls.some(args => args.includes('down')), false); + assert.equal((await loadOwner(config)).nonce, owner.nonce); +}); + +test('a missing Compose file never hides owned containers networks or volumes', async t => { + for (const kind of ['container', 'network', 'volume']) { + const config = await sandbox(t), owner = await initialize(config), calls = []; + const invoke = async (_command, args) => { + calls.push(args); + if (args[1] === 'ls') return args[0] === kind ? 'owned-resource' : ''; + if (args[1] === 'inspect') return JSON.stringify({ [ownerLabel]: owner.nonce }); + throw new Error('Missing Compose must not be passed to Docker.'); + }; + await assert.rejects(removeOwnedDocker(config, owner, { invoke, env: {} }), new RegExp(`Owned ${kind} remains`)); + assert.equal(calls.some(args => args.includes('down')), false); + assert.equal((await loadOwner(config)).nonce, owner.nonce); + } +}); + +test('cleanup refuses a directory in place of the generated Compose document', async t => { + const config = await sandbox(t), owner = await initialize(config), calls = []; + await mkdir(config.compose); + const invoke = async (_command, args) => { calls.push(args); assert.equal(args[1], 'ls'); return ''; }; + await assert.rejects(removeOwnedDocker(config, owner, { invoke, env: {} }), /regular.*non-symlink/); + assert.equal(calls.some(args => args.includes('down')), false); +}); + +test('cleanup refuses a linked Compose document without touching its target', { skip: process.platform === 'win32' }, async t => { + const config = await sandbox(t), owner = await initialize(config), calls = []; + const target = join(config.root, 'compose-sentinel'); await writeFile(target, 'unchanged'); + await symlink(target, config.compose); + const invoke = async (_command, args) => { calls.push(args); assert.equal(args[1], 'ls'); return ''; }; + await assert.rejects(removeOwnedDocker(config, owner, { invoke, env: {} }), /regular.*non-symlink/); + assert.equal(calls.some(args => args.includes('down')), false); + assert.equal(await readFile(target, 'utf8'), 'unchanged'); +}); + +test('a present Compose document still runs owned down and intentionally retains database volumes', async t => { + const config = await sandbox(t), owner = await initialize(config), calls = []; + await privateWrite(config.compose, JSON.stringify(composeDocument(owner, pins, 'synthetic-password'))); + const invoke = async (_command, args) => { + calls.push(args); + if (args[1] === 'ls') return args[0] === 'volume' ? 'retained-owned-volume' : ''; + if (args[1] === 'inspect') return JSON.stringify({ [ownerLabel]: owner.nonce }); + if (args.includes('down')) return ''; + throw new Error('Unexpected cleanup call'); + }; + await removeOwnedDocker(config, owner, { invoke, env: {} }); + assert.equal(calls.filter(args => args.includes('down')).length, 1); + assert.equal(calls.find(args => args.includes('down')).includes('--volumes'), false); +}); + +test('a package edit keeps reload precedence in either event order and each later debounce window starts fresh', { timeout: 5000 }, async t => { + const calls = []; let notify; + const changes = debounceSourceChanges(kind => { calls.push(kind); notify(); }); + t.after(() => changes.stop()); + for (const sequence of [['packages', 'api'], ['api', 'packages'], ['api', 'packages', 'api'], ['api', 'api']]) { + const fired = new Promise(resolvePromise => { notify = resolvePromise; }); + for (const kind of sequence) changes.change(kind); + await fired; + assert.equal(calls.at(-1), sequence.includes('packages') ? 'packages' : 'api'); + } + assert.deepEqual(calls, ['packages', 'packages', 'packages', 'api']); + assert(isPublicExportPath('scripts/dev-stack/reload-debounce.mjs')); +}); + +test('shutdown cancels a pending reload and ignores source notifications already queued by a closing watcher', { timeout: 5000 }, async () => { + const calls = [], changes = debounceSourceChanges(kind => calls.push(kind)); + changes.change('packages'); changes.stop(); changes.change('api'); + await new Promise(resolvePromise => setTimeout(resolvePromise, 500)); + assert.deepEqual(calls, []); +}); test('redactor handles secrets split between stdout chunks', () => { let output = ''; const stream = lineRedactor(['synthetic-password', 'synthetic-token'], x => { output += x; }); stream.chunk(Buffer.from('before synthetic-pass')); stream.chunk(Buffer.from('word\nsynthetic-to')); stream.chunk(Buffer.from('ken after')); stream.end(); From 90502fa26df739ddc2ac4b3d58f2dc8f7130d91d Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sun, 18 Oct 2026 03:18:57 +0200 Subject: [PATCH 22/27] test: retain worktree provisioning errors in source validation --- .github/workflows/dev-stack.yml | 1 + apps/api/tests/room-pane-worktrees.test.ts | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml index 62194deba..0fb380f10 100644 --- a/.github/workflows/dev-stack.yml +++ b/.github/workflows/dev-stack.yml @@ -130,6 +130,7 @@ jobs: apps/api/tests/instance-storage.test.ts apps/api/tests/dev-oauth-storage.test.ts apps/api/tests/dev-docx-isolation.test.ts apps/api/tests/dev-incidents-storage.test.ts apps/api/tests/space-control-governance.test.ts + apps/api/tests/room-pane-worktrees.test.ts - name: Native macOS CLI host regressions if: runner.os == 'macOS' diff --git a/apps/api/tests/room-pane-worktrees.test.ts b/apps/api/tests/room-pane-worktrees.test.ts index a06d1e9bd..4052dd8c0 100644 --- a/apps/api/tests/room-pane-worktrees.test.ts +++ b/apps/api/tests/room-pane-worktrees.test.ts @@ -5,7 +5,7 @@ import { afterEach, expect, it, vi } from "vitest"; import { type AgentRuntimeRegistry, type RoomPaneBatchItem, type SpaceWorktree } from "@space/contracts"; import { InMemorySpaceStore } from "@space/runtime"; import { createRoomPaneCommands } from "../src/room-pane-commands.js"; -import { execGit, WorktreeService } from "../src/worktrees/worktree-service.js"; +import { execGit, WorktreeError, WorktreeService } from "../src/worktrees/worktree-service.js"; import { defaultWorktreeBaseBranch } from "../src/worktrees/default-base-branch.js"; const roots: string[] = []; @@ -101,7 +101,20 @@ it.each([false, true])("creates sixteen isolated and recorded Git worktrees (unb } const service = new WorktreeService(join(root, "worktrees")); const { room, commands } = setup(service, repo); + const createWorktree = service.createWorktree.bind(service); + const provisioningErrors: Array<{ branch: string | null; code: string | null; message: string; truncated: boolean }> = []; + vi.spyOn(service, "createWorktree").mockImplementation(async input => { + try { + return await createWorktree(input); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + provisioningErrors.push({ branch: input.branchName ?? null, code: error instanceof WorktreeError ? error.code : null, + message: message.slice(0, 4096), truncated: message.length > 4096 }); + throw error; + } + }); const panes = await commands.resolve(room.id, items(16)); + expect(provisioningErrors).toEqual([]); expect(new Set(panes.map(pane => pane.cwd)).size).toBe(16); for (const [index, pane] of panes.entries()) { expect(pane.cwd).not.toBe(repo); From 1855198c7ebb343c943bf8fb51769facb83c769d Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sun, 18 Oct 2026 04:28:19 +0200 Subject: [PATCH 23/27] fix: await restored source generations before lifecycle proof --- scripts/dev-stack/acceptance-restoration.mjs | 90 ++++++ scripts/dev-stack/acceptance.mjs | 47 ++- scripts/dev-stack/lifecycle-proof.mjs | 8 +- scripts/dev-stack/reload-debounce.mjs | 46 ++- scripts/dev-stack/reload-proof-channel.mjs | 164 ++++++++++ scripts/dev-stack/supervisor.mjs | 36 ++- scripts/public-export.mjs | 2 + scripts/tests/dev-stack.test.mjs | 299 +++++++++++++++++++ 8 files changed, 665 insertions(+), 27 deletions(-) create mode 100644 scripts/dev-stack/acceptance-restoration.mjs create mode 100644 scripts/dev-stack/reload-proof-channel.mjs diff --git a/scripts/dev-stack/acceptance-restoration.mjs b/scripts/dev-stack/acceptance-restoration.mjs new file mode 100644 index 000000000..b95484df2 --- /dev/null +++ b/scripts/dev-stack/acceptance-restoration.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import { delay, sameProcess } from './processes.mjs'; +import { reloadObservation } from './reload-proof-channel.mjs'; +export { reloadObservation }; + +const names = ['api', 'cli-host', 'web', 'worker']; +const identity = value => value && Number.isSafeInteger(value.pid) && value.pid > 0 && typeof value.identity === 'string' && value.identity.length > 0; + +const idle = value => !value.stopping && value.pending === null && value.queued === null && value.active === null && value.requested === value.completed; +function children(record) { + assert(Array.isArray(record.children) && record.children.length === names.length, 'Idle reload receipt needs every source child.'); + const selected = names.map(name => { + const matches = record.children.filter(item => item.name === name); + assert(matches.length === 1 && identity(matches[0]), 'Idle reload receipt has missing, duplicate or unverified children.'); + return { name, pid: matches[0].pid, identity: matches[0].identity }; + }); + assert(new Set(selected.map(item => item.pid)).size === names.length, 'Idle reload receipt has duplicate process IDs.'); + return selected; +} + +/** + * Join a known source edit to a later completed, live service generation within + * the existing proof deadline. Compare the same idle receipt/identities around + * fresh HTTP readiness and exact expected bytes. This never collects, adopts, + * retries or suppresses descendant identity failures in the later lifecycle proof. + * Callers restore watched sources in finally blocks before awaiting this join. + */ +export async function restorationCheckpoint({ observe, nonce, supervisor, before = null, changed = [], files = [], ready, live = sameProcess, timeout = 120000, until, now = Date.now, pause = delay, label = 'Source restoration' }) { + assert(typeof nonce === 'string' && nonce.length > 0 && [120000, 300000].includes(timeout), 'Invalid restoration proof boundary.'); + assert(until === undefined || Number.isFinite(until), 'Invalid restoration deadline.'); + assert(changed.every(name => names.includes(name)) && (changed.length === 0 || before), 'Changed identities require a prior checkpoint.'); + const bound = supervisor || before, challenges = new Set(), deadline = Math.min(until ?? Infinity, now() + timeout); + assert(identity(bound) && typeof observe === 'function', 'A bound live reload proof channel is required; no file-only fallback.'); + let previous = before?.reload; + const read = async () => { + const challenge = randomUUID(); assert(!challenges.has(challenge), 'A new reload proof challenge is required.'); challenges.add(challenge); + const reply = await observe({ challenge }, Math.max(1, deadline - now())); + assert(reply?.version === 1 && reply.challenge === challenge && reply.nonce === nonce && reply.pid === bound.pid && reply.identity === bound.identity && await live(bound), 'Reload proof response is foreign, stale or unbound.'); + assert(['OK', 'BUSY'].includes(reply.status), 'Reload proof response failed.'); + if (reply.status === 'BUSY') { assert.equal(reply.snapshot, null, 'A busy response must not offer a stale snapshot.'); return null; } + const record = reply.snapshot; + assert(record?.nonce === nonce && identity(record), 'Restoration supervisor ownership/identity is missing.'); + assert(record.pid === bound.pid && record.identity === bound.identity && await live(record), 'Restoration supervisor identity changed.'); + const reload = reloadObservation(record.reload); + assert(!reload.stopping, 'Restoration supervisor is stopping.'); + if (previous) { + assert(reload.revision >= previous.revision && reload.requested >= previous.requested && reload.completed >= previous.completed, 'Reload receipt moved backwards.'); + assert(reload.revision !== previous.revision || JSON.stringify(reload) === JSON.stringify(previous), 'Reload receipt changed without a new revision.'); + } + previous = reload; + return { nonce, pid: record.pid, identity: record.identity, reload, children: record.children, proofRequest: challenge }; + }; + const assertLive = async items => { + for (const item of items) assert(await live(item), 'Idle restored child identity changed.'); + }; + while (now() < deadline) { + const first = await read(); + if (first && idle(first.reload) && (!before || first.reload.completed > before.reload.requested)) { + first.children = children(first); + const distinct = changed.every(name => { + const old = before.children.find(item => item.name === name), next = first.children.find(item => item.name === name); + // Darwin lstart has second resolution: the identity is the PID/birth pair. + return old && next.pid !== old.pid; + }); + if (distinct) { + await assertLive(first.children); + const readyNow = await ready(); + const observed = []; + let matching = true; + for (const file of files) { + const bytes = Buffer.from(await file.read()); + matching &&= (file.expected === undefined || bytes.equals(Buffer.from(file.expected))) && (file.includes === undefined || bytes.includes(file.includes)) && (file.forbidden === undefined || !bytes.includes(file.forbidden)); + observed.push({ name: file.name, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }); + } + const second = await read(); + if (second && idle(second.reload)) { + second.children = children(second); + if (JSON.stringify(first.reload) === JSON.stringify(second.reload)) { + assert.deepEqual(second.children, first.children, 'Children changed inside the same idle reload receipt.'); + await assertLive(second.children); + if (readyNow === true && matching && now() < deadline) return { ...second, proofRequests: [first.proofRequest, second.proofRequest], ready: true, files: observed }; + } + } + } + } + await pause(400); + } + throw new Error(`${label} exceeded its existing ${timeout / 1000}s proof bound; no restored handoff is accepted.`); +} diff --git a/scripts/dev-stack/acceptance.mjs b/scripts/dev-stack/acceptance.mjs index 645843d1f..293207e8b 100644 --- a/scripts/dev-stack/acceptance.mjs +++ b/scripts/dev-stack/acceptance.mjs @@ -6,12 +6,14 @@ import { isAbsolute, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import WebSocket from 'ws'; import { locations, loadOwner, readJson } from './state.mjs'; -import { sameProcess, delay } from './processes.mjs'; +import { sameProcess } from './processes.mjs'; import { browserProof, eventually } from './browser-proof.mjs'; import { assertLoopbackPublications, readOwnedPortPublications, run, dockerEnvironment } from './resources.mjs'; import { InteractiveTerminalProof, assertBrowserTerminal } from './terminal-proof.mjs'; import { extractPins } from './state.mjs'; import { verifyOpenCode } from './opencode-runtime.mjs'; +import { restorationCheckpoint } from './acceptance-restoration.mjs'; +import { queryReloadProof } from './reload-proof-channel.mjs'; const root = resolve(fileURLToPath(new URL('../..', import.meta.url))), config = locations(root, process.env), owner = await loadOwner(config); const artifacts = process.env.SPACE_DEV_PROOF_DIR; @@ -122,23 +124,44 @@ try { assert.equal(await browser.evaluate('performance.timeOrigin'), timeOrigin, 'CSS hot reload must not require navigation/reload'); await browser.screenshot('web-hmr.png'); marker('real web CSS HMR without page navigation'); } finally { await writeFile(css, originalCss); } - const apiSource = join(root, 'apps/api/src/server.ts'), originalApi = await readFile(apiSource), oldApi = await pid('api'); + const restored = []; + const proofSupervisor = await readJson(config.supervisor); + const checkpoint = options => restorationCheckpoint({ observe: (request, remainingMs) => queryReloadProof(config, proofSupervisor, request, remainingMs), supervisor: proofSupervisor, nonce: owner.nonce, ready: async () => { + try { const response = await fetch(`${api}/readyz`, { signal: AbortSignal.timeout(3000) }); return response.ok && (await response.json()).ok === true; } + catch (error) { if (error instanceof TypeError || error.name === 'TimeoutError') return false; throw error; } + }, ...options }); + const apiSource = join(root, 'apps/api/src/server.ts'), originalApi = await readFile(apiSource); + const apiInitial = await checkpoint({}), oldApi = apiInitial.children.find(item => item.name === 'api'); + const changedApi = Buffer.concat([originalApi, Buffer.from(`\n// dev-stack reload proof ${canary}\n`)]); + const apiDeadline = Date.now() + 120000, remaining = deadline => Math.max(1, deadline - Date.now()); + let apiCanary; try { - await writeFile(apiSource, Buffer.concat([originalApi, Buffer.from(`\n// dev-stack reload proof ${canary}\n`)])); - await eventually(async () => { const next = await pid('api'); return next && next.pid !== oldApi.pid && await sameProcess(next); }, 'API source restart'); - await eventually(async () => (await (await fetch(`${api}/readyz`)).json()).ok === true, 'Ready after API restart'); marker('API source restart and readiness'); + await writeFile(apiSource, changedApi); + await eventually(async () => { const next = await pid('api'); return next && next.pid !== oldApi.pid && await sameProcess(next); }, 'API source restart', remaining(apiDeadline)); + await eventually(async () => (await (await fetch(`${api}/readyz`)).json()).ok === true, 'Ready after API restart', remaining(apiDeadline)); + apiCanary = await checkpoint({ supervisor: apiInitial, before: apiInitial, changed: ['api'], until: apiDeadline, files: [{ name: 'API canary source', read: () => readFile(apiSource), expected: changedApi }] }); + marker('API source restart and readiness'); } finally { await writeFile(apiSource, originalApi); } - // Wait for the restoration-triggered API restart before testing a package rebuild. - await delay(1500); - const workerBefore = await pid('worker'), apiBefore = await pid('api'); + const apiRestored = await checkpoint({ supervisor: apiInitial, before: apiCanary, changed: ['api'], files: [{ name: 'restored API source', read: () => readFile(apiSource), expected: originalApi, forbidden: canary }] }); + restored.push({ phase: 'API restoration', ...apiRestored }); + const workerBefore = apiRestored.children.find(item => item.name === 'worker'), apiBefore = apiRestored.children.find(item => item.name === 'api'); const packageFile = join(root, 'packages/contracts/src/index.ts'), originalPackage = await readFile(packageFile); + // Capture original emitted bytes before the canary, not after a later rebuild. + const compiledPackage = join(root, 'packages/contracts/dist/index.js'), originalCompiled = await readFile(compiledPackage); + const changedPackage = Buffer.concat([originalPackage, Buffer.from(`\nexport const spaceDevStackProofCanary = ${JSON.stringify(canary)};\n`)]); + const packageDeadline = Date.now() + 300000; + let packageCanary; try { - await writeFile(packageFile, Buffer.concat([originalPackage, Buffer.from(`\nexport const spaceDevStackProofCanary = ${JSON.stringify(canary)};\n`)])); - await eventually(async () => { const w = await pid('worker'), p = await pid('api'); return w && p && w.pid !== workerBefore.pid && p.pid !== apiBefore.pid && await sameProcess(w) && await sameProcess(p); }, 'Rebuilt package picked up by API/worker', 300000); - await eventually(async () => (await (await fetch(`${api}/readyz`)).json()).ok === true, 'Ready after package rebuild'); marker('shared package rebuilt and API/worker restarted'); + await writeFile(packageFile, changedPackage); + await eventually(async () => { const w = await pid('worker'), p = await pid('api'); return w && p && w.pid !== workerBefore.pid && p.pid !== apiBefore.pid && await sameProcess(w) && await sameProcess(p); }, 'Rebuilt package picked up by API/worker', remaining(packageDeadline)); + await eventually(async () => (await (await fetch(`${api}/readyz`)).json()).ok === true, 'Ready after package rebuild', Math.min(120000, remaining(packageDeadline))); assert((await readFile(join(root, 'packages/contracts/dist/index.js'), 'utf8')).includes(canary), 'Changed shared package source must actually reach built output'); + packageCanary = await checkpoint({ supervisor: apiInitial, before: apiRestored, changed: ['api', 'worker', 'cli-host'], timeout: 300000, until: packageDeadline, files: [{ name: 'shared canary source', read: () => readFile(packageFile), expected: changedPackage }, { name: 'shared canary output', read: () => readFile(compiledPackage), includes: canary }] }); } finally { await writeFile(packageFile, originalPackage); } - await writeFile(join(artifacts, 'acceptance.json'), JSON.stringify({ source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, checks, model_inference: false, real_operator_data: false }, null, 2) + '\n'); + const packageRestored = await checkpoint({ supervisor: apiInitial, before: packageCanary, changed: ['api', 'worker', 'cli-host'], timeout: 300000, files: [{ name: 'restored API source', read: () => readFile(apiSource), expected: originalApi, forbidden: canary }, { name: 'restored shared source', read: () => readFile(packageFile), expected: originalPackage, forbidden: canary }, { name: 'restored shared output', read: () => readFile(compiledPackage), expected: originalCompiled, forbidden: canary }] }); + restored.push({ phase: 'shared-package restoration', ...packageRestored }); + marker('shared package rebuilt and API/worker restarted'); + await writeFile(join(artifacts, 'acceptance.json'), JSON.stringify({ source: process.env.SPACE_DEV_CANDIDATE, platform: process.platform, checks, restorations: restored, model_inference: false, real_operator_data: false }, null, 2) + '\n'); } catch (error) { // Selected synthetic state only. Never serialize a session response, ticket, // cookie, websocket URL, environment or real provider/owner credentials. diff --git a/scripts/dev-stack/lifecycle-proof.mjs b/scripts/dev-stack/lifecycle-proof.mjs index befcfedea..9402a94ba 100644 --- a/scripts/dev-stack/lifecycle-proof.mjs +++ b/scripts/dev-stack/lifecycle-proof.mjs @@ -67,7 +67,13 @@ async function waitReady(stack) { } async function processSnapshot(config) { const record = await readJson(config.supervisor), result = []; - for (const item of record.children) if (await sameProcess(item)) result.push(...await descendants(item)); + for (const item of record.children) { + try { if (await sameProcess(item)) result.push(...await descendants(item)); } + catch (error) { + // Selected ownership context only; the strict guard remains fatal and is never retried. + throw new Error(`Owned snapshot ${String(item.name).slice(0, 32)} pid=${item.pid} expected=${String(item.identity).slice(0, 160)}: ${error.message}`, { cause: error }); + } + } return result; } async function assertStopped(records) { assert.equal((await Promise.all(records.map(record => sameProcess(record)))).some(Boolean), false, 'An owned source/PTY process survived cleanup.'); } diff --git a/scripts/dev-stack/reload-debounce.mjs b/scripts/dev-stack/reload-debounce.mjs index 38147c554..b661760b2 100644 --- a/scripts/dev-stack/reload-debounce.mjs +++ b/scripts/dev-stack/reload-debounce.mjs @@ -3,18 +3,54 @@ * stop() cancels pending work and permanently ignores later watcher notifications. * The supplied rebuild callback owns asynchronous failure handling. */ -export function debounceSourceChanges(rebuild) { - let pending = null, timer, stopped = false; +export function debounceSourceChanges(rebuild, observe = () => {}) { + let pending = null, timer, stopped = false, generation = 0; + const observed = () => observe({ requested: generation, pending: pending === null ? null : { generation, kind: pending }, stopped }); return { change(kind) { if (stopped) return; pending = pending === 'packages' || kind === 'packages' ? 'packages' : 'api'; + generation++; clearTimeout(timer); timer = setTimeout(() => { - const next = pending; pending = null; - void rebuild(next); + const next = pending, requested = generation; pending = null; + observed(); + void rebuild(next, requested); }, 400); + observed(); }, - stop() { stopped = true; clearTimeout(timer); pending = null; }, + stop() { stopped = true; clearTimeout(timer); pending = null; observed(); }, }; } + +/** + * Observe the existing debounce/restart queue without admitting or scheduling work. + * A completed request is idle only after its active and queued work has ended; + * later watcher notifications keep requested ahead of completed until rebuilt. + */ +export class ReloadReceipt { + #state = { version: 1, revision: 0, requested: 0, pending: null, queued: null, active: null, completed: 0, stopping: false }; + + debounced({ requested, pending, stopped }) { + this.#state.requested = requested; + this.#state.pending = pending; + this.#state.stopping ||= stopped; + this.#state.revision++; + } + + started(kind, generation) { this.#state.active = { kind, generation }; this.#state.revision++; } + enqueued(generation) { this.#state.queued = { kind: 'packages', generation }; this.#state.revision++; } + finished(generation, rebuilt) { + if (rebuilt) this.#state.completed = Math.max(this.#state.completed, generation); + this.#state.active = null; + this.#state.revision++; + } + releaseQueued() { + const generation = this.#state.queued.generation; + this.#state.queued = null; + this.#state.revision++; + return generation; + } + stop() { this.#state.stopping = true; this.#state.revision++; } + snapshot() { return structuredClone(this.#state); } +} diff --git a/scripts/dev-stack/reload-proof-channel.mjs b/scripts/dev-stack/reload-proof-channel.mjs new file mode 100644 index 000000000..82f46c364 --- /dev/null +++ b/scripts/dev-stack/reload-proof-channel.mjs @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; +import { chmod, lstat, realpath, unlink } from 'node:fs/promises'; +import { createConnection, createServer } from 'node:net'; +import { join } from 'node:path'; +import { sameProcess } from './processes.mjs'; + +const maxRequest = 1024, maxReply = 16384, maxConnections = 4, timeoutMs = 3000; +const socketName = /^p-[a-f0-9]{12}\.sock$/; +const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/; +const failure = code => Object.assign(new Error(`Owned reload observation failed (${code}).`), { code }); +const bound = (value, owner) => value.nonce === owner.nonce && value.pid === owner.pid && value.identity === owner.identity; +const exactKeys = (value, keys) => value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).sort().join(',') === keys.sort().join(','); +const idle = value => value && !value.stopping && value.pending === null && value.queued === null && value.active === null && value.requested === value.completed; + +/** Validate the versioned observation; legacy recovery records never qualify as proof. */ +export function reloadObservation(value) { + const sequence = number => Number.isSafeInteger(number) && number >= 0; + assert(value?.version === 1 && sequence(value.revision) && sequence(value.requested) && sequence(value.completed) && value.completed <= value.requested && typeof value.stopping === 'boolean', 'Missing or malformed reload receipt.'); + for (const key of ['pending', 'queued', 'active']) { + const slot = value[key]; + assert(slot === null || (slot && sequence(slot.generation) && slot.generation > 0 && slot.generation <= value.requested && ['api', 'packages'].includes(slot.kind) && (key !== 'queued' || slot.kind === 'packages')), 'Malformed reload phase receipt.'); + } + return { version: value.version, revision: value.revision, requested: value.requested, pending: value.pending, queued: value.queued, active: value.active, completed: value.completed, stopping: value.stopping }; +} + +/** One bounded JSON line per half-closed connection; reject extra frames and unrecognized fields. */ +export function parseReloadRequest(bytes, owner) { + if (bytes.length > maxRequest || bytes.at(-1) !== 10) throw failure('MALFORMED_REQUEST'); + let value; + try { value = JSON.parse(bytes.subarray(0, -1).toString('utf8')); } catch { throw failure('MALFORMED_REQUEST'); } + if (!exactKeys(value, ['version', 'nonce', 'pid', 'identity', 'challenge']) || value.version !== 1 || !uuid.test(value.challenge) || !bound(value, owner)) throw failure('FOREIGN_REQUEST'); + return value; +} + +/** Reserve at dispatch as well as connection admission; timed-out requests can still await publication. */ +export function reserveReloadProofObservation(observations, socket) { + if (observations.size >= maxConnections) throw failure('CAPACITY'); + observations.add(socket); +} + +/** + * Drain the write tail captured after request arrival, then synchronously select + * the authoritative RAM snapshot. A newer publication is BUSY, never a stale + * disk fallback; shutdown and failed publication are fatal. This observes work + * without scheduling, blocking admission of, or changing a source reload. + */ +export async function observeReloadProof({ tail, closed, snapshot }) { + if (closed()) throw failure('STOPPING'); + const captured = tail(); + try { await captured; } catch { throw failure('WRITE_FAILED'); } + if (closed()) throw failure('STOPPING'); + if (captured !== tail()) return { status: 'BUSY', snapshot: null }; + const value = snapshot(); + const reload = reloadObservation(value.reload); + if (reload.stopping) throw failure('STOPPING'); + return idle(reload) ? { status: 'OK', snapshot: value } : { status: 'BUSY', snapshot: null }; +} + +async function privateRun(config) { + const state = await lstat(config.state), run = join(config.state, 'run'), info = await lstat(run); + for (const item of [state, info]) if (!item.isDirectory() || item.isSymbolicLink() || (item.mode & 0o777) !== 0o700 || item.uid !== process.getuid()) throw failure('UNSAFE_DIRECTORY'); + if (await realpath(config.state) !== config.state || await realpath(run) !== run) throw failure('LINKED_DIRECTORY'); + return run; +} +async function privateSocket(config, name) { + if (!socketName.test(name)) throw failure('UNSAFE_SOCKET_NAME'); + const run = await privateRun(config), file = join(run, name); + if (Buffer.byteLength(file) > 100) throw failure('SOCKET_PATH_TOO_LONG'); + const info = await lstat(file); + if (!info.isSocket() || info.isSymbolicLink() || (info.mode & 0o777) !== 0o600 || info.uid !== process.getuid()) throw failure('UNSAFE_SOCKET'); + return { file, info }; +} + +/** Create a private observation socket only; no HTTP/TCP endpoint or request-supplied path. */ +export async function openReloadProofChannel(config, owner, observe) { + const run = await privateRun(config), name = `p-${randomBytes(6).toString('hex')}.sock`, file = join(run, name); + if (Buffer.byteLength(file) > 100) throw failure('SOCKET_PATH_TOO_LONG'); + const existing = await lstat(file).catch(error => { if (error.code === 'ENOENT') return null; throw error; }); + if (existing) throw failure('SOCKET_EXISTS'); + let closing = false, closePromise, owned; + const connections = new Set(), observations = new Set(); + const server = createServer({ allowHalfOpen: true }, socket => { + if (closing || connections.size >= maxConnections || observations.size >= maxConnections) { socket.destroy(); return; } + connections.add(socket); + const timer = setTimeout(() => socket.destroy(), timeoutMs), chunks = []; let bytes = 0; + socket.on('error', () => {}); + socket.once('close', () => { clearTimeout(timer); connections.delete(socket); }); + socket.on('data', chunk => { bytes += chunk.length; if (bytes > maxRequest) socket.destroy(); else chunks.push(chunk); }); + socket.once('end', async () => { + let request; + try { + if (closing || socket.destroyed) throw failure('STOPPING'); + request = parseReloadRequest(Buffer.concat(chunks), owner); + reserveReloadProofObservation(observations, socket); + const result = await observe(); + if (closing) throw failure('STOPPING'); + const reply = { version: 1, ...owner, challenge: request.challenge, ...result }; + const text = JSON.stringify(reply) + '\n'; + if (Buffer.byteLength(text) > maxReply) throw failure('REPLY_TOO_LARGE'); + socket.end(text); + } catch (error) { + if (request && !socket.destroyed) socket.end(JSON.stringify({ version: 1, ...owner, challenge: request.challenge, status: 'ERROR', code: ['STOPPING', 'WRITE_FAILED', 'REPLY_TOO_LARGE', 'CAPACITY'].includes(error.code) ? error.code : 'OBSERVATION_FAILED', snapshot: null }) + '\n'); + else socket.destroy(); + } finally { observations.delete(socket); } + }); + }); + server.on('error', () => { closing = true; for (const socket of connections) socket.destroy(); }); + const close = () => closePromise ||= (async () => { + closing = true; + for (const socket of connections) socket.destroy(); + // net.Server.close may unlink its Unix path itself: verify ownership first. + const beforeClose = await lstat(file).catch(error => { if (error.code === 'ENOENT') return null; throw error; }); + if (server.listening && (!owned || (beforeClose && (!beforeClose.isSocket() || beforeClose.isSymbolicLink() || beforeClose.dev !== owned.dev || beforeClose.ino !== owned.ino || beforeClose.uid !== owned.uid)))) { + server.unref(); throw failure('SOCKET_REPLACED'); + } + if (server.listening) await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + if (owned) { + const current = await lstat(file).catch(error => { if (error.code === 'ENOENT') return null; throw error; }); + if (current && (!current.isSocket() || current.isSymbolicLink() || current.dev !== owned.dev || current.ino !== owned.ino || current.uid !== owned.uid)) throw failure('SOCKET_REPLACED'); + if (current) await unlink(file); + } + })(); + try { + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(file, resolve); }); + const created = await lstat(file); + if (!created.isSocket() || created.isSymbolicLink() || created.uid !== process.getuid()) throw failure('UNSAFE_SOCKET'); + owned = created; + await chmod(file, 0o600); + await privateSocket(config, name); + return { name, stopAdmission() { closing = true; server.unref(); for (const socket of connections) socket.destroy(); }, close }; + } catch (error) { await close(); throw error; } +} + +/** Request a fresh challenge-bound observation from the exact live owned supervisor. */ +export async function queryReloadProof(config, owner, request, remainingMs) { + const started = Date.now(); + if (!uuid.test(request.challenge) || !Number.isFinite(remainingMs) || remainingMs <= 0 || !await sameProcess(owner)) throw failure('INVALID_QUERY'); + const { file } = await privateSocket(config, owner.proofSocket); + const remaining = Math.min(timeoutMs, remainingMs) - (Date.now() - started); + if (remaining <= 0) throw failure('QUERY_TIMEOUT'); + const binding = { nonce: owner.nonce, pid: owner.pid, identity: owner.identity }; + const message = { version: 1, ...binding, challenge: request.challenge }; + return new Promise((resolve, reject) => { + const socket = createConnection(file), chunks = []; let bytes = 0, settled = false; + const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(timer); socket.destroy(); if (error) reject(error); else resolve(value); }; + const timer = setTimeout(() => finish(failure('QUERY_TIMEOUT')), remaining); + socket.once('connect', () => socket.end(JSON.stringify(message) + '\n')); + socket.on('error', () => finish(failure('QUERY_TRANSPORT'))); + socket.on('data', chunk => { bytes += chunk.length; if (bytes > maxReply) finish(failure('REPLY_TOO_LARGE')); else chunks.push(chunk); }); + socket.once('end', () => { + try { + const body = Buffer.concat(chunks); + if (body.at(-1) !== 10) throw failure('MALFORMED_REPLY'); + const value = JSON.parse(body.subarray(0, -1).toString('utf8')); + if (!exactKeys(value, value.status === 'ERROR' ? ['version', 'nonce', 'pid', 'identity', 'challenge', 'status', 'snapshot', 'code'] : ['version', 'nonce', 'pid', 'identity', 'challenge', 'status', 'snapshot']) || value.version !== 1 || !bound(value, binding) || value.challenge !== request.challenge || !['OK', 'BUSY', 'ERROR'].includes(value.status)) throw failure('FOREIGN_REPLY'); + if (value.status === 'ERROR') throw failure(['STOPPING', 'WRITE_FAILED', 'REPLY_TOO_LARGE', 'CAPACITY'].includes(value.code) ? value.code : 'OBSERVATION_FAILED'); + if (value.status === 'BUSY' && value.snapshot !== null) throw failure('MALFORMED_REPLY'); + finish(null, value); + } catch (error) { finish(error); } + }); + socket.once('close', () => { if (!settled) finish(failure('QUERY_CLOSED')); }); + }); +} diff --git a/scripts/dev-stack/supervisor.mjs b/scripts/dev-stack/supervisor.mjs index 83f93c4c8..0a38a5b02 100644 --- a/scripts/dev-stack/supervisor.mjs +++ b/scripts/dev-stack/supervisor.mjs @@ -11,7 +11,8 @@ import { verifyOpenCode } from './opencode-runtime.mjs'; import { verifyPtyCapability } from './pty-capability.mjs'; import { ReadinessEpoch } from './readiness.mjs'; import { buildSourceServices } from './source-build.mjs'; -import { debounceSourceChanges } from './reload-debounce.mjs'; +import { debounceSourceChanges, ReloadReceipt } from './reload-debounce.mjs'; +import { observeReloadProof, openReloadProofChannel } from './reload-proof-channel.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); const say = text => process.stdout.write(`[dev-stack] ${text}\n`); @@ -56,7 +57,8 @@ export async function start(config, inherited = process.env) { if (JSON.stringify(owner.ports) !== JSON.stringify(config.ports)) throw new Error('Existing state has different ports. Reset it first or use its original SPACE_DEV_*_PORT values.'); const old = await readJson(config.supervisor).catch(e => { if (e.code === 'ENOENT') return null; throw e; }); if (old) { if (old.nonce !== owner.nonce || await sameProcess(old) || (await Promise.all((old.children || []).map(x => sameProcess(x)))).some(Boolean)) throw new Error('A prior supervisor/child is still alive; use down before restarting.'); await unlink(config.supervisor); } - const record = { nonce: owner.nonce, pid: process.pid, identity: await processIdentity(process.pid), children: [] }; + const reload = new ReloadReceipt(); + const record = { nonce: owner.nonce, pid: process.pid, identity: await processIdentity(process.pid), children: [], reload: reload.snapshot() }; await privateWrite(config.supervisor, JSON.stringify(record), { flag: 'wx' }); const secret = await secrets(config), redactions = [secret.password, encodeURIComponent(secret.password), secret.session, secret.token]; const env = serviceEnvironment(config, owner, secret, inherited), children = new Map(), watchers = []; @@ -64,9 +66,10 @@ export async function start(config, inherited = process.env) { const readiness = new ReadinessEpoch(); let stopping = false, restarting = false, queued = false, startupComplete = false, statusTimer, readyTimer, sourceChanges; const cancellation = new AbortController(); - let activeCommand = Promise.resolve(), writeTail = Promise.resolve(), stopTail = Promise.resolve(); + let activeCommand = Promise.resolve(), writeTail = Promise.resolve(), stopTail = Promise.resolve(), receiptClosed = false, proofChannel; const perform = fn => { activeCommand = fn(); return activeCommand; }; - const recordChildren = () => { record.children = [...children.values()].map(x => x.record); const text = JSON.stringify(record); writeTail = writeTail.then(() => privateWrite(config.supervisor, text)); return writeTail; }; + const recordChildren = () => { record.children = [...children.values()].map(x => x.record); record.reload = reload.snapshot(); const text = JSON.stringify(record); writeTail = writeTail.then(() => privateWrite(config.supervisor, text)); return writeTail; }; + const publishReload = () => { if (!receiptClosed) void recordChildren().catch(() => { void shutdown(1); }); }; const log = chunk => process.stdout.write(chunk); async function command(args, executable = 'npm') { return perform(() => run(executable, args, { cwd: config.root, env, secrets: redactions, output: log, signal: cancellation.signal })); } const stopChildren = (names = [...children.keys()]) => { @@ -85,10 +88,12 @@ export async function start(config, inherited = process.env) { }; async function shutdown(code = 0) { if (stopping) return; stopping = true; + proofChannel?.stopAdmission(); + reload.stop(); publishReload(); readiness.pause(); cancellation.abort(); sourceChanges?.stop(); clearInterval(statusTimer); clearInterval(readyTimer); for (const w of watchers) w.close(); - try { await activeCommand.catch(() => {}); await launches.stop(); await stopChildren(); await removeOwnedDocker(config, owner, { output: log, secrets: redactions, diagnose: code !== 0 }); await writeTail; await unlink(config.supervisor); } + try { await activeCommand.catch(() => {}); await launches.stop(); await stopChildren(); await removeOwnedDocker(config, owner, { output: log, secrets: redactions, diagnose: code !== 0 }); receiptClosed = true; await writeTail; await proofChannel?.close(); await unlink(config.supervisor); } catch (error) { say(`Cleanup failed: ${error.message} Recovery state kept; retry down/reset.`); process.exitCode = 1; return; } say('All owned services stopped.'); process.exitCode = code; } @@ -112,11 +117,13 @@ export async function start(config, inherited = process.env) { await launch('worker', ['apps/worker/dist/worker.js']); await launch('api', ['--import', 'tsx', 'apps/api/src/server.ts']); }; - const rebuild = async kind => { + const rebuild = async (kind, generation) => { if (stopping) return; - if (restarting) { queued = true; return; } + if (restarting) { queued = true; reload.enqueued(generation); publishReload(); return; } restarting = true; + reload.started(kind, generation); publishReload(); readiness.pause(); + let rebuilt = false; try { const names = kind === 'api' ? ['api'] : ['api', 'worker', 'cli-host']; await stopChildren(names); @@ -125,11 +132,22 @@ export async function start(config, inherited = process.env) { if (kind === 'api') await launch('api', ['--import', 'tsx', 'apps/api/src/server.ts']); else await sourceServices(); if (stopping) return; readiness.start(); + rebuilt = true; say(`${kind} source change rebuilt/restarted; waiting for readiness.`); } catch (error) { say(`Reload failed: ${error.message}`); await shutdown(1); } - finally { restarting = false; if (queued && !stopping) { queued = false; void rebuild('packages'); } } + finally { restarting = false; reload.finished(generation, rebuilt); publishReload(); if (queued && !stopping) { queued = false; const next = reload.releaseQueued(); publishReload(); void rebuild('packages', next); } } }; try { + await launches.track(async () => { + proofChannel = await openReloadProofChannel(config, { nonce: record.nonce, pid: record.pid, identity: record.identity }, () => observeReloadProof({ + tail: () => writeTail, + closed: () => stopping || receiptClosed, + snapshot: () => ({ nonce: record.nonce, pid: record.pid, identity: record.identity, reload: reload.snapshot(), children: [...children.values()].map(item => ({ ...item.record })) }), + })); + if (stopping) await proofChannel.close(); + else { record.proofSocket = proofChannel.name; await recordChildren(); } + }); + if (stopping) return; const pins = extractPins(await readFile(join(config.root, 'packages/run-spaceapp/templates/compose.yml'), 'utf8'), await readFile(join(config.root, 'Dockerfile'), 'utf8')); await privateWrite(config.compose, JSON.stringify(composeDocument(owner, pins, secret.password), null, 2) + '\n'); await verifyPtyCapability(config.root, config.state, { inherited: env }); @@ -153,7 +171,7 @@ export async function start(config, inherited = process.env) { const tokenDisplay = inherited.CI || inherited.SPACE_DEV_REDACT_SETUP === 'true' ? '[redacted: private state file]' : secret.token || '[already consumed]'; say(`One-time owner setup token: ${tokenDisplay}`); say(`State ${config.state}; down stops, reset removes only owned state. Browser/companion/root services are disabled. No provider account or inference is used.`); - sourceChanges = debounceSourceChanges(rebuild); + sourceChanges = debounceSourceChanges(rebuild, state => { reload.debounced(state); publishReload(); }); for (const [dir, kind] of [['apps/api/src', 'api'], ['apps/worker/src', 'packages'], ['packages', 'packages']]) { watchers.push(watch(join(config.root, dir), { recursive: true }, (_event, file) => { if (file && !/(?:^|[\\/])(?:node_modules|dist|\.git)(?:[\\/]|$)/.test(file) && /\.(?:ts|tsx|js|mjs|json)$/.test(file)) sourceChanges.change(kind); })); } diff --git a/scripts/public-export.mjs b/scripts/public-export.mjs index 6c516c340..d2750b773 100644 --- a/scripts/public-export.mjs +++ b/scripts/public-export.mjs @@ -93,6 +93,8 @@ const publicScripts = new Set([ "scripts/dev-stack/capability-proof.mjs", "scripts/dev-stack/terminal-proof.mjs", "scripts/dev-stack/acceptance.mjs", + "scripts/dev-stack/acceptance-restoration.mjs", + "scripts/dev-stack/reload-proof-channel.mjs", "scripts/dev-stack/browser-proof.mjs", "scripts/dev-stack/lifecycle-proof.mjs", "scripts/tests/dev-stack.test.mjs", diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs index 0e2fed204..dbcb8c20d 100644 --- a/scripts/tests/dev-stack.test.mjs +++ b/scripts/tests/dev-stack.test.mjs @@ -933,3 +933,302 @@ test('source rebuild cancellation before during and after commands never permits await assert.rejects(buildSourceServices(async () => { if (++finished === sourceReloadBuildPlan.length) stopping = true; }, () => stopping).then(() => { launched = true; }), /cancelled before relaunch/); assert.equal(finished, sourceReloadBuildPlan.length); assert.equal(launched, false); }); + +const { ReloadReceipt } = await import('../dev-stack/reload-debounce.mjs'); +const { reloadObservation, restorationCheckpoint } = await import('../dev-stack/acceptance-restoration.mjs'); + +// Unit-only identities/readiness/bytes exercise the join, not a native platform proof. +function restorationFixture() { + const nonce = 'unit-only-restoration-owner'; + const receipt = (generation, revision = generation * 10) => ({ version: 1, revision, requested: generation, pending: null, queued: null, active: null, completed: generation, stopping: false }); + const record = (generation, offset = generation * 10) => ({ nonce, pid: 90, identity: 'unit-only:supervisor', reload: receipt(generation), children: ['api', 'cli-host', 'web', 'worker'].map((name, index) => ({ name, pid: offset + index + 100, identity: 'darwin:Sun Oct 18 01:00:00 2026' })) }); + const before = record(1); + let current = record(2), clock = 0, pauses = 0, readiness = 0, bytes = Buffer.from('original shared output'); + const reply = (snapshot, request) => ({ version: 1, nonce, pid: before.pid, identity: before.identity, challenge: request.challenge, status: 'OK', snapshot: structuredClone(snapshot) }); + const options = { nonce, supervisor: before, before, changed: ['api', 'worker', 'cli-host'], timeout: 300000, observe: async request => reply(current, request), live: async () => true, ready: async () => { readiness++; return true; }, files: [{ name: 'compiled shared output', read: async () => bytes, expected: Buffer.from(bytes), forbidden: 'unit-canary' }], now: () => clock, pause: async () => { pauses++; clock += 300000; } }; + return { before, receipt, record, reply, options, get current() { return current; }, set current(value) { current = value; }, set bytes(value) { bytes = Buffer.from(value); }, get pauses() { return pauses; }, get readiness() { return readiness; } }; +} + +test('reload observations retain the exact debounce precedence and count every pending request', { timeout: 5000 }, async t => { + const receipt = new ReloadReceipt(), observations = [], calls = []; let notify; + const fired = new Promise(resolvePromise => { notify = resolvePromise; }); + const changes = debounceSourceChanges((kind, generation) => { calls.push({ kind, generation }); receipt.started(kind, generation); notify(); }, state => { receipt.debounced(state); observations.push(receipt.snapshot()); }); + t.after(() => changes.stop()); + changes.change('packages'); changes.change('api'); + assert.deepEqual(receipt.snapshot().pending, { generation: 2, kind: 'packages' }); + assert.equal(receipt.snapshot().requested, 2); assert.equal(receipt.snapshot().completed, 0); + await fired; + assert.deepEqual(calls, [{ kind: 'packages', generation: 2 }]); + assert.equal(observations.at(-1).pending, null); + receipt.finished(2, true); + assert.equal(receipt.snapshot().completed, 2); assert.equal(receipt.snapshot().active, null); + changes.stop(); changes.change('packages'); + assert.equal(receipt.snapshot().stopping, true); assert.equal(receipt.snapshot().requested, 2); + assert(observations.every((item, index) => index === 0 || item.revision > observations[index - 1].revision)); +}); + +test('completing an active reload cannot certify pending or queued work and failures never complete it', () => { + const receipt = new ReloadReceipt(); + receipt.debounced({ requested: 1, pending: null, stopped: false }); receipt.started('api', 1); + receipt.debounced({ requested: 2, pending: { kind: 'packages', generation: 2 }, stopped: false }); + receipt.debounced({ requested: 2, pending: null, stopped: false }); receipt.enqueued(2); + receipt.finished(1, true); + assert.equal(receipt.snapshot().completed, 1); assert.equal(receipt.snapshot().requested, 2); + assert.deepEqual(receipt.snapshot().queued, { kind: 'packages', generation: 2 }); + const queued = receipt.releaseQueued(); assert.equal(queued, 2); + assert.equal(receipt.snapshot().completed < receipt.snapshot().requested, true); + receipt.started('packages', queued); receipt.finished(queued, false); + assert.equal(receipt.snapshot().completed, 1); + receipt.stop(); assert.equal(receipt.snapshot().stopping, true); +}); + +test('the old indistinguishable ready/live/original-byte state cannot pass without a reload receipt', async () => { + const fixture = restorationFixture(); delete fixture.current.reload; + assert(fixture.current.children.every(item => item.pid !== fixture.before.children.find(old => old.name === item.name).pid)); + await assert.rejects(restorationCheckpoint(fixture.options), /Missing or malformed reload receipt/); + assert.equal(fixture.readiness, 0); + assert(isPublicExportPath('scripts/dev-stack/acceptance-restoration.mjs')); +}); + +test('restoration waits through pending queued and active phases despite unchanged ready and original bytes', async () => { + const fixture = restorationFixture(), final = fixture.record(3), states = ['pending', 'queued', 'active'].map((phase, index) => ({ ...fixture.record(2), reload: { ...fixture.receipt(3, 30 + index), completed: 2, [phase]: { kind: 'packages', generation: 3 } } })); + final.reload.revision = 33; + fixture.current = states.shift(); + fixture.options.pause = async () => { fixture.current = states.shift() || final; }; + const joined = await restorationCheckpoint(fixture.options); + assert.equal(joined.reload.completed, 3); assert.equal(joined.reload.requested, 3); + assert.equal(fixture.readiness, 1); assert.deepEqual(joined.children, final.children); + assert.equal(joined.files[0].bytes, Buffer.byteLength('original shared output')); + assert.match(joined.files[0].sha256, /^[a-f0-9]{64}$/); +}); + +test('a request arriving between the two observations prevents a mixed-generation handoff', async () => { + const fixture = restorationFixture(), first = fixture.record(2), queued = { ...fixture.record(2), reload: { ...fixture.receipt(3, 31), completed: 2, queued: { kind: 'packages', generation: 3 } } }, final = fixture.record(3); + final.reload.revision = 32; + let reads = 0; + fixture.options.observe = async request => fixture.reply(++reads === 1 ? first : reads === 2 ? queued : final, request); + fixture.options.pause = async () => {}; + const joined = await restorationCheckpoint(fixture.options); + assert.equal(reads, 4); assert.equal(fixture.readiness, 2); assert.equal(joined.reload.completed, 3); +}); + +test('same-second Darwin birth strings with changed verified PIDs are distinct restored identities', async () => { + const fixture = restorationFixture(); + assert.equal(fixture.current.children[0].identity, fixture.before.children[0].identity); + const live = new Map([fixture.current, ...fixture.current.children].map(item => [item.pid, item.identity])); + fixture.options.live = item => sameProcess(item, async pid => live.get(pid) ?? null); + const joined = await restorationCheckpoint(fixture.options); + assert.notEqual(joined.children[0].pid, fixture.before.children[0].pid); + assert.equal(joined.children[0].identity, fixture.before.children[0].identity); +}); + +test('old ready responses cannot certify an unchanged API or an unreplaced shared service', async () => { + for (const name of ['api', 'worker', 'cli-host']) { + const fixture = restorationFixture(); fixture.current.children.find(item => item.name === name).pid = fixture.before.children.find(item => item.name === name).pid; + await assert.rejects(restorationCheckpoint(fixture.options), /existing 300s proof bound/); + assert.equal(fixture.readiness, 0); assert.equal(fixture.pauses, 1); + } + const fixture = restorationFixture(); fixture.current = fixture.record(1, 200); + await assert.rejects(restorationCheckpoint(fixture.options), /proof bound/); + assert.equal(fixture.readiness, 0, 'New PIDs without a completed later request are insufficient.'); +}); + +test('original source and compiled bytes and a fresh ready response are all required', async () => { + for (const bytes of ['original shared output\n', 'original shared output unit-canary']) { + const fixture = restorationFixture(); fixture.bytes = bytes; + await assert.rejects(restorationCheckpoint(fixture.options), /proof bound/); + assert.equal(fixture.readiness, 1); + } + const forbidden = restorationFixture(); forbidden.bytes = 'unit-canary'; forbidden.options.files[0].expected = Buffer.from('unit-canary'); + await assert.rejects(restorationCheckpoint(forbidden.options), /proof bound/); + const unavailable = restorationFixture(); unavailable.options.ready = async () => false; + await assert.rejects(restorationCheckpoint(unavailable.options), /proof bound/); + const restored = restorationFixture(); restored.bytes = 'unit-canary'; restored.options.pause = async () => { restored.bytes = 'original shared output'; }; + assert.equal((await restorationCheckpoint(restored.options)).ready, true); assert.equal(restored.readiness, 2); +}); + +test('missing malformed decreasing and unversioned reload receipts cannot become proof', async () => { + for (const mutate of [value => { value.version = 0; }, value => { delete value.pending; }, value => { value.requested = NaN; }, value => { value.completed = 3; }, value => { value.queued = { kind: 'api', generation: 2 }; }, value => { value.active = { kind: 'packages', generation: 0 }; }]) { + const fixture = restorationFixture(); mutate(fixture.current.reload); + await assert.rejects(restorationCheckpoint(fixture.options), /receipt/); + } + const decreasing = restorationFixture(); decreasing.current.reload.revision = 9; + await assert.rejects(restorationCheckpoint(decreasing.options), /moved backwards/); + const unversioned = restorationFixture(); unversioned.current.reload.revision = unversioned.before.reload.revision; + await assert.rejects(restorationCheckpoint(unversioned.options), /without a new revision/); + assert.throws(() => reloadObservation(null), /receipt/); +}); + +test('foreign supervisor records stopping and dead or duplicate idle children fail without retry', async () => { + for (const mutate of [value => { value.nonce = 'foreign'; }, value => { value.pid++; }, value => { value.identity += ':changed'; }, value => { value.reload.stopping = true; }, value => { value.children.pop(); }, value => { value.children[1].pid = value.children[0].pid; }, value => { value.children[0].identity = null; }]) { + const fixture = restorationFixture(); mutate(fixture.current); + await assert.rejects(restorationCheckpoint(fixture.options)); assert.equal(fixture.pauses, 0); + } + const dead = restorationFixture(); dead.options.live = async item => item.pid === dead.current.pid; + await assert.rejects(restorationCheckpoint(dead.options), /child identity changed/); assert.equal(dead.readiness, 0); assert.equal(dead.pauses, 0); + const root = restorationFixture(); root.options.live = async () => false; + await assert.rejects(restorationCheckpoint(root.options), /unbound|supervisor identity changed/); assert.equal(root.pauses, 0); + const malformed = restorationFixture(), failure = new SyntaxError('unit-only incomplete private JSON'); malformed.options.observe = async () => { throw failure; }; + await assert.rejects(restorationCheckpoint(malformed.options), error => error === failure); +}); + +test('an identity changing around fresh readiness or inside the same idle receipt stays fatal', async () => { + const fixture = restorationFixture(); let calls = 0; + fixture.options.live = async item => item.pid === fixture.current.pid || ++calls <= 4; + await assert.rejects(restorationCheckpoint(fixture.options), /child identity changed/); assert.equal(fixture.pauses, 0); + const mixed = restorationFixture(); let reads = 0; + mixed.options.observe = async request => { const value = structuredClone(mixed.current); if (++reads === 2) value.children[0].pid += 500; return mixed.reply(value, request); }; + await assert.rejects(restorationCheckpoint(mixed.options), /same idle reload receipt/); assert.equal(mixed.pauses, 0); +}); + +test('the strict actual lifecycle snapshot still rejects root churn after a successful restoration join', async () => { + const source = await readFile(join(root, 'scripts/dev-stack/lifecycle-proof.mjs'), 'utf8'); + const body = source.slice(source.indexOf('async function processSnapshot('), source.indexOf('async function assertStopped(')); + assert(body.startsWith('async function processSnapshot(')); + const fixture = restorationFixture(), joined = await restorationCheckpoint(fixture.options), first = joined.children[0]; + let calls = 0, lists = 0; + const identity = async pid => pid === first.pid && ++calls > 1 ? 'unit-only:late-replacement' : joined.children.find(item => item.pid === pid)?.identity; + const snapshot = new Function('readJson', 'sameProcess', 'descendants', `return (${body});`)(async () => joined, item => sameProcess(item, identity), item => descendants(item, { identity, list: async () => { lists++; return { stdout: '' }; } })); + await assert.rejects(snapshot({ supervisor: 'unit-only-unused-path' }), /root changed before descendant collection/); + assert.equal(lists, 0, 'The guard rejects before adopting or collecting a replacement tree.'); +}); + +test('the restoration helper cannot widen the original API or shared bounds or a remaining phase deadline', async () => { + const fixture = restorationFixture(); + for (const timeout of [0, 150000, 300001, Infinity]) await assert.rejects(restorationCheckpoint({ ...fixture.options, timeout }), /boundary/); + await assert.rejects(restorationCheckpoint({ ...fixture.options, until: 0 }), /existing 300s proof bound/); + await assert.rejects(restorationCheckpoint({ ...fixture.options, until: NaN }), /deadline/); +}); + +const { observeReloadProof, openReloadProofChannel, parseReloadRequest, queryReloadProof, reserveReloadProofObservation } = await import('../dev-stack/reload-proof-channel.mjs'); +const { randomUUID: proofChallenge } = await import('node:crypto'); + +test('an already known pending reload cannot pass through two stale disk reads while publication is held', async () => { + const fixture = restorationFixture(); let memory = fixture.current, persisted = structuredClone(memory), tail = Promise.resolve(), releaseWrite, releasePause, paused = false, completed = false, snapshots = 0; + fixture.options.observe = async request => { + const result = await observeReloadProof({ tail: () => tail, closed: () => false, snapshot: () => { snapshots++; return structuredClone(memory); } }); + return { ...fixture.reply(null, request), ...result }; + }; + fixture.options.ready = async () => { + if (memory.reload.requested === 2) { + memory = { ...fixture.record(2), reload: { ...fixture.receipt(3, 31), completed: 2, pending: { kind: 'packages', generation: 3 } } }; + tail = new Promise(resolvePromise => { releaseWrite = resolvePromise; }).then(() => { persisted = structuredClone(memory); }); + } + return true; + }; + fixture.options.pause = async () => { paused = true; await new Promise(resolvePromise => { releasePause = resolvePromise; }); }; + const joining = restorationCheckpoint(fixture.options).then(value => { completed = true; return value; }); + await new Promise(resolvePromise => setImmediate(resolvePromise)); + assert.equal(persisted.reload.requested, 2); assert.equal(memory.reload.requested, 3); + assert.equal(snapshots, 1, 'The second request must wait for its captured pending publication.'); + assert.equal(completed, false); + releaseWrite(); await new Promise(resolvePromise => setImmediate(resolvePromise)); + assert.equal(paused, true); assert.equal(completed, false, 'Drained but pending RAM is BUSY, never idle disk generation2.'); + memory = fixture.record(3); memory.reload.revision = 32; persisted = structuredClone(memory); tail = Promise.resolve(); + releasePause(); + const joined = await joining; + assert.equal(joined.reload.completed, 3); assert.equal(persisted.reload.completed, 3); + assert.equal(joined.proofRequests.length, 2); assert.notEqual(...joined.proofRequests); +}); + +test('a tail added during a request drain is BUSY and cannot publish an earlier snapshot', async () => { + let release, snapshots = 0; + const first = new Promise(resolvePromise => { release = resolvePromise; }); let tail = first; + const request = observeReloadProof({ tail: () => tail, closed: () => false, snapshot: () => { snapshots++; return restorationFixture().current; } }); + tail = first.then(() => {}); release(); + assert.deepEqual(await request, { status: 'BUSY', snapshot: null }); assert.equal(snapshots, 0); +}); + +test('publication rejection shutdown and malformed snapshots are fatal rather than BUSY', async () => { + const settled = Promise.resolve(); + await assert.rejects(observeReloadProof({ tail: () => Promise.reject(new Error('unit publication error')), closed: () => false, snapshot: () => ({}) }), /WRITE_FAILED/); + await assert.rejects(observeReloadProof({ tail: () => Promise.resolve(), closed: () => true, snapshot: () => ({}) }), /STOPPING/); + let release, closed = false; + const tail = new Promise(resolvePromise => { release = resolvePromise; }); + const pending = observeReloadProof({ tail: () => tail, closed: () => closed, snapshot: () => { throw new Error('Must not snapshot during shutdown'); } }); + closed = true; release(); await assert.rejects(pending, /STOPPING/); + await assert.rejects(observeReloadProof({ tail: () => settled, closed: () => false, snapshot: () => ({ reload: {} }) }), /malformed reload receipt/); + const fixture = restorationFixture(); fixture.current.reload.stopping = true; + const resolved = Promise.resolve(); + await assert.rejects(observeReloadProof({ tail: () => resolved, closed: () => false, snapshot: () => fixture.current }), /STOPPING/); +}); + +test('fresh request binding rejects replayed foreign or missing channel responses without a file fallback', async () => { + for (const mutate of [value => { value.challenge = proofChallenge(); }, value => { value.nonce = 'foreign'; }, value => { value.pid++; }, value => { value.identity = 'different'; }, value => { value.version = 0; }, value => { value.status = 'ERROR'; }, value => { value.status = 'BUSY'; }]) { + const fixture = restorationFixture(); fixture.options.observe = async request => { const value = fixture.reply(fixture.current, request); mutate(value); return value; }; + await assert.rejects(restorationCheckpoint(fixture.options)); assert.equal(fixture.pauses, 0); + } + const replay = restorationFixture(); let first; + replay.options.observe = async request => first ||= replay.reply(replay.current, request); + await assert.rejects(restorationCheckpoint(replay.options), /stale or unbound/); + await assert.rejects(restorationCheckpoint({ ...restorationFixture().options, observe: undefined }), /no file-only fallback/); +}); + +test('request parsing enforces one bounded owned message with no supplied paths or unknown fields', () => { + const owner = { nonce: 'a'.repeat(32), pid: 90, identity: 'unit-only:supervisor' }, request = { version: 1, ...owner, challenge: proofChallenge() }; + const encoded = value => Buffer.from(JSON.stringify(value) + '\n'); + assert.deepEqual(parseReloadRequest(encoded(request), owner), request); + for (const value of [{ ...request, path: '/foreign' }, { ...request, nonce: 'foreign' }, { ...request, pid: 91 }, { ...request, identity: 'other' }, { ...request, challenge: 'not-a-fresh-uuid' }, { ...request, version: 2 }]) assert.throws(() => parseReloadRequest(encoded(value), owner), /REQUEST/); + for (const bytes of [Buffer.from('{}'), Buffer.concat([encoded(request), encoded(request)]), Buffer.from('x'.repeat(1025)), Buffer.from('{not-json}\n')]) assert.throws(() => parseReloadRequest(bytes, owner), /REQUEST/); + assert(isPublicExportPath('scripts/dev-stack/reload-proof-channel.mjs')); +}); + +test('a completed request cannot qualify after the unchanged overall proof deadline', async () => { + const fixture = restorationFixture(); let clock = 0; + fixture.options.now = () => clock; fixture.options.ready = async () => { clock = 300000; return true; }; + await assert.rejects(restorationCheckpoint(fixture.options), /existing 300s proof bound/); +}); + +async function unixProofFixture(t, { allowReplaced = false } = {}) { + const state = await realpath(await mkdtemp(join(tmpdir(), 'space-reload-proof-'))); + await chmod(state, 0o700); await mkdir(join(state, 'run'), { mode: 0o700 }); + const owner = { nonce: 'b'.repeat(32), pid: process.pid, identity: await processIdentity(process.pid) }; + const fixture = restorationFixture(), settled = Promise.resolve(); + const channel = await openReloadProofChannel({ state }, owner, () => observeReloadProof({ tail: () => settled, closed: () => false, snapshot: () => ({ ...fixture.current, ...owner }) })); + t.after(async () => { + try { if (allowReplaced) await assert.rejects(channel.close(), /SOCKET_REPLACED/); else await channel.close(); } + finally { await rm(state, { recursive: true, force: true }); } + }); + return { state, owner: { ...owner, proofSocket: channel.name }, channel }; +} + +test('real POSIX owned socket transports fresh observations and removes its own private path', { skip: process.platform === 'win32' }, async t => { + const { state, owner, channel } = await unixProofFixture(t), file = join(state, 'run', channel.name); + assert.equal((await stat(file)).mode & 0o777, 0o600); + const first = await queryReloadProof({ state }, owner, { challenge: proofChallenge() }, 3000), second = await queryReloadProof({ state }, owner, { challenge: proofChallenge() }, 3000); + assert.equal(first.status, 'OK'); assert.equal(first.snapshot.pid, process.pid); assert.notEqual(first.challenge, second.challenge); + await assert.rejects(queryReloadProof({ state }, { ...owner, nonce: 'c'.repeat(32) }, { challenge: proofChallenge() }, 3000)); + await channel.close(); assert.equal(await stat(file).catch(error => error.code), 'ENOENT'); +}); + +test('real POSIX channel shutdown destroys stalled clients without waiting for a request', { skip: process.platform === 'win32' }, async t => { + const { createConnection } = await import('node:net'); + const { state, channel } = await unixProofFixture(t), socket = createConnection(join(state, 'run', channel.name)); + socket.on('error', () => {}); await new Promise(resolvePromise => socket.once('connect', resolvePromise)); + const closed = new Promise(resolvePromise => socket.once('close', resolvePromise)); + channel.stopAdmission(); await channel.close(); await closed; + assert.equal(socket.destroyed, true); +}); + +test('real POSIX channel refuses linked directories and preserves a foreign replacement on cleanup', { skip: process.platform === 'win32' }, async t => { + const { unlink } = await import('node:fs/promises'); + const { state, owner, channel } = await unixProofFixture(t, { allowReplaced: true }), file = join(state, 'run', channel.name); + await unlink(file); await writeFile(file, 'unit-only-foreign-replacement', { mode: 0o600, flag: 'wx' }); + await assert.rejects(channel.close(), /SOCKET_REPLACED/); + assert.equal(await readFile(file, 'utf8'), 'unit-only-foreign-replacement'); + // The failed close is deliberately retained for recovery and its handle is unref'd. + const target = join(state, 'linked-run'); await mkdir(target, { mode: 0o700 }); + await rm(join(state, 'run'), { recursive: true, force: true }); await symlink(target, join(state, 'run')); + await assert.rejects(openReloadProofChannel({ state }, owner, async () => ({ status: 'BUSY', snapshot: null })), /DIRECTORY/); +}); + +test('three held publications plus four preaccepted clients can reserve only four observation slots', () => { + const held = new Set(); + for (let index = 0; index < 3; index++) reserveReloadProofObservation(held, { unitOnly: `timed-out-but-still-pending-${index}` }); + const preaccepted = Array.from({ length: 4 }, (_, index) => ({ unitOnly: `already-connected-${index}` })); + reserveReloadProofObservation(held, preaccepted[0]); + for (const socket of preaccepted.slice(1)) assert.throws(() => reserveReloadProofObservation(held, socket), /CAPACITY/); + assert.equal(held.size, 4); + held.delete(preaccepted[0]); reserveReloadProofObservation(held, preaccepted[1]); + assert.equal(held.size, 4, 'Completion releases only one slot for a later actual request.'); +}); From 884d0015b7a249069091ead76614deef217aab6e Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Mon, 19 Oct 2026 07:56:50 +0200 Subject: [PATCH 24/27] fix: coordinate Git worktree metadata operations Serialize owned worktree metadata operations by the canonical Git common directory while allowing unrelated repositories and post-add setup to proceed independently. Preserve typed failures and add deterministic concurrency regressions. Supply ffmpeg for the hosted Linux attachment fixture. --- .github/workflows/dev-stack.yml | 5 +- CHANGELOG.md | 2 + apps/api/src/worktrees/worktree-service.ts | 156 ++++++++++++--------- apps/api/tests/room-pane-worktrees.test.ts | 117 +++++++++++++++- 4 files changed, 207 insertions(+), 73 deletions(-) diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml index 0fb380f10..197f20525 100644 --- a/.github/workflows/dev-stack.yml +++ b/.github/workflows/dev-stack.yml @@ -92,9 +92,9 @@ jobs: colima start --profile spaceapp-dev-proof --cpu 3 --memory 6 --disk 30 docker context use colima-spaceapp-dev-proof - - name: Prepare hosted Linux search tool + - name: Prepare hosted Linux test prerequisites if: runner.os == 'Linux' - run: sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep + run: sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep ffmpeg - name: Verify existing Docker, Compose, browser and search tools run: | @@ -105,6 +105,7 @@ jobs: test -x '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' else test -x /usr/bin/google-chrome + test -x /usr/bin/ffmpeg fi - name: Install locked workspace dependencies diff --git a/CHANGELOG.md b/CHANGELOG.md index 4117a8ed1..0533004bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ Semantic Versioning. ### Fixed +- Serialize Git worktree metadata operations for each repository during concurrent + pane setup. - Preserve package reload priority and recovery before Compose setup in the source development stack. - Validate isolated storage roots and preserve typed DOCX errors and MCP diagnostics diff --git a/apps/api/src/worktrees/worktree-service.ts b/apps/api/src/worktrees/worktree-service.ts index de85a448b..7dcfaa26b 100644 --- a/apps/api/src/worktrees/worktree-service.ts +++ b/apps/api/src/worktrees/worktree-service.ts @@ -117,7 +117,31 @@ export interface WorktreeRegistryData { // All API service instances sharing a registry must serialize read/modify/write. // Atomic rename alone protects the file, but can still lose concurrent records. const registryUpdates = new Map>(); -const repositoryInitializations = new Map>(); +const repositoryMetadataUpdates = new Map>(); + +// Git publishes a worktree's linking files separately. Another worktree command +// must not enumerate that shared directory while its commondir is still empty. +// The common-directory key also covers linked paths and separate service instances. +async function withWorktreeMetadata(repoPath: string, work: () => Promise): Promise { + const common = await execGit(repoPath, ["rev-parse", "--path-format=absolute", "--git-common-dir"]); + if (common.code !== 0 || !common.stdout.trim()) { + throw new WorktreeError("NOT_GIT_REPO", `Cannot resolve Git metadata: ${common.stderr.trim()}`); + } + const key = await realpath(resolve(repoPath, common.stdout.trim())); + const previous = repositoryMetadataUpdates.get(key) ?? Promise.resolve(); + const result = previous.then(work, work); + const settled = result.then(() => {}, () => {}); + repositoryMetadataUpdates.set(key, settled); + try { + return await result; + } finally { + if (repositoryMetadataUpdates.get(key) === settled) repositoryMetadataUpdates.delete(key); + } +} + +function execMetadataGit(repoPath: string, args: string[], timeoutMs = 25000): Promise { + return withWorktreeMetadata(repoPath, () => execGit(repoPath, args, timeoutMs)); +} export class WorktreeService { constructor(private readonly baseStorageDir = WORKTREES_ROOT) {} @@ -228,67 +252,59 @@ export class WorktreeService { const worktreeId = `wt-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`; const targetDir = join(this.baseStorageDir, repoSlug, worktreeId); - // Share initialization so parallel checkouts cannot race on an unborn - // repository's index or create several independent initial commits. - let initialization = repositoryInitializations.get(repoRoot); - if (!initialization) { - initialization = (async () => { - const repoHeadCheck = await execGit(repoRoot, ["rev-parse", "--verify", "HEAD"]); - if (repoHeadCheck.code !== 0) { - await execGit(repoRoot, ["add", "-A"]); - await execGit(repoRoot, ["-c", "user.name=SpaceApp", "-c", "user.email=space@local", "commit", "-m", "chore: initial commit", "--allow-empty"]); - } - })(); - repositoryInitializations.set(repoRoot, initialization); - } - try { - await initialization; - } finally { - if (repositoryInitializations.get(repoRoot) === initialization) repositoryInitializations.delete(repoRoot); - } - - // Determine base branch (fallback to current branch if specified base does not exist) - let baseBranch = input.baseBranch?.trim() || "main"; - const baseBranchRefCheck = await execGit(repoRoot, ["show-ref", "--verify", `refs/heads/${baseBranch}`]); - if (baseBranchRefCheck.code !== 0) { - const currentBranchCheck = await execGit(repoRoot, ["branch", "--show-current"]); - const current = currentBranchCheck.stdout.trim(); - if (current) { - baseBranch = current; + const { baseBranch, branchName } = await withWorktreeMetadata(repoRoot, async () => { + // Initialization and branch checks share the registration lease, so an + // unborn repository gets one initial commit and branch checks see complete metadata. + const repoHeadCheck = await execGit(repoRoot, ["rev-parse", "--verify", "HEAD"]); + if (repoHeadCheck.code !== 0) { + await execGit(repoRoot, ["add", "-A"]); + await execGit(repoRoot, ["-c", "user.name=SpaceApp", "-c", "user.email=space@local", "commit", "-m", "chore: initial commit", "--allow-empty"]); } - } - // Determine branch name - const branchName = input.branchName?.trim() || `space/${worktreeId}`; - - // Ensure parent directory exists - await mkdir(dirname(targetDir), { recursive: true, mode: 0o775 }); - await chmod(dirname(targetDir), 0o775).catch(() => {}); - - // Check if branch already exists in repo - const branchCheck = await execGit(repoRoot, ["show-ref", "--verify", `refs/heads/${branchName}`]); - const branchExists = branchCheck.code === 0; + // Determine base branch (fallback to current branch if specified base does not exist) + let baseBranch = input.baseBranch?.trim() || "main"; + const baseBranchRefCheck = await execGit(repoRoot, ["show-ref", "--verify", `refs/heads/${baseBranch}`]); + if (baseBranchRefCheck.code !== 0) { + const currentBranchCheck = await execGit(repoRoot, ["branch", "--show-current"]); + const current = currentBranchCheck.stdout.trim(); + if (current) { + baseBranch = current; + } + } - // Check if branch is already checked out elsewhere - if (branchExists) { - const listCheck = await execGit(repoRoot, ["worktree", "list", "--porcelain"]); - if (listCheck.stdout.includes(`refs/heads/${branchName}`)) { - throw new WorktreeError( - "BRANCH_ALREADY_CHECKED_OUT", - `Branch ${branchName} is already checked out in another worktree.` - ); + // Determine branch name + const branchName = input.branchName?.trim() || `space/${worktreeId}`; + + // Ensure parent directory exists + await mkdir(dirname(targetDir), { recursive: true, mode: 0o775 }); + await chmod(dirname(targetDir), 0o775).catch(() => {}); + + // Check if branch already exists in repo + const branchCheck = await execGit(repoRoot, ["show-ref", "--verify", `refs/heads/${branchName}`]); + const branchExists = branchCheck.code === 0; + + // Check if branch is already checked out elsewhere + if (branchExists) { + const listCheck = await execGit(repoRoot, ["worktree", "list", "--porcelain"]); + if (listCheck.stdout.includes(`refs/heads/${branchName}`)) { + throw new WorktreeError( + "BRANCH_ALREADY_CHECKED_OUT", + `Branch ${branchName} is already checked out in another worktree.` + ); + } } - } - // Run git worktree add - const addArgs = branchExists - ? ["worktree", "add", targetDir, branchName] - : ["worktree", "add", targetDir, "-b", branchName, baseBranch]; + // Run git worktree add + const addArgs = branchExists + ? ["worktree", "add", targetDir, branchName] + : ["worktree", "add", targetDir, "-b", branchName, baseBranch]; - const result = await execGit(repoRoot, addArgs); - if (result.code !== 0) { - throw new WorktreeError("WORKTREE_ADD_FAILED", `Failed to add worktree: ${result.stderr.trim()}`); - } + const result = await execGit(repoRoot, addArgs); + if (result.code !== 0) { + throw new WorktreeError("WORKTREE_ADD_FAILED", `Failed to add worktree: ${result.stderr.trim()}`); + } + return { baseBranch, branchName }; + }); await chmod(targetDir, 0o775).catch(() => {}); // Link modules and environment if requested @@ -407,7 +423,7 @@ export class WorktreeService { async listWorktrees(repoPath: string): Promise { const repoRoot = await this.validateGitRepository(repoPath); - const listResult = await execGit(repoRoot, ["worktree", "list", "--porcelain"]); + const listResult = await execMetadataGit(repoRoot, ["worktree", "list", "--porcelain"]); if (listResult.code !== 0) { throw new WorktreeError("WORKTREE_LIST_FAILED", `Failed to list worktrees: ${listResult.stderr.trim()}`); } @@ -508,7 +524,7 @@ export class WorktreeService { try { const toplevel = await execGit(realPath, ["rev-parse", "--show-toplevel"]); if (toplevel.code === 0 && toplevel.stdout.trim() === realPath) { - const branchResult = await execGit(realPath, ["branch", "--show-current"]); + const branchResult = await execMetadataGit(realPath, ["branch", "--show-current"]); const branch = branchResult.stdout.trim(); const headResult = await execGit(realPath, ["rev-parse", "HEAD"]); const head = headResult.stdout.trim(); @@ -714,8 +730,8 @@ export class WorktreeService { // 1. If already recognized as MERGED: if (wt.status === "MERGED") { if (input.deleteBranchAfter && branch && !branch.startsWith("detached-") && branch !== "main") { - await execGit(repoRoot, ["branch", "-d", branch]).catch(async () => { - await execGit(repoRoot, ["branch", "-D", branch]).catch(() => {}); + await execMetadataGit(repoRoot, ["branch", "-d", branch]).catch(async () => { + await execMetadataGit(repoRoot, ["branch", "-D", branch]).catch(() => {}); }); } if (input.deleteBranchAfter) { @@ -771,8 +787,8 @@ export class WorktreeService { if (alreadyMerged) { if (input.deleteBranchAfter && branch && !branch.startsWith("detached-") && branch !== "main") { - await execGit(repoRoot, ["branch", "-d", branch]).catch(async () => { - await execGit(repoRoot, ["branch", "-D", branch]).catch(() => {}); + await execMetadataGit(repoRoot, ["branch", "-d", branch]).catch(async () => { + await execMetadataGit(repoRoot, ["branch", "-D", branch]).catch(() => {}); }); } if (input.deleteBranchAfter) { @@ -828,8 +844,8 @@ export class WorktreeService { } if (input.deleteBranchAfter && branch && !branch.startsWith("detached-") && branch !== "main") { - await execGit(repoRoot, ["branch", "-d", branch]).catch(async () => { - await execGit(repoRoot, ["branch", "-D", branch]).catch(() => {}); + await execMetadataGit(repoRoot, ["branch", "-d", branch]).catch(async () => { + await execMetadataGit(repoRoot, ["branch", "-D", branch]).catch(() => {}); }); } @@ -887,8 +903,10 @@ export class WorktreeService { if (repoRoot && repoRoot !== realPath) { const removeArgs = force ? ["worktree", "remove", "--force", realPath] : ["worktree", "remove", realPath]; - await execGit(repoRoot, removeArgs); - await execGit(repoRoot, ["worktree", "prune"]); + await withWorktreeMetadata(repoRoot, async () => { + await execGit(repoRoot, removeArgs); + await execGit(repoRoot, ["worktree", "prune"]); + }); } // Cleanup any remaining filesystem leftovers if git left them @@ -913,7 +931,7 @@ export class WorktreeService { preserved: Array<{ path: string; branch: string; reason: string }>; }> { const repoRoot = await this.validateGitRepository(repoPath); - await execGit(repoRoot, ["worktree", "prune"], 5000).catch(() => null); + await execMetadataGit(repoRoot, ["worktree", "prune"], 5000).catch(() => null); const worktrees = await this.listWorktrees(repoRoot); const cleaned: Array<{ path: string; branch: string; reason: string }> = []; @@ -1009,7 +1027,7 @@ export class WorktreeService { try { await this.removeWorktree(wt.worktreePath, true); if (wt.branch && !wt.branch.startsWith("detached-") && wt.branch !== "main") { - await execGit(repoRoot, ["branch", "-D", wt.branch]).catch(() => {}); + await execMetadataGit(repoRoot, ["branch", "-D", wt.branch]).catch(() => {}); } await this.recordWorktree({ id: wt.id, @@ -1033,7 +1051,7 @@ export class WorktreeService { }); } - await execGit(repoRoot, ["worktree", "prune"], 5000).catch(() => null); + await execMetadataGit(repoRoot, ["worktree", "prune"], 5000).catch(() => null); return { cleaned, preserved }; } diff --git a/apps/api/tests/room-pane-worktrees.test.ts b/apps/api/tests/room-pane-worktrees.test.ts index 4052dd8c0..b18808047 100644 --- a/apps/api/tests/room-pane-worktrees.test.ts +++ b/apps/api/tests/room-pane-worktrees.test.ts @@ -1,12 +1,16 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; +import { PassThrough } from "node:stream"; import { afterEach, expect, it, vi } from "vitest"; import { type AgentRuntimeRegistry, type RoomPaneBatchItem, type SpaceWorktree } from "@space/contracts"; import { InMemorySpaceStore } from "@space/runtime"; import { createRoomPaneCommands } from "../src/room-pane-commands.js"; import { execGit, WorktreeError, WorktreeService } from "../src/worktrees/worktree-service.js"; import { defaultWorktreeBaseBranch } from "../src/worktrees/default-base-branch.js"; +import * as gitTransport from "../src/group-writable-git.js"; const roots: string[] = []; afterEach(async () => { @@ -138,3 +142,112 @@ it("gives a T3 Code pane its own worktree of the room project, as for CLI panes" } expect(create).toHaveBeenCalledTimes(3); }); + +function gate() { + let release!: () => void; + const ready = new Promise(resolve => { release = resolve; }); + return { ready, release }; +} + +// A held worktree registration exposes an incomplete sibling to any concurrent +// enumeration, just as Git's existing-but-empty commondir reader fails closed. +async function metadataFixture(failBranch?: string) { + const root = await scratchRoot(); + const repo = join(root, "repo"), alias = join(root, "alias"), other = join(root, "other"); + const common = join(repo, ".git"), commonAlias = join(root, "common-alias"); + await Promise.all([mkdir(common, { recursive: true }), mkdir(alias), mkdir(join(other, ".git"), { recursive: true })]); + await symlink(common, commonAlias, process.platform === "win32" ? "junction" : "dir"); + const commonDirs = new Map([[repo, common], [alias, commonAlias], [other, join(other, ".git")]]); + const services = ["first", "second", "other"].map(name => new WorktreeService(join(root, name))); + for (const service of services) vi.spyOn(service, "validateGitRepository").mockImplementation(async path => path); + const held = gate(), started = gate(); + const active = new Set(), addressed = new Set(), collisions: string[] = []; + const calls: Array<{ root: string; args: string[] }> = []; + function reply(stdout: string, stderr = "", code = 0, wait = Promise.resolve(), done = () => {}) { + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), stderr: new PassThrough(), kill: () => true + }); + void wait.then(() => { + child.stdout.end(stdout); child.stderr.end(stderr); done(); child.emit("close", code); + }); + return child as ChildProcessWithoutNullStreams; + } + vi.spyOn(gitTransport, "spawnGroupWritableGit").mockImplementation(args => { + const at = args.indexOf("-C"), directory = args[at + 1]!, command = args.slice(at + 2); + calls.push({ root: directory, args: command }); + if (command[0] === "rev-parse" && command.includes("--git-common-dir")) { + addressed.add(directory); + return reply(`${commonDirs.get(directory)}\n`); + } + if (command[0] === "show-ref") return reply("", "", command.at(-1) === "refs/heads/main" ? 0 : 1); + if (command[0] !== "worktree") return reply(`${"a".repeat(40)}\n`); + const key = directory === alias ? resolve(common) : resolve(commonDirs.get(directory)!); + if (active.has(key)) { + collisions.push(command.join(" ")); + return reply("", "fatal: failed to read .git/worktrees/publishing/commondir: empty metadata", 128); + } + if (command[1] !== "add") return reply(""); + const branch = command[command.indexOf("-b") + 1]; + active.add(key); + const wait = branch === "test/held" ? held.ready : Promise.resolve(); + if (branch === "test/held") started.release(); + return reply("", branch === failBranch ? "fixture add failure" : "", branch === failBranch ? 128 : 0, + wait, () => { active.delete(key); }); + }); + const create = (service: WorktreeService, path: string, branch: string, linkModules = false) => + service.createWorktree({ repoPath: path, roomId: "room:metadata", branchName: branch, baseBranch: "main", linkModules }); + return { repo, alias, other, services, held, started, addressed, collisions, calls, create }; +} + +it("protects shared Git metadata across service and common-directory aliases while other repositories overlap", async () => { + const f = await metadataFixture(); + const [first, second, other] = f.services as [WorktreeService, WorktreeService, WorktreeService]; + const pending = [f.create(first, f.repo, "test/held")]; + await f.started.ready; + const errors: unknown[] = []; + pending.push(f.create(second, f.alias, "test/second")); + const listed = second.listWorktrees(f.alias).catch(error => { errors.push(error); return []; }); + for (const work of pending) void work.catch(error => { errors.push(error); }); + try { + await f.create(other, f.other, "test/other"); + await expect.poll(() => f.addressed.has(f.alias) || f.collisions.length > 0).toBe(true); + expect(f.collisions).toEqual([]); + } finally { + f.held.release(); + await Promise.allSettled([...pending, listed]); + } + expect(errors).toEqual([]); + expect((await pending[1]!).branch).toBe("test/second"); +}); + +it("releases a failed metadata operation without replacing its error or poisoning the next creation", async () => { + const f = await metadataFixture("test/fail"); + const [first, second] = f.services as [WorktreeService, WorktreeService, WorktreeService]; + const results = await Promise.allSettled([ + f.create(first, f.repo, "test/fail"), f.create(second, f.alias, "test/next") + ]); + expect(results[0]).toMatchObject({ status: "rejected", reason: { code: "WORKTREE_ADD_FAILED", message: "Failed to add worktree: fixture add failure" } }); + expect(results[1]).toMatchObject({ status: "fulfilled", value: { branch: "test/next" } }); + expect(f.collisions).toEqual([]); +}); + +it("releases shared metadata before slow module linking", async () => { + const f = await metadataFixture(); + const [first, second] = f.services as [WorktreeService, WorktreeService, WorktreeService]; + await mkdir(join(f.repo, "node_modules")); + const linking = gate(), entered = gate(); + vi.spyOn(WorktreeService, "linkNodeModulesOverlay").mockImplementation(async () => { + entered.release(); await linking.ready; + }); + const pending = f.create(first, f.repo, "test/linking", true); + await entered.ready; + let completed = false; + const next = f.create(second, f.alias, "test/next").then(value => { completed = true; return value; }); + try { + await expect.poll(() => completed, { timeout: 2000 }).toBe(true); + } finally { + linking.release(); await Promise.allSettled([pending, next]); + } + await expect(next).resolves.toMatchObject({ branch: "test/next" }); + await expect(pending).resolves.toMatchObject({ branch: "test/linking" }); +}); From 8a11a042f7cc4a29c69a19e4d640d1f5b3cb80d9 Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Mon, 19 Oct 2026 08:51:09 +0200 Subject: [PATCH 25/27] test: make browser privacy and watchdog fixtures deterministic Inspect the raw browser-session response for synthetic secret values, private paths and private keys while permitting opaque public pane IDs. Give the manually recorded watchdog event a timestamp strictly after setup so latest-event assertions follow the existing timestamp/id ordering. --- apps/api/tests/agent-pane.test.ts | 9 +++++++-- apps/api/tests/room-watchdog.test.ts | 5 +++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/api/tests/agent-pane.test.ts b/apps/api/tests/agent-pane.test.ts index c35f05791..03b8396eb 100644 --- a/apps/api/tests/agent-pane.test.ts +++ b/apps/api/tests/agent-pane.test.ts @@ -1922,7 +1922,8 @@ describe("Space API native agent panes", () => { }); expect(response.statusCode).toBe(200); - const session = agentPaneSessionSchema.parse(response.json()); + const responseBody = response.json(); + const session = agentPaneSessionSchema.parse(responseBody); expect(session.toolOptions).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -1972,7 +1973,11 @@ describe("Space API native agent panes", () => { }) ]) ); - expect(JSON.stringify(session)).not.toMatch(/raw-secret|profilePath|cdp|websocket|token/i); + // Public random IDs can contain "cdp"; inspect private keys and secret values instead. + const serializedResponse = JSON.stringify(responseBody); + expect(serializedResponse).not.toContain("raw-secret"); + expect(serializedResponse).not.toContain("/opt/spaceapp/var/artifacts/browser-profiles/browser-1"); + expect(serializedResponse).not.toMatch(/"[^"\n]*(?:profilePath|cdp|websocket|token)[^"\n]*"\s*:/i); }); it("passes selected browser tools as metadata without injecting Space context into the Codex prompt", async () => { diff --git a/apps/api/tests/room-watchdog.test.ts b/apps/api/tests/room-watchdog.test.ts index 0d6f02898..28725eb6a 100644 --- a/apps/api/tests/room-watchdog.test.ts +++ b/apps/api/tests/room-watchdog.test.ts @@ -231,7 +231,8 @@ describe("Room Watchdog and Anti-Freeze Protection", () => { resolveLock(); await wedgedPromise; - // Also test recording room event directly in store + // Also test recording room event directly in store. Latest ordering breaks timestamp ties by ID. + const latestSetupEvent = store.getLatestEvent(room.id)!; const watchdogEvent: Event = { id: "event:watchdog-test", roomId: room.id, @@ -250,7 +251,7 @@ describe("Room Watchdog and Anti-Freeze Protection", () => { durationMs: 8000, killedAt: new Date().toISOString() }, - createdAt: new Date().toISOString() + createdAt: new Date(Date.parse(latestSetupEvent.createdAt) + 1).toISOString() }; const recorded = await store.recordRoomEvent(watchdogEvent); From b0ce62fedcc3e7d4731d98e1c7268b84a135dcfd Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Mon, 19 Oct 2026 08:38:53 +0200 Subject: [PATCH 26/27] fix(web): cancel live debug notice timer on unmount --- .../src/features/app-notices/useAppNotices.ts | 6 +++- .../workspace-overlays-controllers.test.tsx | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/web/src/features/app-notices/useAppNotices.ts b/apps/web/src/features/app-notices/useAppNotices.ts index e7e399283..dc33c72ce 100644 --- a/apps/web/src/features/app-notices/useAppNotices.ts +++ b/apps/web/src/features/app-notices/useAppNotices.ts @@ -63,7 +63,11 @@ export function useAppNotices(): AppNotices { if (detail?.message) showLiveDebug(detail.message, detail.type || "info"); }; window.addEventListener("space-live-debug", handleDebug); - return () => window.removeEventListener("space-live-debug", handleDebug); + return () => { + window.removeEventListener("space-live-debug", handleDebug); + if (liveDebugTimerRef.current !== null) window.clearTimeout(liveDebugTimerRef.current); + liveDebugTimerRef.current = null; + }; }, [showLiveDebug]); const [storageWarning, setStorageWarning] = useState(""); diff --git a/apps/web/tests/workspace-overlays-controllers.test.tsx b/apps/web/tests/workspace-overlays-controllers.test.tsx index 70b18bd1c..5085214b0 100644 --- a/apps/web/tests/workspace-overlays-controllers.test.tsx +++ b/apps/web/tests/workspace-overlays-controllers.test.tsx @@ -141,6 +141,38 @@ describe("useAppNotices", () => { log.mockRestore(); }); + it("cancels live debug dismissal on unmount before browser teardown", () => { + vi.useFakeTimers(); + const { unmount } = renderHook(() => useAppNotices()); + act(() => { + window.dispatchEvent(new CustomEvent("space-live-debug", { detail: { message: "Live failed", type: "error" } })); + }); + unmount(); + + // Vitest removes the browser globals after a file finishes. No notice callback may outlive its hook. + const browserWindow = Object.getOwnPropertyDescriptor(globalThis, "window")!; + try { + Reflect.deleteProperty(globalThis, "window"); + expect(() => vi.advanceTimersByTime(4_500)).not.toThrow(); + } finally { + Object.defineProperty(globalThis, "window", browserWindow); + } + }); + + it("gives a replacement live debug toast its full dismissal delay", () => { + vi.useFakeTimers(); + const { result } = renderHook(() => useAppNotices()); + act(() => result.current.showLiveDebug("First failure", "error")); + act(() => vi.advanceTimersByTime(3_000)); + act(() => result.current.showLiveDebug("Latest failure", "error")); + act(() => vi.advanceTimersByTime(1_500)); + expect(result.current.liveDebugToast?.message).toBe("Latest failure"); + act(() => vi.advanceTimersByTime(2_999)); + expect(result.current.liveDebugToast?.message).toBe("Latest failure"); + act(() => vi.advanceTimersByTime(1)); + expect(result.current.liveDebugToast).toBeNull(); + }); + it("keeps a dismissed storage warning dismissed for the tab until the server clears it", () => { const { result } = renderHook(() => useAppNotices()); act(() => result.current.applyStorageWarning("Disk almost full")); From 7448752c102418b907313942cf92c2c67585e5b1 Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Mon, 19 Oct 2026 16:43:36 +0200 Subject: [PATCH 27/27] test(web): assert live debug timer is removed on unmount --- apps/web/tests/workspace-overlays-controllers.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/tests/workspace-overlays-controllers.test.tsx b/apps/web/tests/workspace-overlays-controllers.test.tsx index 5085214b0..11e35a456 100644 --- a/apps/web/tests/workspace-overlays-controllers.test.tsx +++ b/apps/web/tests/workspace-overlays-controllers.test.tsx @@ -147,7 +147,9 @@ describe("useAppNotices", () => { act(() => { window.dispatchEvent(new CustomEvent("space-live-debug", { detail: { message: "Live failed", type: "error" } })); }); + expect(vi.getTimerCount()).toBe(1); unmount(); + expect(vi.getTimerCount()).toBe(0); // Vitest removes the browser globals after a file finishes. No notice callback may outlive its hook. const browserWindow = Object.getOwnPropertyDescriptor(globalThis, "window")!;