diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml new file mode 100644 index 000000000..197f20525 --- /dev/null +++ b/.github/workflows/dev-stack.yml @@ -0,0 +1,173 @@ +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" + - "apps/web/**" + - ".nvmrc" + - "packages/**" + - "apps/api/**" + - "apps/worker/**" + - "package*.json" + - "Dockerfile" + - ".github/workflows/dev-stack.yml" + push: + branches: + - "codex/spaceapp65-*" + workflow_dispatch: + inputs: + expected_source: + description: "Optional exact 40-character 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 }} + 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: | + 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'; + 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); + if (process.env.EXPECTED_SOURCE) { + 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 + + - 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: Prepare hosted Linux test prerequisites + if: runner.os == 'Linux' + run: sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep ffmpeg + + - 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 + test -x /usr/bin/ffmpeg + fi + + - 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 + + - name: Full stock check + run: npm run check + + - 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 + 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' + run: npm test --workspace @space/cli-host + + - 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/CHANGELOG.md b/CHANGELOG.md index b8cb4bef9..0533004bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ Semantic Versioning. ## [Unreleased] +### 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 + in new development instances. + +### 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-18 Source: sanitized Space app @ f8803d5d96 (4595 files). Adds install telemetry diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03bcdad05..63a317f50 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,51 @@ 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. +## 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 ```sh diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ee14eb208..dd247ae1e 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -99,6 +99,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"; @@ -1442,7 +1443,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 74c70e444..3bb82e397 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 @@ -229,7 +230,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 20218e6e9..292975841 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..670040e9b 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; @@ -200,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 = {} @@ -208,8 +214,18 @@ 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."); + } + 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 new file mode 100644 index 000000000..3f4bf2ba6 --- /dev/null +++ b/apps/api/src/instance-storage.ts @@ -0,0 +1,44 @@ +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)) { + throw new Error("Dev service storage requires a private absolute SPACE_INSTALL_ROOT."); + } + 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. */ +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 2ec2606dd..aeb6ad518 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 { type ControlActor, 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"; import type { ControlCaptureParams } from "./space-control-capture.js"; /** `dotId` attributes operations and schedules to the Space Dot acting for this user. */ @@ -1642,6 +1644,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) { @@ -1867,7 +1870,9 @@ 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); + const directory = instanceStorageDirectory("live-voice-logs"); + await fsPromises.mkdir(directory, { recursive: true, mode: 0o700 }); + appendFileSync(join(directory, "debug-incidents.jsonl"), line); } catch {} } @@ -1975,7 +1980,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/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/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/dev-docx-isolation.test.ts b/apps/api/tests/dev-docx-isolation.test.ts new file mode 100644 index 000000000..52b457ece --- /dev/null +++ b/apps/api/tests/dev-docx-isolation.test.ts @@ -0,0 +1,81 @@ +import { join, relative, isAbsolute } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +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 +// 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.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); + 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..bf0d98f0d --- /dev/null +++ b/apps/api/tests/instance-storage.test.ts @@ -0,0 +1,107 @@ +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 { instanceSecretDirectories, 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("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"); + 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/apps/api/tests/room-pane-worktrees.test.ts b/apps/api/tests/room-pane-worktrees.test.ts index a06d1e9bd..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, 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"; +import * as gitTransport from "../src/group-writable-git.js"; const roots: string[] = []; afterEach(async () => { @@ -101,7 +105,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); @@ -125,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" }); +}); 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); 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/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..11e35a456 100644 --- a/apps/web/tests/workspace-overlays-controllers.test.tsx +++ b/apps/web/tests/workspace-overlays-controllers.test.tsx @@ -141,6 +141,40 @@ 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" } })); + }); + 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")!; + 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")); 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..a625e2135 --- /dev/null +++ b/docs/development-stack.md @@ -0,0 +1,237 @@ +# 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 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 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; +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. +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, 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. +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 + +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: + +```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. 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 +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 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 +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. +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 + +`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 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` +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 +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. +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-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. +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. +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. +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; +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 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 +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. +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. 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 +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 +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 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 4580c1e0b..dbb0fd26d 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 b488fbbcb..7fd92d9c9 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", @@ -68,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/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.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-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 new file mode 100644 index 000000000..293207e8b --- /dev/null +++ b/scripts/dev-stack/acceptance.mjs @@ -0,0 +1,175 @@ +#!/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 WebSocket from 'ws'; +import { locations, loadOwner, readJson } from './state.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; +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, 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) }); + 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 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); + 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); + 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 } }); + 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 () => { + interactive.assertHealthy(); + 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 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 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 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 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 }); + 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 }); + 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]) { + 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'); + 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 { + 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 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, 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); } + 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, 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); } + 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. + 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 { + 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 new file mode 100644 index 000000000..54327c1fc --- /dev/null +++ b/scripts/dev-stack/browser-proof.mjs @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +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 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.'); + 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.'); + // 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})` : ''}.`); +} +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 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 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, ciMockKeychainConfigured }, phase); + 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 () => { 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) { 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); 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, diagnostic, version: { product: version.product, protocolVersion: version.protocolVersion, binaryVersion, ciMockKeychainConfigured } }; + } catch (error) { + error.browserStartup = diagnostic('startup'); + 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, 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); + 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.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 }) { + const { call, evaluate, close, diagnostic } = 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' }); + 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); })()`); + 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; 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) => { + 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 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 }); + } + }; + 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/capability-proof.mjs b/scripts/dev-stack/capability-proof.mjs new file mode 100644 index 000000000..62842e4e3 --- /dev/null +++ b/scripts/dev-stack/capability-proof.mjs @@ -0,0 +1,37 @@ +// 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 } 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 { verifyCliHostPtyCapability, 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); +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 }); +// 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')}` }); + 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) }); + await verifyOpenCode(config.state, pins.opencode, { inherited: env, observe: async value => Object.assign(proof, value) }); + 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/dev-stack/lifecycle-proof.mjs b/scripts/dev-stack/lifecycle-proof.mjs new file mode 100644 index 000000000..9402a94ba --- /dev/null +++ b/scripts/dev-stack/lifecycle-proof.mjs @@ -0,0 +1,164 @@ +#!/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 exceeded 15 minutes; no acceptance is claimed.'); +} +async function processSnapshot(config) { + const record = await readJson(config.supervisor), result = []; + 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.'); } +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 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 }); + 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 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); + 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-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 new file mode 100644 index 000000000..adcc9225f --- /dev/null +++ b/scripts/dev-stack/opencode-wrapper.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +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 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 (['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 { + 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 + }); + 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..0fa649ee2 --- /dev/null +++ b/scripts/dev-stack/processes.mjs @@ -0,0 +1,75 @@ +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)); +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 (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 read(`/proc/${pid}/stat`, 'utf8'); + const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' '); + 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 (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) { + 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(); + 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/pty-capability.mjs b/scripts/dev-stack/pty-capability.mjs new file mode 100644 index 000000000..26279c223 --- /dev/null +++ b/scripts/dev-stack/pty-capability.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { constants } from 'node:fs'; +import { access, lstat, mkdir, open, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } 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 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.'); + } + 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 }); } +} + +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 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, ok: true, exitCode: 0, exactOutput: true, productionEntry: cliHost, modelInference: false })); + } catch (error) { + console.log(JSON.stringify({ capability, ok: false, error: { name: error.name, message: error.message.slice(0, 500) } })); process.exitCode = 1; + } +} 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/reload-debounce.mjs b/scripts/dev-stack/reload-debounce.mjs new file mode 100644 index 000000000..b661760b2 --- /dev/null +++ b/scripts/dev-stack/reload-debounce.mjs @@ -0,0 +1,56 @@ +/** + * 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, 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, requested = generation; pending = null; + observed(); + void rebuild(next, requested); + }, 400); + observed(); + }, + 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/resources.mjs b/scripts/dev-stack/resources.mjs new file mode 100644 index 000000000..9e7559cbd --- /dev/null +++ b/scripts/dev-stack/resources.mjs @@ -0,0 +1,139 @@ +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'; + +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 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 }); + await assertOwnedDocker(owner, control, env); + const redactor = lineRedactor(secrets, output); + try { + 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(); } +} +/** + * 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'); } + } + 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 || !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/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/state.mjs b/scripts/dev-stack/state.mjs new file mode 100644 index 000000000..18234bc89 --- /dev/null +++ b/scripts/dev-stack/state.mjs @@ -0,0 +1,109 @@ +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, { fresh = false } = {}) { + await assertPlainState(config); + const existing = await lstat(config.state).catch(e => { if (e.code === 'ENOENT') return null; throw e; }); + 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 }; + 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 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 } } + }; +} +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_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'), + 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..0a38a5b02 --- /dev/null +++ b/scripts/dev-stack/supervisor.mjs @@ -0,0 +1,204 @@ +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'; +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, 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`); +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; }); + 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.'); +} +/** 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; } + 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.'); +} +/** 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 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); + 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 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 = []; + const launches = new LaunchBarrier(); + 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(), receiptClosed = false, proofChannel; + const perform = fn => { activeCommand = fn(); return activeCommand; }; + 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()]) => { + 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; + 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 }); 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; + } + 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, generation) => { + if (stopping) 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); + 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; + 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; 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 }); + 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}`]); + 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')); + 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.`); + 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); })); + } + readiness.start(); + readyTimer = setInterval(async () => { + 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; + 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); } +} +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/dev-stack/terminal-proof.mjs b/scripts/dev-stack/terminal-proof.mjs new file mode 100644 index 000000000..98c027cf3 --- /dev/null +++ b/scripts/dev-stack/terminal-proof.mjs @@ -0,0 +1,158 @@ +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; +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; 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)}).`); } + 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 (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 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.'); + } + } + 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'; + } + 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, phase = 'screen-projection') { + if (!this.live(response)) return null; + const geometry = this.observeGeometry(response, phase); + if (!this.completeReplay) this.fail('A complete terminal replay has not been established.'); + 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.'); + 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; + 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, `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; + } + 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, 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(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, initialGeometry: selectedGeometry(this.initialGeometry), browserGeometry: this.browserGeometry, geometryObservation: this.geometryObservation, 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 29b5b5572..1b3acf8be 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,25 @@ 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/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", + "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", // 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..dbcb8c20d --- /dev/null +++ b/scripts/tests/dev-stack.test.mjs @@ -0,0 +1,1234 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +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'; +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'; +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', () => { + 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) => { + 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'); + 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()); + 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/); +}); + +// 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')); +}); +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); + 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', 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 ''; } + 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 = []; + await privateWrite(config.compose, JSON.stringify(composeDocument(owner, pins, 'synthetic-password'))); + let down = false, output = ''; + 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'); + }; + 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('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(); + 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, []); +}); +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'; + 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/); +}); + +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); +}); +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.'); +}); +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://')); +}); + +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); +}); + +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/); +}); + +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')); +}); + +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); +}); + +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.'); +}); 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) { 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";