From 4d71c5c77dfb53a919707292c6bc7ae7b8e11edf Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Wed, 21 Oct 2026 22:50:23 -0700 Subject: [PATCH 01/29] docs: configure isolated source development stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- .dockerignore | 1 + .gitignore | 1 + CONTRIBUTING.md | 72 +++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 ++ 4 files changed, 76 insertions(+) diff --git a/.dockerignore b/.dockerignore index 11092fa87..afca73ff6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ .git +.dev-stack .github .env .env.* diff --git a/.gitignore b/.gitignore index 81e68b26a..ba0b11dac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Generated outputs and local tool reports. These are safe to regenerate; do # not commit them, and clean them only with an allowlist. node_modules/ +.dev-stack/ dist/ dist-mock/ dist-types/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 078772388..2cd36bfe8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,78 @@ 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 Node from `.nvmrc`, npm, a C++ build toolchain/Python for `node-pty`, and a +running Docker daemon with Compose v2 supporting `up --wait`. On macOS use +Docker Desktop or Colima (`colima start`); on Windows 11 run everything inside +WSL2 with Docker Desktop's WSL integration enabled. Native Windows is refused. + +```sh +ELECTRON_SKIP_BINARY_DOWNLOAD=1 npm ci +npm run dev:stack +# In another terminal: +npm run dev:stack -- down +npm run dev:stack -- reset +``` + +The foreground launcher builds the internal packages and starts PostgreSQL +(pgvector) and Temporal in Docker, then runs migrations and the CLI host, +Temporal worker, API watcher and Vite **from this checkout**. It installs the +exact OpenCode package pinned in `Dockerfile` under `.dev-stack/opencode`, +never globally. First start needs network access for images and that package. + +Open the printed URL (`http://127.0.0.1:5911` by default) and use the printed +one-time token to create your owner account. Tokens expire after 15 minutes; +restart the stack if an unclaimed token has expired. Sign in normally and open +an OpenCode terminal in Getting Started; reaching its TUI needs no provider +account. Sending AI requests still requires a working model/provider. +Browser and optional companion services are disabled in this dev stack. + +Each checkout has its own Compose project, volume, Unix sockets and ignored +`.dev-stack/` state. Secret files are mode 0600. No installed instance's +credentials, environment configuration or provider homes are reused. All +published ports bind to `127.0.0.1`: + +| Service | Default | Override | +| --- | --- | --- | +| API | 5910 | `SPACE_DEV_API_PORT` | +| Vite | 5911 | `SPACE_DEV_WEB_PORT` | +| PostgreSQL | 55432 | `SPACE_DEV_POSTGRES_PORT` | +| Temporal | 57233 | `SPACE_DEV_TEMPORAL_PORT` | + +Use distinct free ports and keep the same overrides for `up`, `down` and +`reset`. Ports 4910/4911 are refused to avoid collisions with installed SpaceApp. +Vite fails instead of silently choosing another port. Do not share these +development endpoints through a public proxy. + +Edits in `apps/web/src` hot-reload; edits in `apps/api/src` restart the API. +After changes in `packages/*` or the worker, use **one command** in another +terminal: `npm run dev:stack -- rebuild`. It stops the old stack, rebuilds all +internal packages and starts a new foreground stack with the existing data. +Ctrl+C and `down` stop the source process groups and Docker services; `reset` +also removes only this checkout's volume and marked state directory. Unexpected +service exits fail the supervisor and stop the other services. + +Troubleshooting: + +- `docker info` / `docker compose version` must work before startup. No daemon + is installed or started for you. On macOS check Colima/Docker Desktop; in WSL2 + check Docker integration and install/build on the Linux filesystem. +- A port conflict: change the corresponding override; do not stop an installed + SpaceApp to free 4910/4911. A second stack in the same checkout is refused. +- A readiness timeout or failed worker: inspect the named service output; + authentication shortcuts and disabled workflows are not readiness fixes. +- After an uncatchable supervisor kill, a lock may remain. Inspect its PID and + confirm no source processes from this checkout are alive before removing + `.dev-stack/lock`; rerun `reset` to remove its Docker resources. Do not remove + a lock belonging to a running stack. +- To validate real acceptance in a **fresh checkout without dev state**, run + `npm run smoke:dev-stack`. It creates an owner, signs in over HTTP, opens and + attaches a real OpenCode PTY, and asserts that reset leaves no processes, + containers or volumes. The test creates and destroys only its own data. + A passing unit test is not evidence of a passing Docker or macOS/WSL2 run. + ## First executed task, without a provider ```sh diff --git a/package.json b/package.json index 207743196..c3f244ec7 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,8 @@ "@napi-rs/canvas": "1.0.9" }, "scripts": { + "dev:stack": "node scripts/dev-stack.mjs", + "smoke:dev-stack": "node scripts/dev-stack-smoke.mjs", "dev:api": "npm run dev -w @space/api", "dev:web": "npm run dev -w @space/web", "dev:worker": "npm run dev -w @space/worker", From 2c3ff8e306a21a207a04fa13879da6cfb6ef2407 Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Wed, 21 Oct 2026 23:18:12 -0700 Subject: [PATCH 02/29] feat: add source stack supervisor and Docker acceptance runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- scripts/dev-stack-smoke.mjs | 139 ++++++++++++++++++++++++ scripts/dev-stack.mjs | 210 ++++++++++++++++++++++++++++++++++++ scripts/public-export.mjs | 6 ++ 3 files changed, 355 insertions(+) create mode 100644 scripts/dev-stack-smoke.mjs create mode 100644 scripts/dev-stack.mjs diff --git a/scripts/dev-stack-smoke.mjs b/scripts/dev-stack-smoke.mjs new file mode 100644 index 000000000..be4d0a530 --- /dev/null +++ b/scripts/dev-stack-smoke.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +// Real Docker/Postgres/Temporal/API/PTY acceptance. Never falls back to a mock. +import assert from "node:assert/strict"; +import { spawn, execFileSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { readFile, realpath } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; +import { once } from "node:events"; +import WebSocket from "ws"; +import { configuration } from "./dev-stack/config.mjs"; +import { redact } from "./dev-stack/processes.mjs"; + +const root = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..")); +const config = configuration(root); +const npmCli = process.env.npm_execpath; +if (!npmCli) throw new Error("Run npm run smoke:dev-stack."); +try { + await readFile(join(config.state, "owner.json")); + throw new Error("smoke:dev-stack requires a fresh checkout with no .dev-stack state; it creates and destroys its own data."); +} catch (error) { if (error.code !== "ENOENT") throw error; } + +const api = `http://127.0.0.1:${config.ports.api}`; +let cookie = ""; +let csrf = {}; +let socket; +let output = ""; +let childExit; +let stopping = false; +const child = spawn(process.execPath, [npmCli, "run", "dev:stack"], { cwd: root, stdio: ["ignore", "pipe", "pipe"] }); +const ended = new Promise(resolve => child.on("exit", (code) => { childExit = code; resolve(); })); +child.stdout.on("data", data => { output = (output + data).slice(-2_000_000); }); +child.stderr.on("data", data => { output = (output + data).slice(-2_000_000); }); +child.on("error", error => { output += error.message; childExit = 1; }); + +async function until(check, description, timeout = 90_000) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (childExit !== undefined && !stopping) throw new Error(`Stack exited (${childExit}) while waiting for ${description}.`); + if (await check()) return; + await delay(500); + } + throw new Error(`Timed out waiting for ${description}.`); +} + +async function request(path, body, anonymous = false) { + const response = await fetch(`${api}${path}`, { + method: body === undefined ? "GET" : "POST", + headers: { + "content-type": "application/json", origin: api, + ...(!anonymous && cookie ? { cookie, ...csrf } : {}) + }, + body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30_000) + }); + if (!response.ok) throw new Error(`${path} returned ${response.status}: ${(await response.text()).slice(0, 500)}`); + const setCookie = response.headers.getSetCookie(); + if (setCookie.length) cookie = setCookie.map(c => c.split(";")[0]).filter(c => !c.endsWith("=")).join("; "); + return response.json(); +} + +function docker(args) { + return execFileSync("docker", args, { cwd: root, encoding: "utf8", timeout: 60_000 }).trim(); +} + +let secrets = []; +try { + await until(async () => { + try { const r = await fetch(`http://127.0.0.1:${config.ports.web}/readyz`, { signal: AbortSignal.timeout(2000) }); return r.ok && (await r.json()).ok === true; } + catch { return false; } + }, "real web/API/database/worker readiness", 15 * 60_000); + const readiness = await request("/readyz", undefined, true); + assert.equal(readiness.dependencies.database, "RUNNING"); + assert.equal(readiness.dependencies.worker, "RUNNING"); + assert.equal(readiness.dependencies.temporal, "enabled"); + const setupToken = await readFile(join(config.state, "secrets", "setup-token"), "utf8"); + secrets = await Promise.all(["setup-token", "session-secret", "postgres-password", "control-token"].map(n => readFile(join(config.state, "secrets", n), "utf8"))); + assert.equal((await request("/api/setup/status", undefined, true)).setupRequired, true); + const credentials = { email: "dev-stack@example.invalid", password: randomBytes(24).toString("hex") }; + secrets.push(credentials.password); + const owner = await request("/api/setup/claim", { token: setupToken, ...credentials }, true); + assert.equal(owner.isAuthenticated, true); + assert.notEqual(owner.user.id, "user:dev-operator"); + assert.equal((await request("/api/auth/login", credentials, true)).isAuthenticated, true); + const csrfInfo = await request("/api/auth/csrf"); + csrf = { [csrfInfo.headerName]: csrfInfo.csrfToken }; + const starter = await request("/api/setup/starter-room", {}); + assert.equal(starter.room.name, "Getting Started"); + const pane = await request("/api/panes", { roomId: starter.room.id, title: "OpenCode acceptance", mode: "TERMINAL", terminalRuntimeId: "cli:opencode" }); + const session = await request(`/api/panes/${encodeURIComponent(pane.id)}/cli/session`, { runtimeId: "cli:opencode" }); + assert.ok(session.websocket?.token); + const query = new URLSearchParams({ sessionId: session.session.sessionId, token: session.websocket.token, clientId: "dev-stack-smoke", initialCols: "100", initialRows: "30" }); + socket = new WebSocket(`${api.replace("http:", "ws:")}/api/panes/${encodeURIComponent(pane.id)}/cli/terminal?${query}`, { headers: { cookie, origin: api } }); + let terminal = ""; + let terminalError; + socket.on("message", data => { + try { + const message = JSON.parse(data.toString()); + if (message.type === "output") terminal = (terminal + message.data).slice(-1_000_000); + if (message.type === "error") terminalError = new Error(message.message); + } catch (error) { terminalError = error; } + }); + socket.on("error", error => { terminalError = error; }); + await once(socket, "open", { signal: AbortSignal.timeout(30_000) }); + await until(async () => { + if (terminalError) throw terminalError; + const current = await request(`/api/panes/${encodeURIComponent(pane.id)}/cli/session`); + if (["EXITED", "ERROR"].includes(current?.session?.status)) throw new Error("OpenCode terminated before TUI acceptance."); + return current?.session?.status === "RUNNING" && /opencode/i.test(terminal); + }, "a RUNNING OpenCode PTY with TUI output"); + console.log("PASS: normal owner claim/login, Getting Started room, real OpenCode terminal."); + const pids = JSON.parse(await readFile(join(config.state, "processes.json"), "utf8")); + socket.close(); socket = undefined; + stopping = true; + // reset must stop a running supervisor, not merely delete its data. + execFileSync(process.execPath, [npmCli, "run", "dev:stack", "--", "reset"], { cwd: root, stdio: "pipe", timeout: 90_000 }); + await until(async () => childExit !== undefined, "supervisor exit after reset", 10_000); + assert.equal(childExit, 0); + for (const { name, pid } of pids) { + assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }, `${name} leaked its process`); + assert.throws(() => process.kill(-pid, 0), { code: "ESRCH" }, `${name} leaked a child process group`); + } + const filter = `label=com.docker.compose.project=${config.project}`; + assert.equal(docker(["ps", "--all", "--quiet", "--filter", filter]), ""); + assert.equal(docker(["volume", "ls", "--quiet", "--filter", filter]), ""); + console.log("PASS: reset removed every dev-stack container, volume and source process."); +} catch (error) { + // Setup tokens are deliberately printed by the interactive launcher. Never echo them in CI. + console.error(redact(output, secrets).replace(/One-time owner setup token: \S+/g, "One-time owner setup token: [REDACTED]")); + throw error; +} finally { + socket?.terminate(); + if (childExit === undefined) { + stopping = true; + try { execFileSync(process.execPath, [npmCli, "run", "dev:stack", "--", "reset"], { cwd: root, stdio: "pipe", timeout: 90_000 }); } + catch { child.kill("SIGTERM"); } + await Promise.race([ended, delay(10_000)]); + } +} diff --git a/scripts/dev-stack.mjs b/scripts/dev-stack.mjs new file mode 100644 index 000000000..b839ad680 --- /dev/null +++ b/scripts/dev-stack.mjs @@ -0,0 +1,210 @@ +#!/usr/bin/env node +import { createServer, createConnection } from "node:net"; +import { chmod, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { once } from "node:events"; +import { assertOwnedState, composeArgs, composeDocument, configuration, openCodePackage, prepareState, sourceEnvironment } from "./dev-stack/config.mjs"; +import { Processes, redact, waitForReady } from "./dev-stack/processes.mjs"; + +const root = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..")); +const config = configuration(root); +let command = process.argv[2] ?? "up"; +if (!["up", "down", "reset", "rebuild"].includes(command) || process.argv.length > 3) { + throw new Error("Usage: npm run dev:stack -- [up|down|reset|rebuild]"); +} +if (process.platform === "win32") throw new Error("Use WSL2; the source stack requires Unix sockets and process groups."); +if (Number(process.versions.node.split(".")[0]) !== 22 || Number(process.versions.node.split(".")[1]) < 12) { + throw new Error("Use the Node 22 version from .nvmrc (>=22.12 <23)."); +} + +const packages = ["contracts", "icons", "streaming", "opencode-control", "memory-graph", "browser-host", "cli-host", "codex-app-server", "runtime", "db", "gateway"]; +const npmCli = process.env.npm_execpath; +if (!npmCli) throw new Error("Start with npm run dev:stack, so the same npm installation is used for builds."); +const npmRun = (processes, name, args, options) => processes.run(name, process.execPath, [npmCli, ...args], options); + +async function build(processes) { + for (const name of packages) await npmRun(processes, `build:${name}`, ["run", "build", "-w", `@space/${name}`], { cwd: root }); + // The worker's Temporal bundle requires emitted .js files, even in dev. + await npmRun(processes, "build:worker", ["run", "build", "-w", "@space/worker"], { cwd: root }); + await npmRun(processes, "build:api", ["run", "build", "-w", "@space/api"], { cwd: root }); +} + +async function requestDown() { + await assertOwnedState(config); + const token = await readFile(join(config.state, "secrets", "control-token"), "utf8"); + return new Promise((resolve, reject) => { + const socket = createConnection(config.control); + let response = ""; + socket.setTimeout(60_000, () => socket.destroy(new Error("Dev-stack shutdown timed out."))); + socket.on("connect", () => socket.end(`${token}\n`)); + socket.on("data", (data) => { response += data; }); + socket.on("end", () => response.trim() === "stopped" ? resolve(true) : reject(new Error("Dev-stack shutdown was not acknowledged."))); + socket.on("error", (error) => { + if (["ENOENT", "ECONNREFUSED"].includes(error.code)) resolve(false); + else reject(error); + }); + }); +} + +async function removeStaleControlSocket() { + // Called only while holding this checkout's lock. Never unlink a live server. + const stale = await new Promise((resolve, reject) => { + const socket = createConnection(config.control); + socket.setTimeout(2000, () => socket.destroy(new Error("Cannot determine whether the control socket is in use."))); + socket.on("connect", () => { socket.destroy(); reject(new Error("A dev-stack control server is already running.")); }); + socket.on("error", error => { + if (error.code === "ENOENT") resolve(false); + else if (error.code === "ECONNREFUSED") resolve(true); + else reject(error); + }); + }); + if (stale) await rm(config.control, { force: true }); +} + +async function prepareOpenCode(processes) { + const spec = openCodePackage(await readFile(join(root, "Dockerfile"), "utf8")); + const vendor = join(config.state, "opencode"); + const installed = await readFile(join(vendor, "installed-spec"), "utf8").catch(() => ""); + if (installed !== spec) { + await npmRun(processes, "install:opencode", ["install", "--prefix", vendor, "--no-audit", "--no-fund", "--save-exact", spec], { cwd: root }); + await writeFile(join(vendor, "installed-spec"), spec); + } + const wrapper = `#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +const home = ${JSON.stringify(join(config.state, "providers", "opencode"))}; +const binary = ${JSON.stringify(join(vendor, "node_modules", ".bin", "opencode"))}; +const args = process.argv.slice(2); +if (args[0] === '--run-inner') args.shift(); +if (args[0] === 'credential-status') { console.log('READY'); process.exit(0); } +if (args[0] === 'credential-observation') { console.log('OBSERVATION:dev-stack-opencode'); process.exit(0); } +if (args[0] === 'credential-smoke') { console.error('Provider requests are not part of account-free TUI acceptance.'); process.exit(1); } +const env = { ...process.env, HOME: home, TMPDIR: join(home,'tmp'), OPENCODE_DISABLE_AUTOUPDATE: 'true' }; +env.TMP = env.TEMP = env.TMPDIR; +delete env.DBUS_SESSION_BUS_ADDRESS; +delete env.XDG_RUNTIME_DIR; +for (const [key, dir] of Object.entries({ XDG_CONFIG_HOME:'config', XDG_DATA_HOME:'data', XDG_CACHE_HOME:'cache', XDG_STATE_HOME:'state' })) { + env[key] = join(home,dir); mkdirSync(env[key], { recursive:true, mode:0o700 }); +} +mkdirSync(env.TMPDIR, { recursive:true, mode:0o700 }); +if (!args.some(arg => ['--help','-h','--version','-v'].includes(arg))) args.push('--standalone'); +const child = spawn(binary,args,{ env, stdio:'inherit' }); +child.on('error', () => process.exit(1)); +child.on('exit', (code) => process.exit(code ?? 1)); +`; + await writeFile(join(config.state, "bin", "opencode-vscode-parity"), wrapper, { mode: 0o700 }); +} + +async function main() { + if (command === "rebuild") { + await requestDown(); + command = "up"; // One command rebuilds and restarts every source service. + } + if (command === "down" || command === "reset") { + try { await assertOwnedState(config); } catch (error) { if (error.code === "ENOENT") { console.log("No dev stack state."); return; } throw error; } + await requestDown(); + // Never touch a live or stale process lock without a successful shutdown. + try { await readFile(join(config.state, "lock", "pid")); throw new Error("A startup or stale lock remains; inspect it before cleanup."); } + catch (error) { if (error.code !== "ENOENT") throw error; } + const processes = new Processes(); + try { + await processes.run("docker:down", "docker", composeArgs(config, "down", "--remove-orphans", ...(command === "reset" ? ["--volumes"] : [])), { cwd: config.state }); + await removeStaleControlSocket(); + await rm(config.hostSocket, { force: true }); + if (command === "reset") { await assertOwnedState(config); await rm(config.state, { recursive: true }); } + } finally { await processes.stop(); } + return; + } + + // Fail before writing state when Docker is absent or its daemon is unreachable. + const preflight = new Processes(); + try { + await preflight.run("docker:preflight", "docker", ["compose", "version"], { cwd: root }); + await preflight.run("docker:daemon", "docker", ["info", "--format", "{{.ServerVersion}}"], { cwd: root }); + } finally { await preflight.stop(); } + const secrets = await prepareState(config); + const lock = join(config.state, "lock"); + await mkdir(lock); // An atomic lock prevents two invocations sharing the same state/ports. + await writeFile(join(lock, "pid"), String(process.pid), { mode: 0o600 }); + const processes = new Processes({ secrets: Object.values(secrets) }); + const controller = new AbortController(); + const signal = AbortSignal.any([controller.signal, processes.abort.signal]); + const clients = new Set(); + const control = createServer({ allowHalfOpen: true }, (socket) => { + clients.add(socket); + socket.setTimeout(3000, () => socket.destroy()); + let input = ""; + socket.on("data", (data) => { + input += data; + if (input.length > 128) socket.destroy(); + if (input.includes("\n")) { + if (input.trim() !== secrets["control-token"]) { socket.end("denied\n"); return; } + socket.setTimeout(0); + controller.abort(new Error("Requested shutdown.")); + } + }); + socket.on("error", () => {}); + socket.on("close", () => clients.delete(socket)); + }); + let composeStarted = false; + let hostStarted = false; + let controlListening = false; + let stopPromise; + signal.addEventListener("abort", () => { stopPromise ??= processes.stop(); }, { once: true }); + const stop = () => controller.abort(new Error("Requested shutdown.")); + process.once("SIGINT", stop); process.once("SIGTERM", stop); + let cleanupError; + try { + await removeStaleControlSocket(); + control.listen(config.control); + await once(control, "listening"); + controlListening = true; + await chmod(config.control, 0o600); + const template = await readFile(join(root, "packages/run-spaceapp/templates/compose.yml"), "utf8"); + await writeFile(join(config.state, "compose.json"), JSON.stringify(composeDocument(config, template), null, 2), { mode: 0o600 }); + await build(processes); + await prepareOpenCode(processes); + signal.throwIfAborted(); + composeStarted = true; + await processes.run("docker:up", "docker", composeArgs(config, "up", "--detach", "--wait", "--wait-timeout", "240"), { cwd: config.state }); + await processes.run("temporal:namespace", "docker", composeArgs(config, "exec", "-T", "temporal", "sh", "-ec", + "temporal operator namespace describe --namespace spaceapp-dev --address 127.0.0.1:7233 >/dev/null 2>&1 || temporal operator namespace create --namespace spaceapp-dev --address 127.0.0.1:7233"), { cwd: config.state }); + const env = sourceEnvironment(config, secrets); + await mkdir(env.TMPDIR, { recursive: true, mode: 0o700 }); + await processes.run("migrate", process.execPath, ["packages/db/dist/migrate.js"], { cwd: root, env }); + signal.throwIfAborted(); + processes.start("cli-host", process.execPath, ["packages/cli-host/dist/main.js"], { cwd: root, env }, true); + hostStarted = true; + processes.start("worker", process.execPath, ["apps/worker/dist/worker.js"], { cwd: root, env }, true); + processes.start("api", process.execPath, ["node_modules/tsx/dist/cli.mjs", "watch", "apps/api/src/server.ts"], { cwd: root, env }, true); + processes.start("web", process.execPath, ["node_modules/vite/bin/vite.js", "--strictPort"], { cwd: join(root, "apps/web"), env }, true); + await writeFile(join(config.state, "processes.json"), JSON.stringify([...processes.children].filter(r => !r.exited).map(r => ({ name: r.name, pid: r.child.pid }))), { mode: 0o600 }); + await waitForReady(`http://127.0.0.1:${config.ports.web}/readyz`, signal); + console.log(`\nSpaceApp: http://127.0.0.1:${config.ports.web}\nOne-time owner setup token: ${secrets["setup-token"]}\nOptional browser/companion services are disabled. Ctrl+C or npm run dev:stack -- down stops this stack.`); + await new Promise((resolve) => { if (signal.aborted) resolve(); else signal.addEventListener("abort", resolve, { once: true }); }); + if (processes.abort.signal.aborted) throw processes.abort.signal.reason; + } finally { + await stopPromise; + await processes.stop(); + if (composeStarted) { + const cleanup = new Processes({ secrets: Object.values(secrets) }); + try { await cleanup.run("docker:down", "docker", composeArgs(config, "down", "--remove-orphans"), { cwd: config.state }); } + catch (error) { cleanupError = error; } + finally { await cleanup.stop(); } + } + if (controlListening) { control.close(); await rm(config.control, { force: true }); } + if (hostStarted) await rm(config.hostSocket, { force: true }); + await rm(lock, { recursive: true, force: true }); + await rm(join(config.state, "processes.json"), { force: true }); + for (const socket of clients) socket.end(cleanupError ? "cleanup failed\n" : "stopped\n"); + process.removeListener("SIGINT", stop); process.removeListener("SIGTERM", stop); + } + if (cleanupError) throw cleanupError; +} + +main().catch((error) => { + console.error(`dev:stack failed: ${redact(error.message, [])}`); + process.exitCode = 1; +}); diff --git a/scripts/public-export.mjs b/scripts/public-export.mjs index c7dbddb50..43a44420d 100644 --- a/scripts/public-export.mjs +++ b/scripts/public-export.mjs @@ -78,6 +78,12 @@ const publicDocs = new Set([ export const publicRepositoryMaintainedFiles = new Set(["docs/security-model.md"]); const publicScripts = new Set([ + "scripts/dev-stack.mjs", + "scripts/dev-stack-smoke.mjs", + "scripts/dev-stack/config.mjs", + "scripts/dev-stack/processes.mjs", + "scripts/tests/dev-stack.test.mjs", + "scripts/tests/dev-stack-vite.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. From c0e75544f4c2cd2d13ad70887f5f23b8c0cfe04e Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Wed, 21 Oct 2026 23:19:46 -0700 Subject: [PATCH 03/29] feat: isolate dev state and supervise source processes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- scripts/dev-stack/config.mjs | 164 ++++++++++++++++++++++++++++++++ scripts/dev-stack/processes.mjs | 80 ++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 scripts/dev-stack/config.mjs create mode 100644 scripts/dev-stack/processes.mjs diff --git a/scripts/dev-stack/config.mjs b/scripts/dev-stack/config.mjs new file mode 100644 index 000000000..00432e1da --- /dev/null +++ b/scripts/dev-stack/config.mjs @@ -0,0 +1,164 @@ +import { createHash, randomBytes } from "node:crypto"; +import { chmod, lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; + +export function port(value, fallback) { + const result = value === undefined ? fallback : Number(value); + if (!Number.isInteger(result) || result < 1024 || result > 65535 || [4910, 4911].includes(result)) { + throw new Error("Dev-stack ports must be integers in 1024..65535, excluding installed ports 4910/4911."); + } + return result; +} + +export function configuration(root, env = process.env) { + const ports = { + api: port(env.SPACE_DEV_API_PORT, 5910), web: port(env.SPACE_DEV_WEB_PORT, 5911), + postgres: port(env.SPACE_DEV_POSTGRES_PORT, 55432), temporal: port(env.SPACE_DEV_TEMPORAL_PORT, 57233) + }; + if (new Set(Object.values(ports)).size !== 4) throw new Error("Dev-stack ports must be distinct."); + const id = createHash("sha256").update(root).digest("hex").slice(0, 16); + return { + root, state: join(root, ".dev-stack"), project: `spaceapp-dev-${id}`, ports, + control: join(tmpdir(), `spaceapp-dev-${id}.sock`), + hostSocket: join(tmpdir(), `spaceapp-cli-dev-${id}.sock`) + }; +} + +export async function assertOwnedState(config) { + const info = await lstat(config.state); + if (!info.isDirectory() || info.isSymbolicLink() || await realpath(config.state) !== config.state) { + throw new Error("Refusing non-directory or symlink dev-stack state."); + } + const marker = JSON.parse(await readFile(join(config.state, "owner.json"), "utf8")); + if (marker.root !== config.root || marker.project !== config.project || marker.version !== 1) { + throw new Error("State does not belong to this checkout; refusing cleanup."); + } +} + +export async function prepareState(config) { + try { await lstat(config.state); await assertOwnedState(config); } + catch (error) { + if (error.code !== "ENOENT") throw error; + // An existing directory without a marker is never adopted. + await mkdir(config.state, { mode: 0o700 }); + await writeFile(join(config.state, "owner.json"), JSON.stringify({ + version: 1, root: config.root, project: config.project + }), { flag: "wx", mode: 0o600 }); + } + await chmod(config.state, 0o700); + for (const dir of ["secrets", "memory", "memory-graph", "artifacts", "workspaces", "providers", "bin", "logs"]) { + const path = join(config.state, dir); + await mkdir(path, { recursive: true, mode: 0o700 }); + const info = await lstat(path); + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Invalid dev-stack directory: ${dir}`); + await chmod(path, 0o700); + } + const secrets = {}; + for (const name of ["postgres-password", "session-secret", "setup-token", "control-token"]) { + const path = join(config.state, "secrets", name); + try { await writeFile(path, randomBytes(32).toString("hex"), { flag: "wx", mode: 0o600 }); } + catch (error) { if (error.code !== "EEXIST") throw error; } + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink()) throw new Error(`Invalid secret file: ${name}`); + await chmod(path, 0o600); + secrets[name] = await readFile(path, "utf8"); + if (!/^[a-f0-9]{64}$/.test(secrets[name])) throw new Error(`Invalid secret format: ${name}`); + } + for (const name of ["space_memory.md", "space_memory_monthly.md", "installation-guide.md"]) { + const destination = join(config.state, "memory", name); + try { await writeFile(destination, await readFile(join(config.root, "starter-memory", name)), { flag: "wx", mode: 0o600 }); } + catch (error) { if (error.code !== "EEXIST") throw error; } + } + return secrets; +} + +export function pinnedImage(template, service) { + const section = template.match(new RegExp(`^ ${service}:\\n([\\s\\S]*?)(?=^ [a-zA-Z]|^networks:|$(?![\\s\\S]))`, "m")); + const image = section?.[1].match(/^ {4}image: ([^\s]+)$/m)?.[1]; + if (!image || !/@sha256:[a-f0-9]{64}$/.test(image)) throw new Error(`Missing pinned ${service} image in launcher template.`); + return image; +} + +export function composeDocument(config, template) { + return { + services: { + postgres: { + image: pinnedImage(template, "postgres"), + environment: { POSTGRES_DB: "spaceapp_dev", POSTGRES_USER: "spaceapp", POSTGRES_PASSWORD_FILE: "/run/secrets/postgres-password" }, + ports: [`127.0.0.1:${config.ports.postgres}:5432`], + secrets: ["postgres-password"], volumes: ["database:/var/lib/postgresql/data"], + healthcheck: { test: ["CMD-SHELL", "pg_isready -U spaceapp -d spaceapp_dev"], interval: "2s", timeout: "5s", retries: 60 } + }, + temporal: { + image: pinnedImage(template, "temporal"), + // Compose mounts host secret files without remapping their 0600 ownership. + // The image defaults to UID 1000, which cannot read those files on Linux. + user: "0:0", + environment: { DB: "postgres12", DB_PORT: "5432", POSTGRES_USER: "spaceapp", POSTGRES_SEEDS: "postgres", DYNAMIC_CONFIG_FILE_PATH: "config/dynamicconfig/docker.yaml" }, + entrypoint: ["/bin/sh", "-ec"], + command: ['export POSTGRES_PWD="$$(cat /run/secrets/postgres-password)"; exec /etc/temporal/entrypoint.sh autosetup'], + ports: [`127.0.0.1:${config.ports.temporal}:7233`], + secrets: ["postgres-password"], depends_on: { postgres: { condition: "service_healthy" } }, + healthcheck: { + test: ["CMD-SHELL", "temporal operator cluster health --address 127.0.0.1:7233 >/dev/null && temporal operator namespace describe --namespace default --address 127.0.0.1:7233 >/dev/null"], + interval: "3s", timeout: "10s", retries: 60 + } + } + }, + secrets: { "postgres-password": { file: "./secrets/postgres-password" } }, + volumes: { database: {} } + }; +} + +export function sourceEnvironment(config, secrets, inherited = process.env) { + // Do not inherit operator SPACE_* configuration, provider credentials, NODE_OPTIONS or homes. + const env = {}; + for (const key of ["PATH", "LANG", "LC_ALL", "TERM", "TZ", "SYSTEMROOT", "COMSPEC"]) { + if (inherited[key]) env[key] = inherited[key]; + } + const path = (...parts) => join(config.state, ...parts); + return Object.assign(env, { + HOME: path("providers"), TMPDIR: path("providers", "tmp"), NODE_ENV: "development", + SPACE_INSTANCE: "dev", SPACE_INSTANCE_ENABLED_JOBS: "", SPACE_PUBLIC_DISTRIBUTION: "true", + SPACE_DEV_LOGIN: "false", SPACE_SESSION_SECRET: secrets["session-secret"], + SPACE_GOOGLE_AUTH_DISABLED: "true", SPACE_T3CODE_ENABLED: "false", + SPACE_SETUP_TOKEN_FILE: path("secrets", "setup-token"), + SPACE_RUNTIME_STORE: "postgres", SPACE_DATABASE_URL: `postgresql://spaceapp:${secrets["postgres-password"]}@127.0.0.1:${config.ports.postgres}/spaceapp_dev`, + SPACE_API_HOST: "127.0.0.1", SPACE_API_PORT: String(config.ports.api), + SPACE_API_ORIGIN: `http://127.0.0.1:${config.ports.api}`, + SPACE_BROWSER_EVIDENCE_TARGET_ORIGIN: `http://127.0.0.1:${config.ports.web}`, + SPACE_WEB_HOST: "127.0.0.1", SPACE_WEB_PORT: String(config.ports.web), + SPACE_TEMPORAL_ADDRESS: `127.0.0.1:${config.ports.temporal}`, SPACE_TEMPORAL_NAMESPACE: "spaceapp-dev", + SPACE_TEMPORAL_TASK_QUEUE: config.project, SPACEAPP_WORKFLOWS_ENABLED: "true", + SPACE_ENABLE_DUMMY_TURNS: "true", + SPACE_INSTALL_ROOT: config.root, SPACE_MEMORY_ROOT: path("memory"), + SPACE_MEMORY_INDEX_PATH: path("memory", "space_memory.md"), SPACE_MEMORY_MONTHLY_PATH: path("memory", "space_memory_monthly.md"), + SPACE_MEMORY_LOCK_PATH: path("memory", ".memory.lock"), SPACE_MEMORY_GRAPH_ROOT: path("memory-graph"), + SPACE_ARTIFACT_ROOT: path("artifacts"), SPACE_PROJECTS_ROOT: path("workspaces"), + SPACE_CLI_ENABLED: "true", SPACE_CLI_ROOT_ENABLED: "false", SPACE_CLI_COMMAND_PATH: path("bin"), + SPACE_CLI_WORKSPACE_ROOT: path("workspaces"), SPACE_CLI_HOST_SOCKET: config.hostSocket, + SPACE_CODEX_HOME: path("providers", "codex"), SPACE_CODEX_APP_SERVER_HOME: path("providers", "codex"), + SPACE_OPENCODE_STATE_ROOT: path("providers", "opencode"), + SPACE_CODEX_GOALS_DB_PATH: path("codex-goals.sqlite"), + SPACE_CODEX_CLI_MODE_DEFAULTS_PROJECTION_PATH: path("codex-mode-defaults.json"), + SPACE_TELEGRAM_SECRET_ROOT: path("providers", "telegram"), SPACE_STREAMING_SECRET_ROOT: path("providers", "streaming"), + SPACE_TELEGRAM_OWNERSHIP_MANIFEST: path("telegram-ownership.json"), + SPACE_MAINTENANCE_AUTOPILOT_STATE_FILE: path("maintenance.json"), SPACE_APP_DIAGNOSTICS_ROOT: path("diagnostics"), + SPACE_CONNECTOR_KEY_FILE: path("secrets", "connector.key"), SPACE_DOT_WORKSPACE_ROOT: path("workspaces", "dots"), + SPACE_DESIGN_GATEWAY_KEY_FILE: path("secrets", "design.key"), + SPACE_CHESS_MOVE_SOCKET: "disabled", SPACE_BROWSER_SESSIONS_ENABLED: "false", SPACE_BROWSER_EVIDENCE_ENABLED: "false", + SPACE_DEMO_PROJECTS_ENABLED: "false", SPACE_FILES_HOST_MODE: "disabled", SPACE_AGENT_TOOLS_WRITER: "disabled", + SPACE_ROOM_AGENT_AUTOPILOT_ENABLED: "false", SPACE_ROOM_AGENT_MONITOR_ENABLED: "false" + }); +} + +export function openCodePackage(dockerfile) { + const match = dockerfile.match(/(?:@opencode\/cli|opencode-ai)@\d+\.\d+\.\d+(?:[-\w.]*)?/); + if (!match) throw new Error("No pinned OpenCode package found in Dockerfile."); + return match[0]; +} + +export function composeArgs(config, ...args) { + return ["compose", "--project-name", config.project, "--file", resolve(config.state, "compose.json"), ...args]; +} diff --git a/scripts/dev-stack/processes.mjs b/scripts/dev-stack/processes.mjs new file mode 100644 index 000000000..179bb975c --- /dev/null +++ b/scripts/dev-stack/processes.mjs @@ -0,0 +1,80 @@ +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import { setTimeout as delay } from "node:timers/promises"; + +export function redact(value, secrets) { + let result = String(value); + for (const secret of secrets) if (secret) result = result.split(secret).join("[REDACTED]"); + return result; +} + +// Every child owns a process group: watch-mode subprocesses and PTYs are stopped too. +export class Processes { + children = new Set(); + stopping = false; + stopPromise; + constructor({ secrets = [], output = (line) => process.stdout.write(`${line}\n`) } = {}) { + this.secrets = secrets; + this.output = output; + this.abort = new AbortController(); + } + start(name, command, args, options = {}, persistent = false) { + if (this.stopping) throw new Error("Dev-stack is stopping."); + const child = spawn(command, args, { ...options, detached: true, stdio: ["ignore", "pipe", "pipe"] }); + const record = { name, child, exited: false }; + this.children.add(record); + for (const stream of [child.stdout, child.stderr]) { + const lines = createInterface({ input: stream }); + lines.on("line", (line) => this.output(`[${name}] ${redact(line, this.secrets)}`)); + } + const done = new Promise((resolve, reject) => { + child.once("error", (error) => { record.exited = true; reject(error); }); + child.once("exit", (code, signal) => { + record.exited = true; + if (!persistent && code === 0) this.children.delete(record); + if (!this.stopping && (persistent || code !== 0)) { + const error = new Error(`${name} exited unexpectedly (${code ?? signal}).`); + this.abort.abort(error); + reject(error); + } else resolve(); + }); + }); + // Persistent failures also interrupt readiness waits; avoid unhandled rejections. + done.catch((error) => { if (!this.stopping) this.abort.abort(error); }); + return { child, done }; + } + async run(name, command, args, options) { + await this.start(name, command, args, options).done; + } + stop() { + this.stopPromise ??= this.stopAll(); + return this.stopPromise; + } + async stopAll() { + this.stopping = true; + for (const { child } of this.children) { + if (!child.pid) continue; + try { process.kill(-child.pid, "SIGTERM"); } catch (error) { if (error.code !== "ESRCH") throw error; } + } + const deadline = Date.now() + 5000; + while ([...this.children].some(({ exited }) => !exited) && Date.now() < deadline) await delay(50); + // A parent can exit while a watcher/PTY remains in its process group. + for (const { child } of this.children) { + if (!child.pid) continue; + try { process.kill(-child.pid, "SIGKILL"); } catch (error) { if (error.code !== "ESRCH") throw error; } + } + } +} + +export async function waitForReady(url, signal, timeoutMs = 180_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + signal.throwIfAborted(); + try { + const response = await fetch(url, { signal: AbortSignal.any([signal, AbortSignal.timeout(2000)]) }); + if (response.ok && (await response.json()).ok === true) return; + } catch (error) { if (signal.aborted) throw error; } + await delay(500, undefined, { signal }); + } + throw new Error(`Readiness timeout: ${url}`); +} From d81725960b8afd27d249fbe9e0464d59c2469bd1 Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Wed, 21 Oct 2026 23:21:30 -0700 Subject: [PATCH 04/29] test: cover dev stack isolation and terminal routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- scripts/tests/dev-stack-vite.test.mjs | 43 +++++++ scripts/tests/dev-stack.test.mjs | 119 ++++++++++++++++++ .../tests/public-package-metadata.test.mjs | 2 + scripts/tests/public-suite.test.mjs | 2 + 4 files changed, 166 insertions(+) create mode 100644 scripts/tests/dev-stack-vite.test.mjs create mode 100644 scripts/tests/dev-stack.test.mjs diff --git a/scripts/tests/dev-stack-vite.test.mjs b/scripts/tests/dev-stack-vite.test.mjs new file mode 100644 index 000000000..99e284f43 --- /dev/null +++ b/scripts/tests/dev-stack-vite.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createServer } from "node:http"; +import { test } from "node:test"; +import { createServer as createViteServer } from "vite"; +import WebSocket, { WebSocketServer } from "ws"; + +test("the source web server proxies readiness and a real terminal WebSocket to the selected API", async () => { + const api = createServer((request, response) => { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ ok: true, path: request.url })); + }); + const sockets = new WebSocketServer({ server: api }); + sockets.on("connection", socket => socket.on("message", data => socket.send(data.toString()))); + api.listen(0, "127.0.0.1"); await once(api, "listening"); + const previous = process.env.SPACE_API_ORIGIN; + process.env.SPACE_API_ORIGIN = `http://127.0.0.1:${api.address().port}`; + let web; + let terminal; + try { + web = await createViteServer({ + root: new URL("../../apps/web", import.meta.url).pathname, + configFile: new URL("../../apps/web/vite.config.ts", import.meta.url).pathname, + server: { host: "127.0.0.1", port: 0, watch: null }, logLevel: "error" + }); + await web.listen(); + const origin = `http://127.0.0.1:${web.httpServer.address().port}`; + assert.deepEqual(await (await fetch(`${origin}/readyz`)).json(), { ok: true, path: "/readyz" }); + terminal = new WebSocket(`${origin.replace("http:", "ws:")}/api/panes/test/cli/terminal`); + await once(terminal, "open", { signal: AbortSignal.timeout(10_000) }); + const received = once(terminal, "message", { signal: AbortSignal.timeout(10_000) }); + terminal.send("terminal transport"); + assert.equal((await received)[0].toString(), "terminal transport"); + } finally { + terminal?.terminate(); + for (const socket of sockets.clients) socket.terminate(); + await web?.close(); + await new Promise(resolve => sockets.close(resolve)); + await new Promise(resolve => api.close(resolve)); + if (previous === undefined) delete process.env.SPACE_API_ORIGIN; + else process.env.SPACE_API_ORIGIN = previous; + } +}); diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs new file mode 100644 index 000000000..319a33385 --- /dev/null +++ b/scripts/tests/dev-stack.test.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { mkdtemp, mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import { configuration, prepareState, assertOwnedState, composeDocument, sourceEnvironment, openCodePackage } from "../dev-stack/config.mjs"; +import { Processes, redact, waitForReady } from "../dev-stack/processes.mjs"; + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), "space-dev-test-")); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(join(root, "starter-memory")); + for (const name of ["space_memory.md", "space_memory_monthly.md", "installation-guide.md"]) await writeFile(join(root, "starter-memory", name), "starter"); + return configuration(root, {}); +} + +test("secrets persist across starts with private modes and the marker binds cleanup to its checkout", async (t) => { + const config = await fixture(t); + const first = await prepareState(config); + assert.deepEqual(await prepareState(config), first); + assert.equal((await stat(config.state)).mode & 0o777, 0o700); + for (const name of Object.keys(first)) assert.equal((await stat(join(config.state, "secrets", name))).mode & 0o777, 0o600); + await assertOwnedState(config); + await assert.rejects(assertOwnedState({ ...config, project: "operator-install" }), /does not belong/); +}); + +test("refuses an unmarked directory or a symlink without changing it", async (t) => { + const config = await fixture(t); + await mkdir(config.state); + await writeFile(join(config.state, "keep"), "important"); + await assert.rejects(prepareState(config)); + assert.equal(await readFile(join(config.state, "keep"), "utf8"), "important"); + await rm(config.state, { recursive: true }); + await symlink(join(config.root, "starter-memory"), config.state); + await assert.rejects(prepareState(config), /symlink/); +}); + +test("the dev compose uses the installer's exact image digests and exposes only loopback ports", async (t) => { + const config = await fixture(t); + const template = await readFile(new URL("../../packages/run-spaceapp/templates/compose.yml", import.meta.url), "utf8"); + const doc = composeDocument(config, template); + for (const service of Object.values(doc.services)) { + assert.ok(template.includes(`image: ${service.image}`)); + assert.match(service.image, /@sha256:[a-f0-9]{64}$/); + assert.ok(service.ports.every(p => p.startsWith("127.0.0.1:"))); + } + assert.ok(!JSON.stringify(doc).includes("password-value")); + assert.throws(() => composeDocument(config, template.replace(/@sha256:[a-f0-9]{64}/g, "")), /pinned/); +}); + +test("refuses redirected secret directories before writing outside checkout state", async (t) => { + const config = await fixture(t); + await prepareState(config); + await rm(join(config.state, "secrets"), { recursive: true }); + await symlink(join(config.root, "starter-memory"), join(config.state, "secrets")); + await assert.rejects(prepareState(config), /Invalid dev-stack directory/); + await assert.rejects(readFile(join(config.root, "starter-memory", "postgres-password")), { code: "ENOENT" }); +}); + +test("rejects installed ports, overlapping ports and invalid values before spawning anything", () => { + for (const value of ["4910", "4911", "0", "-1", "65536", "hello", "5910.5"]) { + assert.throws(() => configuration("/checkout", { SPACE_DEV_API_PORT: value })); + } + assert.throws(() => configuration("/checkout", { SPACE_DEV_API_PORT: "5911" }), /distinct/); +}); + +test("source services cannot inherit installed-instance paths, credentials or injected Node flags", async (t) => { + const config = await fixture(t); + const env = sourceEnvironment(config, await prepareState(config), { + PATH: "/usr/bin", SPACE_DATABASE_URL: "postgresql://operator/live", SPACE_CLI_HOST_SOCKET: "/run/live.sock", + OPENAI_API_KEY: "operator-key", NODE_OPTIONS: "--require operator-script", HOME: "/operator-home" + }); + assert.equal(env.SPACE_DEV_LOGIN, "false"); + assert.equal(env.SPACE_INSTANCE, "dev"); + assert.equal(env.SPACEAPP_WORKFLOWS_ENABLED, "true"); + assert.equal(env.OPENAI_API_KEY, undefined); + assert.equal(env.NODE_OPTIONS, undefined); + assert.ok(env.SPACE_DATABASE_URL.endsWith("/spaceapp_dev")); + assert.ok(env.HOME.startsWith(config.state)); + assert.equal(env.SPACE_CLI_HOST_SOCKET, config.hostSocket); +}); + +test("reads both generations of the pinned OpenCode package from Dockerfile", () => { + assert.equal(openCodePackage("npm install --global opencode-ai@1.18.4"), "opencode-ai@1.18.4"); + assert.equal(openCodePackage("npm install --global @opencode/cli@2.0.32"), "@opencode/cli@2.0.32"); + assert.throws(() => openCodePackage("npm install opencode-ai@latest"), /pinned/); +}); + +test("a failed worker aborts supervision and real child groups are terminated", async () => { + const lines = []; + const processes = new Processes({ output: line => lines.push(line), secrets: ["private-password"] }); + try { + const alive = processes.start("web", process.execPath, ["-e", "setInterval(()=>{},1000)"], {}, true); + const failure = processes.start("worker", process.execPath, ["-e", "console.error('private-password'); process.exit(7)"], {}, true); + await assert.rejects(failure.done, /worker exited unexpectedly \(7\)/); + assert.equal(processes.abort.signal.aborted, true); + assert.ok(lines.some(line => line.includes("[REDACTED]"))); + await processes.stop(); + await alive.done; + assert.throws(() => process.kill(alive.child.pid, 0), { code: "ESRCH" }); + } finally { await processes.stop(); } +}); + +test("readiness requires ok:true rather than any HTTP response and respects abort", async () => { + let ready = false; + const server = createServer((req, res) => { res.setHeader("content-type", "application/json"); res.end(JSON.stringify({ ok: ready })); }); + server.listen(0, "127.0.0.1"); await once(server, "listening"); + try { + const url = `http://127.0.0.1:${server.address().port}/readyz`; + await assert.rejects(waitForReady(url, new AbortController().signal, 30), /timeout/); + ready = true; + await waitForReady(url, new AbortController().signal, 1000); + const abort = new AbortController(); abort.abort(new Error("worker died")); + await assert.rejects(waitForReady(url, abort.signal), /worker died/); + } finally { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); } + assert.equal(redact("url: secret-value", ["secret-value"]), "url: [REDACTED]"); +}); diff --git a/scripts/tests/public-package-metadata.test.mjs b/scripts/tests/public-package-metadata.test.mjs index 4bcafcdc7..d17074e4a 100644 --- a/scripts/tests/public-package-metadata.test.mjs +++ b/scripts/tests/public-package-metadata.test.mjs @@ -11,6 +11,8 @@ const globalFollowUpCommand = /(^|[^\w-])spaceapp (?:doctor|status|uninstall|credentials)\b/; const publicTestModules = [ + "dev-stack.test.mjs", + "dev-stack-vite.test.mjs", "publish-npm-candidate.test.mjs", "container-image-size-budget.test.mjs", "mcp-hono-compatibility.test.mjs", diff --git a/scripts/tests/public-suite.test.mjs b/scripts/tests/public-suite.test.mjs index 1b541bb80..07ed50d88 100644 --- a/scripts/tests/public-suite.test.mjs +++ b/scripts/tests/public-suite.test.mjs @@ -1,3 +1,5 @@ +import "./dev-stack.test.mjs"; +import "./dev-stack-vite.test.mjs"; import "./publish-npm-candidate.test.mjs"; import "./container-image-size-budget.test.mjs"; import "./mcp-hono-compatibility.test.mjs"; From 8b1236e57b0eb4f566ddad513056aeece160d9b9 Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Wed, 21 Oct 2026 23:22:16 -0700 Subject: [PATCH 05/29] fix: keep dev auth isolated from installed credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- apps/api/src/auth.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index a2deafdcd..061fbc3d8 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -60,7 +60,8 @@ export const csrfHeaderName = CSRF_HEADER; export function getAuthConfig(env: NodeJS.ProcessEnv): AuthConfig { let googleClientId = env.SPACE_GOOGLE_CLIENT_ID || env.GOOGLE_CLIENT_ID || undefined; let googleClientSecret = env.SPACE_GOOGLE_CLIENT_SECRET || env.GOOGLE_CLIENT_SECRET || undefined; - if (!googleClientId) { + // A source dev stack must not discover an installed operator's OAuth secrets. + if (!googleClientId && env.SPACE_INSTANCE !== "dev" && env.SPACE_GOOGLE_AUTH_DISABLED !== "true") { try { const candidates = ["/opt/spaceapp/secrets/google-oauth.json", "/var/lib/spaceapp/google-oauth.json"]; for (const p of candidates) { From 02979a13da949231015e705cd89153d3267ed103 Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Wed, 21 Oct 2026 23:23:05 -0700 Subject: [PATCH 06/29] test: reject installed OAuth discovery in source dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- apps/api/tests/auth-dev-isolation.test.ts | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 apps/api/tests/auth-dev-isolation.test.ts diff --git a/apps/api/tests/auth-dev-isolation.test.ts b/apps/api/tests/auth-dev-isolation.test.ts new file mode 100644 index 000000000..16f58c0a1 --- /dev/null +++ b/apps/api/tests/auth-dev-isolation.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from "vitest"; + +const files = vi.hoisted(() => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => JSON.stringify({ clientId: "installed-client", clientSecret: "installed-secret" })) +})); +vi.mock("node:fs", async (original) => ({ ...await original(), ...files })); +import { getAuthConfig } from "../src/auth.js"; + +describe("source development OAuth isolation", () => { + it("does not probe installed files in dev or when Google auth is disabled", () => { + for (const env of [{ SPACE_INSTANCE: "dev" }, { SPACE_GOOGLE_AUTH_DISABLED: "true" }]) { + files.existsSync.mockClear(); files.readFileSync.mockClear(); + expect(getAuthConfig(env).googleClientId).toBeUndefined(); + expect(files.existsSync).not.toHaveBeenCalled(); + expect(files.readFileSync).not.toHaveBeenCalled(); + } + }); + it("retains installed-instance discovery and explicit dev configuration", () => { + expect(getAuthConfig({}).googleClientId).toBe("installed-client"); + expect(getAuthConfig({ SPACE_INSTANCE: "dev", SPACE_GOOGLE_CLIENT_ID: "own-client", SPACE_GOOGLE_CLIENT_SECRET: "own-secret" })) + .toMatchObject({ googleClientId: "own-client", googleClientSecret: "own-secret" }); + }); +}); From 2fb3cbb06b51b7b77fa428b6f41d6749d5127835 Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Wed, 21 Oct 2026 23:24:09 -0700 Subject: [PATCH 07/29] fix: proxy source API and terminal WebSockets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- apps/web/vite.config.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index cd7612d98..8bf47c97c 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -34,11 +34,11 @@ export default defineConfig({ host: process.env.SPACE_WEB_HOST ?? "0.0.0.0", port: Number.parseInt(process.env.SPACE_WEB_PORT ?? "4911", 10), 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: process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910", ws: true }, + "/plugins": process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910", + "/healthz": process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910", + "/readyz": process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910", + "/version": process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910" } }, test: { From a20d191e12571687a97cee2aae555e119b3842e0 Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Wed, 21 Oct 2026 23:26:01 -0700 Subject: [PATCH 08/29] ci: validate source stack and cleanup on Ubuntu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- .github/workflows/dev-stack.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/dev-stack.yml diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml new file mode 100644 index 000000000..eb45c9483 --- /dev/null +++ b/.github/workflows/dev-stack.yml @@ -0,0 +1,29 @@ +name: Source development stack + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + source-stack: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + - run: node --test scripts/tests/dev-stack.test.mjs scripts/tests/dev-stack-vite.test.mjs + - name: Real source stack, owner setup, OpenCode PTY and cleanup + run: npm run smoke:dev-stack From a7a61aa9dfd93c656fff72060defb37472ebeff8 Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Wed, 21 Oct 2026 23:54:37 -0700 Subject: [PATCH 09/29] ci: verify source stack on Linux and macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- .github/workflows/dev-stack.yml | 42 +++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml index eb45c9483..53a06d373 100644 --- a/.github/workflows/dev-stack.yml +++ b/.github/workflows/dev-stack.yml @@ -11,8 +11,13 @@ permissions: jobs: source-stack: - runs-on: ubuntu-latest - timeout-minutes: 30 + name: Source stack / ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-15-intel] + runs-on: ${{ matrix.os }} + timeout-minutes: 45 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: @@ -21,9 +26,42 @@ jobs: with: node-version-file: .nvmrc cache: npm + - name: Start Docker on macOS with Colima + if: runner.os == 'macOS' + run: | + brew install colima docker docker-compose + mkdir -p "$HOME/.docker/cli-plugins" + ln -sf "$(brew --prefix)/opt/docker-compose/bin/docker-compose" "$HOME/.docker/cli-plugins/docker-compose" + colima start --cpu 2 --memory 6 --disk 30 --runtime docker + docker info - run: npm ci env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" - run: node --test scripts/tests/dev-stack.test.mjs scripts/tests/dev-stack-vite.test.mjs - name: Real source stack, owner setup, OpenCode PTY and cleanup run: npm run smoke:dev-stack + - name: Stop test VM + if: always() && runner.os == 'macOS' + run: colima stop + + source-quality: + name: Source quality gates + runs-on: ubuntu-latest + timeout-minutes: 75 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + cache: npm + - name: Install native test dependencies + run: sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep ffmpeg + - run: npm ci + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + - run: npm run check + - run: npm test + - run: npm run build + - run: npm run hygiene:preflight From 0a02e25bba8239f4f8169674ed9c245fa1068b02 Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Wed, 21 Oct 2026 23:57:56 -0700 Subject: [PATCH 10/29] fix: diagnose failed containers before cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- scripts/dev-stack-smoke.mjs | 7 +++++++ scripts/dev-stack.mjs | 10 +++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/dev-stack-smoke.mjs b/scripts/dev-stack-smoke.mjs index be4d0a530..6f6da61f0 100644 --- a/scripts/dev-stack-smoke.mjs +++ b/scripts/dev-stack-smoke.mjs @@ -8,6 +8,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { setTimeout as delay } from "node:timers/promises"; import { once } from "node:events"; +import { createInterface } from "node:readline"; import WebSocket from "ws"; import { configuration } from "./dev-stack/config.mjs"; import { redact } from "./dev-stack/processes.mjs"; @@ -32,6 +33,12 @@ const child = spawn(process.execPath, [npmCli, "run", "dev:stack"], { cwd: root, const ended = new Promise(resolve => child.on("exit", (code) => { childExit = code; resolve(); })); child.stdout.on("data", data => { output = (output + data).slice(-2_000_000); }); child.stderr.on("data", data => { output = (output + data).slice(-2_000_000); }); +for (const stream of [child.stdout, child.stderr]) { + createInterface({ input: stream }).on("line", line => { + // Forward only fixed launcher milestones; raw service logs remain buffered/redacted. + if (line.startsWith("[dev:stack] ")) console.log(line); + }); +} child.on("error", error => { output += error.message; childExit = 1; }); async function until(check, description, timeout = 90_000) { diff --git a/scripts/dev-stack.mjs b/scripts/dev-stack.mjs index b839ad680..4b2638b2c 100644 --- a/scripts/dev-stack.mjs +++ b/scripts/dev-stack.mjs @@ -164,13 +164,16 @@ async function main() { await chmod(config.control, 0o600); const template = await readFile(join(root, "packages/run-spaceapp/templates/compose.yml"), "utf8"); await writeFile(join(config.state, "compose.json"), JSON.stringify(composeDocument(config, template), null, 2), { mode: 0o600 }); + console.log("[dev:stack] Building source packages and installing pinned OpenCode."); await build(processes); await prepareOpenCode(processes); signal.throwIfAborted(); composeStarted = true; + console.log("[dev:stack] Starting PostgreSQL and Temporal."); await processes.run("docker:up", "docker", composeArgs(config, "up", "--detach", "--wait", "--wait-timeout", "240"), { cwd: config.state }); await processes.run("temporal:namespace", "docker", composeArgs(config, "exec", "-T", "temporal", "sh", "-ec", - "temporal operator namespace describe --namespace spaceapp-dev --address 127.0.0.1:7233 >/dev/null 2>&1 || temporal operator namespace create --namespace spaceapp-dev --address 127.0.0.1:7233"), { cwd: config.state }); + "temporal operator namespace describe --namespace spaceapp-dev --address temporal:7233 >/dev/null 2>&1 || temporal operator namespace create --namespace spaceapp-dev --address temporal:7233"), { cwd: config.state }); + console.log("[dev:stack] Applying migrations and starting source services."); const env = sourceEnvironment(config, secrets); await mkdir(env.TMPDIR, { recursive: true, mode: 0o700 }); await processes.run("migrate", process.execPath, ["packages/db/dist/migrate.js"], { cwd: root, env }); @@ -181,6 +184,7 @@ async function main() { processes.start("api", process.execPath, ["node_modules/tsx/dist/cli.mjs", "watch", "apps/api/src/server.ts"], { cwd: root, env }, true); processes.start("web", process.execPath, ["node_modules/vite/bin/vite.js", "--strictPort"], { cwd: join(root, "apps/web"), env }, true); await writeFile(join(config.state, "processes.json"), JSON.stringify([...processes.children].filter(r => !r.exited).map(r => ({ name: r.name, pid: r.child.pid }))), { mode: 0o600 }); + console.log("[dev:stack] Waiting for database, worker and CLI-host readiness."); await waitForReady(`http://127.0.0.1:${config.ports.web}/readyz`, signal); console.log(`\nSpaceApp: http://127.0.0.1:${config.ports.web}\nOne-time owner setup token: ${secrets["setup-token"]}\nOptional browser/companion services are disabled. Ctrl+C or npm run dev:stack -- down stops this stack.`); await new Promise((resolve) => { if (signal.aborted) resolve(); else signal.addEventListener("abort", resolve, { once: true }); }); @@ -190,6 +194,10 @@ async function main() { await processes.stop(); if (composeStarted) { const cleanup = new Processes({ secrets: Object.values(secrets) }); + if (!controller.signal.aborted) { + // Preserve useful startup diagnostics before removing failed containers. + await cleanup.run("docker:diagnostics", "docker", composeArgs(config, "logs", "--no-color", "--tail", "100"), { cwd: config.state }).catch(() => {}); + } try { await cleanup.run("docker:down", "docker", composeArgs(config, "down", "--remove-orphans"), { cwd: config.state }); } catch (error) { cleanupError = error; } finally { await cleanup.stop(); } From 742284d70c712b397b06229724c97dc9213d97b2 Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Wed, 21 Oct 2026 23:59:57 -0700 Subject: [PATCH 11/29] fix: use Temporal service address for health checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- scripts/dev-stack/config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/dev-stack/config.mjs b/scripts/dev-stack/config.mjs index 00432e1da..99845e6f0 100644 --- a/scripts/dev-stack/config.mjs +++ b/scripts/dev-stack/config.mjs @@ -101,7 +101,7 @@ export function composeDocument(config, template) { ports: [`127.0.0.1:${config.ports.temporal}:7233`], secrets: ["postgres-password"], depends_on: { postgres: { condition: "service_healthy" } }, healthcheck: { - test: ["CMD-SHELL", "temporal operator cluster health --address 127.0.0.1:7233 >/dev/null && temporal operator namespace describe --namespace default --address 127.0.0.1:7233 >/dev/null"], + test: ["CMD-SHELL", "temporal operator cluster health --address temporal:7233 >/dev/null && temporal operator namespace describe --namespace default --address temporal:7233 >/dev/null"], interval: "3s", timeout: "10s", retries: 60 } } From 40d01456f170cf788c90b83b0e84d4f4ec19ed4a Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Thu, 22 Oct 2026 07:12:34 +0000 Subject: [PATCH 12/29] fix: verify original export snapshot and regenerated candidate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify the original manifest snapshot independently from subsequent public contributions, then strictly verify the regenerated candidate before tests and builds. Signed-off-by: 秦世为 <345942185@qq.com> --- .github/workflows/ci.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1563948e..ea80f84f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + # Locate the original export snapshot even after contributor commits. + fetch-depth: 0 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: @@ -31,13 +33,25 @@ jobs: cache: npm - name: Verify export provenance - run: node scripts/verify-export-provenance.mjs . + shell: bash + run: | + set -euo pipefail + # The manifest describes the export snapshot, not later public edits. + export_commit=$(git log -1 --format=%H -- PUBLIC_EXPORT_MANIFEST.json) + test -n "$export_commit" + snapshot=$(mktemp -d "$RUNNER_TEMP/spaceapp-provenance.XXXXXX") + trap 'rm -rf "$snapshot"' EXIT + git archive "$export_commit" | tar -x -C "$snapshot" + node scripts/verify-export-provenance.mjs "$snapshot" - name: Create sanitized candidate run: >- node scripts/public-export.mjs --output "$RUNNER_TEMP/spaceapp-public-export" + - name: Verify candidate provenance + run: node scripts/verify-export-provenance.mjs "$RUNNER_TEMP/spaceapp-public-export" + - name: Install candidate dependencies working-directory: ${{ runner.temp }}/spaceapp-public-export run: npm ci From 520c750b0110f64a4e7b7e14de67b17096b39a2a Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Thu, 22 Oct 2026 07:15:23 +0000 Subject: [PATCH 13/29] test: cover export snapshot verification after public edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercise the CI snapshot step against a real temporary Git history: allow later public edits while rejecting corrupted export manifests. Signed-off-by: 秦世为 <345942185@qq.com> --- scripts/tests/public-workflows.test.mjs | 55 ++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/scripts/tests/public-workflows.test.mjs b/scripts/tests/public-workflows.test.mjs index d48dd186a..1ef56ad98 100644 --- a/scripts/tests/public-workflows.test.mjs +++ b/scripts/tests/public-workflows.test.mjs @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { readFile, mkdtemp, mkdir, writeFile, copyFile, rm } from "node:fs/promises"; +import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import test from "node:test"; @@ -239,3 +242,53 @@ test("workflow actions use full commit SHA pins with version comments", async () assert.equal(pinnedCount, usesCount, `${name} must pin every action by full commit SHA`); } }); + + +test("CI verifies the export snapshot after public edits and rejects corrupted provenance", async (t) => { + const ci = await workflow("ci.yml"); + assert.match(ci, /fetch-depth: 0/); + const commands = ci.match(/- name: Verify export provenance\n shell: bash\n run: \|\n([\s\S]*?)\n - name:/)?.[1] + .split("\n").map(line => line.replace(/^ /, "")).join("\n"); + assert.ok(commands, "snapshot verification must be present"); + assert.match(ci, /Verify candidate provenance[\s\S]*verify-export-provenance\.mjs "\$RUNNER_TEMP\/spaceapp-public-export"/); + const temporary = await mkdtemp(join(tmpdir(), "spaceapp-ci-provenance-")); + t.after(() => rm(temporary, { recursive: true, force: true })); + const checkout = join(temporary, "checkout"); + await mkdir(join(checkout, "scripts"), { recursive: true }); + await copyFile(join(root, "scripts/verify-export-provenance.mjs"), join(checkout, "scripts/verify-export-provenance.mjs")); + const git = (...args) => execFileSync("git", args, { cwd: checkout, stdio: "pipe" }); + git("init"); + git("config", "user.name", "CI test"); + git("config", "user.email", "ci@example.invalid"); + const original = "original export\n"; + const manifest = { + sourceCommit: "a".repeat(40), transformations: 0, + files: [{ path: "file.txt", size: Buffer.byteLength(original), + sha256: createHash("sha256").update(original).digest("hex"), + sourceObjectId: createHash("sha1").update(`blob ${Buffer.byteLength(original)}\0${original}`).digest("hex") }] + }; + await writeFile(join(checkout, "file.txt"), original); + await writeFile(join(checkout, "PUBLIC_EXPORT_MANIFEST.json"), JSON.stringify(manifest)); + git("add", "."); + git("commit", "-m", "original export"); + await writeFile(join(checkout, "file.txt"), "intentional public change\n"); + git("add", "."); + git("commit", "-m", "public contribution"); + const verify = () => spawnSync("bash", ["-c", commands], { + cwd: checkout, env: { ...process.env, RUNNER_TEMP: temporary }, encoding: "utf8" + }); + let result = verify(); + assert.equal(result.status, 0, result.stdout + result.stderr); + // The current edited tree still fails the strict verifier: it was not weakened. + result = spawnSync(process.execPath, ["scripts/verify-export-provenance.mjs", "."], { cwd: checkout, encoding: "utf8" }); + assert.equal(result.status, 1); + assert.match(result.stdout, /differs from the manifest: file.txt/); + // A newly recorded export with a bad checksum must fail the snapshot step too. + manifest.files[0].sha256 = "0".repeat(64); + await writeFile(join(checkout, "PUBLIC_EXPORT_MANIFEST.json"), JSON.stringify(manifest)); + git("add", "."); + git("commit", "-m", "corrupt export manifest"); + result = verify(); + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.match(result.stdout, /differs from the manifest: file.txt/); +}); From 7c7daf1d07d41f551947c0d5909691ca83e90868 Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Thu, 22 Oct 2026 07:44:47 +0000 Subject: [PATCH 14/29] fix: require database and enabled worker readiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- scripts/dev-stack/processes.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/dev-stack/processes.mjs b/scripts/dev-stack/processes.mjs index 179bb975c..7b6164093 100644 --- a/scripts/dev-stack/processes.mjs +++ b/scripts/dev-stack/processes.mjs @@ -72,7 +72,12 @@ export async function waitForReady(url, signal, timeoutMs = 180_000) { signal.throwIfAborted(); try { const response = await fetch(url, { signal: AbortSignal.any([signal, AbortSignal.timeout(2000)]) }); - if (response.ok && (await response.json()).ok === true) return; + if (response.ok) { + const status = await response.json(); + if (status.ok === true && status.dependencies?.database === "RUNNING" + && status.dependencies?.worker === "RUNNING" + && status.dependencies?.temporal === "enabled") return; + } } catch (error) { if (signal.aborted) throw error; } await delay(500, undefined, { signal }); } From 70bd8d1117af8dcf13b51274a5925784e57a7566 Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Thu, 22 Oct 2026 07:45:50 +0000 Subject: [PATCH 15/29] fix: reset smoke resources after launcher failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- scripts/dev-stack-smoke.mjs | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/scripts/dev-stack-smoke.mjs b/scripts/dev-stack-smoke.mjs index 6f6da61f0..6ea5808e8 100644 --- a/scripts/dev-stack-smoke.mjs +++ b/scripts/dev-stack-smoke.mjs @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { spawn, execFileSync } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { readFile, realpath } from "node:fs/promises"; +import { lstat, readFile, realpath } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { setTimeout as delay } from "node:timers/promises"; @@ -71,6 +71,7 @@ function docker(args) { } let secrets = []; +let failure; try { await until(async () => { try { const r = await fetch(`http://127.0.0.1:${config.ports.web}/readyz`, { signal: AbortSignal.timeout(2000) }); return r.ok && (await r.json()).ok === true; } @@ -134,13 +135,28 @@ try { } catch (error) { // Setup tokens are deliberately printed by the interactive launcher. Never echo them in CI. console.error(redact(output, secrets).replace(/One-time owner setup token: \S+/g, "One-time owner setup token: [REDACTED]")); - throw error; + failure = error; } finally { socket?.terminate(); - if (childExit === undefined) { - stopping = true; + stopping = true; + // A failed launcher can already have exited while retaining its Docker volume + // and private state. Cleanup must not depend on the launcher still being alive. + const stateExists = await lstat(config.state).then(() => true, error => { + if (error.code === "ENOENT") return false; + throw error; + }); + if (stateExists) { try { execFileSync(process.execPath, [npmCli, "run", "dev:stack", "--", "reset"], { cwd: root, stdio: "pipe", timeout: 90_000 }); } - catch { child.kill("SIGTERM"); } + catch { + if (childExit === undefined) child.kill("SIGTERM"); + const cleanupError = new Error("Dev-stack cleanup failed; inspect this checkout's .dev-stack state and Docker resources."); + console.error(cleanupError.message); + failure ??= cleanupError; + } + } + if (childExit === undefined) { + if (!stateExists) child.kill("SIGTERM"); await Promise.race([ended, delay(10_000)]); } } +if (failure) throw failure; From c7a4e0e88b7906ca82263421ad67d77ca85b3d12 Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Thu, 22 Oct 2026 07:46:47 +0000 Subject: [PATCH 16/29] test: cover disabled workflows and cleanup after failed startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- scripts/tests/dev-stack.test.mjs | 44 +++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs index 319a33385..c629a7657 100644 --- a/scripts/tests/dev-stack.test.mjs +++ b/scripts/tests/dev-stack.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { mkdtemp, mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { copyFile, mkdtemp, mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { createServer } from "node:http"; @@ -104,16 +104,52 @@ test("a failed worker aborts supervision and real child groups are terminated", }); test("readiness requires ok:true rather than any HTTP response and respects abort", async () => { - let ready = false; - const server = createServer((req, res) => { res.setHeader("content-type", "application/json"); res.end(JSON.stringify({ ok: ready })); }); + let status = { ok: false }; + const server = createServer((req, res) => { res.setHeader("content-type", "application/json"); res.end(JSON.stringify(status)); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); try { const url = `http://127.0.0.1:${server.address().port}/readyz`; await assert.rejects(waitForReady(url, new AbortController().signal, 30), /timeout/); - ready = true; + for (const dependencies of [undefined, + { database: "RUNNING", worker: "disabled", temporal: "disabled" }, + { database: "RUNNING", worker: "STALE", temporal: "enabled" }, + { database: "UNAVAILABLE", worker: "RUNNING", temporal: "enabled" }]) { + status = { ok: true, dependencies }; + await assert.rejects(waitForReady(url, new AbortController().signal, 30), /timeout/); + } + status = { ok: true, dependencies: { database: "RUNNING", worker: "RUNNING", temporal: "enabled" } }; await waitForReady(url, new AbortController().signal, 1000); const abort = new AbortController(); abort.abort(new Error("worker died")); await assert.rejects(waitForReady(url, abort.signal), /worker died/); } finally { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); } assert.equal(redact("url: secret-value", ["secret-value"]), "url: [REDACTED]"); }); + +test("smoke cleanup resets resources even when the launcher has already failed", async (t) => { + const config = await fixture(t); + const scripts = join(config.root, "scripts"); + await mkdir(join(scripts, "dev-stack"), { recursive: true }); + for (const file of ["dev-stack-smoke.mjs", "dev-stack/config.mjs", "dev-stack/processes.mjs"]) { + await copyFile(new URL(`../${file}`, import.meta.url), join(scripts, file)); + } + await symlink(new URL("../../node_modules", import.meta.url).pathname, join(config.root, "node_modules")); + const fakeNpm = join(config.root, "fake-npm.mjs"); + await writeFile(fakeNpm, ` + import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; + if (process.argv.includes('reset')) { + rmSync('.dev-stack', { recursive: true, force: true }); + writeFileSync('reset-proof', 'cleaned'); + } else { + mkdirSync('.dev-stack'); + process.exitCode = 9; + } + `); + const processes = new Processes({ output: () => {} }); + try { + await assert.rejects(processes.run("smoke", process.execPath, [join(scripts, "dev-stack-smoke.mjs")], { + cwd: config.root, env: { ...process.env, npm_execpath: fakeNpm } + }), /exited unexpectedly \(1\)/); + assert.equal(await readFile(join(config.root, "reset-proof"), "utf8"), "cleaned"); + await assert.rejects(stat(config.state), { code: "ENOENT" }); + } finally { await processes.stop(); } +}); From 49ecf95edc533dd1c67c18e13d5f4942013eea95 Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Thu, 22 Oct 2026 07:47:29 +0000 Subject: [PATCH 17/29] ci: restore upstream provenance policy for outside contributions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #65 explicitly states that reviewers ignore the expected outside-PR provenance failure. Preserve the upstream gate instead of changing its semantics. Signed-off-by: 秦世为 <345942185@qq.com> --- .github/workflows/ci.yml | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea80f84f0..c1563948e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,8 +24,6 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - # Locate the original export snapshot even after contributor commits. - fetch-depth: 0 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: @@ -33,25 +31,13 @@ jobs: cache: npm - name: Verify export provenance - shell: bash - run: | - set -euo pipefail - # The manifest describes the export snapshot, not later public edits. - export_commit=$(git log -1 --format=%H -- PUBLIC_EXPORT_MANIFEST.json) - test -n "$export_commit" - snapshot=$(mktemp -d "$RUNNER_TEMP/spaceapp-provenance.XXXXXX") - trap 'rm -rf "$snapshot"' EXIT - git archive "$export_commit" | tar -x -C "$snapshot" - node scripts/verify-export-provenance.mjs "$snapshot" + run: node scripts/verify-export-provenance.mjs . - name: Create sanitized candidate run: >- node scripts/public-export.mjs --output "$RUNNER_TEMP/spaceapp-public-export" - - name: Verify candidate provenance - run: node scripts/verify-export-provenance.mjs "$RUNNER_TEMP/spaceapp-public-export" - - name: Install candidate dependencies working-directory: ${{ runner.temp }}/spaceapp-public-export run: npm ci From 8bff3d4fb004beb4843c8941c40464a03b3b5df9 Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Thu, 22 Oct 2026 07:48:19 +0000 Subject: [PATCH 18/29] test: restore upstream workflow assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- scripts/tests/public-workflows.test.mjs | 55 +------------------------ 1 file changed, 1 insertion(+), 54 deletions(-) diff --git a/scripts/tests/public-workflows.test.mjs b/scripts/tests/public-workflows.test.mjs index 1ef56ad98..d48dd186a 100644 --- a/scripts/tests/public-workflows.test.mjs +++ b/scripts/tests/public-workflows.test.mjs @@ -1,8 +1,5 @@ import assert from "node:assert/strict"; -import { readFile, mkdtemp, mkdir, writeFile, copyFile, rm } from "node:fs/promises"; -import { execFileSync, spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { tmpdir } from "node:os"; +import { readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import test from "node:test"; @@ -242,53 +239,3 @@ test("workflow actions use full commit SHA pins with version comments", async () assert.equal(pinnedCount, usesCount, `${name} must pin every action by full commit SHA`); } }); - - -test("CI verifies the export snapshot after public edits and rejects corrupted provenance", async (t) => { - const ci = await workflow("ci.yml"); - assert.match(ci, /fetch-depth: 0/); - const commands = ci.match(/- name: Verify export provenance\n shell: bash\n run: \|\n([\s\S]*?)\n - name:/)?.[1] - .split("\n").map(line => line.replace(/^ /, "")).join("\n"); - assert.ok(commands, "snapshot verification must be present"); - assert.match(ci, /Verify candidate provenance[\s\S]*verify-export-provenance\.mjs "\$RUNNER_TEMP\/spaceapp-public-export"/); - const temporary = await mkdtemp(join(tmpdir(), "spaceapp-ci-provenance-")); - t.after(() => rm(temporary, { recursive: true, force: true })); - const checkout = join(temporary, "checkout"); - await mkdir(join(checkout, "scripts"), { recursive: true }); - await copyFile(join(root, "scripts/verify-export-provenance.mjs"), join(checkout, "scripts/verify-export-provenance.mjs")); - const git = (...args) => execFileSync("git", args, { cwd: checkout, stdio: "pipe" }); - git("init"); - git("config", "user.name", "CI test"); - git("config", "user.email", "ci@example.invalid"); - const original = "original export\n"; - const manifest = { - sourceCommit: "a".repeat(40), transformations: 0, - files: [{ path: "file.txt", size: Buffer.byteLength(original), - sha256: createHash("sha256").update(original).digest("hex"), - sourceObjectId: createHash("sha1").update(`blob ${Buffer.byteLength(original)}\0${original}`).digest("hex") }] - }; - await writeFile(join(checkout, "file.txt"), original); - await writeFile(join(checkout, "PUBLIC_EXPORT_MANIFEST.json"), JSON.stringify(manifest)); - git("add", "."); - git("commit", "-m", "original export"); - await writeFile(join(checkout, "file.txt"), "intentional public change\n"); - git("add", "."); - git("commit", "-m", "public contribution"); - const verify = () => spawnSync("bash", ["-c", commands], { - cwd: checkout, env: { ...process.env, RUNNER_TEMP: temporary }, encoding: "utf8" - }); - let result = verify(); - assert.equal(result.status, 0, result.stdout + result.stderr); - // The current edited tree still fails the strict verifier: it was not weakened. - result = spawnSync(process.execPath, ["scripts/verify-export-provenance.mjs", "."], { cwd: checkout, encoding: "utf8" }); - assert.equal(result.status, 1); - assert.match(result.stdout, /differs from the manifest: file.txt/); - // A newly recorded export with a bad checksum must fail the snapshot step too. - manifest.files[0].sha256 = "0".repeat(64); - await writeFile(join(checkout, "PUBLIC_EXPORT_MANIFEST.json"), JSON.stringify(manifest)); - git("add", "."); - git("commit", "-m", "corrupt export manifest"); - result = verify(); - assert.equal(result.status, 1, result.stdout + result.stderr); - assert.match(result.stdout, /differs from the manifest: file.txt/); -}); From f7e163506acbab03ab059dd7092cf3d1ce653c83 Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Thu, 22 Oct 2026 00:56:08 -0700 Subject: [PATCH 19/29] fix: resolve hoisted Vite from the checkout root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- scripts/dev-stack/processes.mjs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/dev-stack/processes.mjs b/scripts/dev-stack/processes.mjs index 7b6164093..352d3eee7 100644 --- a/scripts/dev-stack/processes.mjs +++ b/scripts/dev-stack/processes.mjs @@ -1,6 +1,15 @@ import { spawn } from "node:child_process"; import { createInterface } from "node:readline"; import { setTimeout as delay } from "node:timers/promises"; +import { join } from "node:path"; + +export function startWeb(processes, root, env) { + // npm workspaces hoists Vite to the checkout root, while its working + // directory must remain apps/web so it loads the web configuration. + return processes.start("web", process.execPath, + [join(root, "node_modules/vite/bin/vite.js"), "--strictPort"], + { cwd: join(root, "apps/web"), env }, true); +} export function redact(value, secrets) { let result = String(value); From fb197f79a7ea69d09b4ea19ec4d90a07ac05852e Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Thu, 22 Oct 2026 00:56:42 -0700 Subject: [PATCH 20/29] fix: launch the web service using the absolute Vite path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix MODULE_NOT_FOUND in Source stack / ubuntu-latest job 119704861837. Signed-off-by: 秦世为 <345942185@qq.com> --- scripts/dev-stack.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/dev-stack.mjs b/scripts/dev-stack.mjs index 4b2638b2c..e2652f41c 100644 --- a/scripts/dev-stack.mjs +++ b/scripts/dev-stack.mjs @@ -5,7 +5,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { once } from "node:events"; import { assertOwnedState, composeArgs, composeDocument, configuration, openCodePackage, prepareState, sourceEnvironment } from "./dev-stack/config.mjs"; -import { Processes, redact, waitForReady } from "./dev-stack/processes.mjs"; +import { Processes, redact, startWeb, waitForReady } from "./dev-stack/processes.mjs"; const root = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..")); const config = configuration(root); @@ -182,7 +182,7 @@ async function main() { hostStarted = true; processes.start("worker", process.execPath, ["apps/worker/dist/worker.js"], { cwd: root, env }, true); processes.start("api", process.execPath, ["node_modules/tsx/dist/cli.mjs", "watch", "apps/api/src/server.ts"], { cwd: root, env }, true); - processes.start("web", process.execPath, ["node_modules/vite/bin/vite.js", "--strictPort"], { cwd: join(root, "apps/web"), env }, true); + startWeb(processes, root, env); await writeFile(join(config.state, "processes.json"), JSON.stringify([...processes.children].filter(r => !r.exited).map(r => ({ name: r.name, pid: r.child.pid }))), { mode: 0o600 }); console.log("[dev:stack] Waiting for database, worker and CLI-host readiness."); await waitForReady(`http://127.0.0.1:${config.ports.web}/readyz`, signal); From 55b84a7fdd683c4487340a3eb7b4f3bb73d06f41 Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Thu, 22 Oct 2026 00:57:29 -0700 Subject: [PATCH 21/29] test: start the real Vite CLI from the launcher working directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression for the hoisted executable path failure; verifies an actual child process serves the API readiness proxy. Signed-off-by: 秦世为 <345942185@qq.com> --- scripts/tests/dev-stack-vite.test.mjs | 37 +++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/scripts/tests/dev-stack-vite.test.mjs b/scripts/tests/dev-stack-vite.test.mjs index 99e284f43..a167e4ac3 100644 --- a/scripts/tests/dev-stack-vite.test.mjs +++ b/scripts/tests/dev-stack-vite.test.mjs @@ -4,6 +4,8 @@ import { createServer } from "node:http"; import { test } from "node:test"; import { createServer as createViteServer } from "vite"; import WebSocket, { WebSocketServer } from "ws"; +import { setTimeout as delay } from "node:timers/promises"; +import { Processes, startWeb } from "../dev-stack/processes.mjs"; test("the source web server proxies readiness and a real terminal WebSocket to the selected API", async () => { const api = createServer((request, response) => { @@ -41,3 +43,38 @@ test("the source web server proxies readiness and a real terminal WebSocket to t else process.env.SPACE_API_ORIGIN = previous; } }); + +test("the launcher starts the hoisted Vite binary with apps/web as its working directory", async () => { + const api = createServer((request, response) => { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ ok: true, path: request.url })); + }); + api.listen(0, "127.0.0.1"); await once(api, "listening"); + const reservation = createServer(); + reservation.listen(0, "127.0.0.1"); await once(reservation, "listening"); + const port = reservation.address().port; + await new Promise(resolve => reservation.close(resolve)); + const lines = []; + const processes = new Processes({ output: line => lines.push(line) }); + try { + startWeb(processes, new URL("../../", import.meta.url).pathname, { + ...process.env, SPACE_WEB_HOST: "127.0.0.1", SPACE_WEB_PORT: String(port), + SPACE_API_ORIGIN: `http://127.0.0.1:${api.address().port}` + }); + const deadline = Date.now() + 15_000; + let result; + while (Date.now() < deadline) { + processes.abort.signal.throwIfAborted(); + try { + const response = await fetch(`http://127.0.0.1:${port}/readyz`, { signal: AbortSignal.timeout(500) }); + if (response.ok) { result = await response.json(); break; } + } catch { /* Vite is still starting. */ } + await delay(100); + } + assert.deepEqual(result, { ok: true, path: "/readyz" }, lines.join("\n")); + } finally { + await processes.stop(); + api.closeAllConnections(); + await new Promise(resolve => api.close(resolve)); + } +}); From bf2291f26a16ad9d1b87a211404b3eb6a91e5164 Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Thu, 22 Oct 2026 07:59:12 +0000 Subject: [PATCH 22/29] test: canonicalize temporary checkout paths on macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve /var versus /private/var before preparing state, matching the production launcher. Verified with a symlinked TMPDIR while retaining symlink refusal tests. Signed-off-by: 秦世为 <345942185@qq.com> --- scripts/tests/dev-stack.test.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs index c629a7657..6c327a485 100644 --- a/scripts/tests/dev-stack.test.mjs +++ b/scripts/tests/dev-stack.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { copyFile, mkdtemp, mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { copyFile, mkdtemp, mkdir, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { createServer } from "node:http"; @@ -9,7 +9,8 @@ import { configuration, prepareState, assertOwnedState, composeDocument, sourceE import { Processes, redact, waitForReady } from "../dev-stack/processes.mjs"; async function fixture(t) { - const root = await mkdtemp(join(tmpdir(), "space-dev-test-")); + // macOS exposes /var through /private/var; match the launcher's canonical root. + const root = await realpath(await mkdtemp(join(tmpdir(), "space-dev-test-"))); t.after(() => rm(root, { recursive: true, force: true })); await mkdir(join(root, "starter-memory")); for (const name of ["space_memory.md", "space_memory_monthly.md", "installation-guide.md"]) await writeFile(join(root, "starter-memory", name), "starter"); From bc12e2abadf421a720e59b7a2ab65c8c59c2a17c Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Thu, 22 Oct 2026 01:02:56 -0700 Subject: [PATCH 23/29] fix: use valid UUID for source stack terminal acceptance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use crypto.randomUUID() for the terminal clientId required by the API schema. Signed-off-by: 秦世为 <345942185@qq.com> --- scripts/dev-stack-smoke.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/dev-stack-smoke.mjs b/scripts/dev-stack-smoke.mjs index 6ea5808e8..eda95f606 100644 --- a/scripts/dev-stack-smoke.mjs +++ b/scripts/dev-stack-smoke.mjs @@ -2,7 +2,7 @@ // Real Docker/Postgres/Temporal/API/PTY acceptance. Never falls back to a mock. import assert from "node:assert/strict"; import { spawn, execFileSync } from "node:child_process"; -import { randomBytes } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; import { lstat, readFile, realpath } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -97,7 +97,7 @@ try { const pane = await request("/api/panes", { roomId: starter.room.id, title: "OpenCode acceptance", mode: "TERMINAL", terminalRuntimeId: "cli:opencode" }); const session = await request(`/api/panes/${encodeURIComponent(pane.id)}/cli/session`, { runtimeId: "cli:opencode" }); assert.ok(session.websocket?.token); - const query = new URLSearchParams({ sessionId: session.session.sessionId, token: session.websocket.token, clientId: "dev-stack-smoke", initialCols: "100", initialRows: "30" }); + const query = new URLSearchParams({ sessionId: session.session.sessionId, token: session.websocket.token, clientId: randomUUID(), initialCols: "100", initialRows: "30" }); socket = new WebSocket(`${api.replace("http:", "ws:")}/api/panes/${encodeURIComponent(pane.id)}/cli/terminal?${query}`, { headers: { cookie, origin: api } }); let terminal = ""; let terminalError; From 3541d69de0ba58f25a70dfb56e4392f282989afb Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Thu, 22 Oct 2026 08:12:31 +0000 Subject: [PATCH 24/29] fix(deps): patch Vite security advisories with 7.3.7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update only the Vite lock entry within the existing supported semver range. npm audit --audit-level=high passed (0 high/critical; 7 moderate remain). Both real Vite HTTP/WebSocket tests passed under Node 24.19.0. Full Node 22 CI remains required. Signed-off-by: 秦世为 <345942185@qq.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index bc825c1cf..23b07eaa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11001,9 +11001,9 @@ } }, "node_modules/vite": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", - "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", "dev": true, "license": "MIT", "dependencies": { From 5abb4c77d5f842c88261d012e0e48bc7f9c07ca5 Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Thu, 22 Oct 2026 01:54:16 -0700 Subject: [PATCH 25/29] fix(deps): patch Hono and sanitize-html security findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade Hono to 4.13.13 and sanitize-html to 2.18.1, resolving the four findings in the filesystem Trivy report from run 39746048018. Regenerate the dependency lock without weakening scan thresholds. Validation: npm ci --ignore-scripts passed; application mail sanitizer assertions and Hono request smoke passed on Node 24.19.0. Full workspace tests require built internal packages and Node 22 CI verification. Signed-off-by: 秦世为 <345942185@qq.com> --- package-lock.json | 105 +++++++++++++++++++++++++++------------------- package.json | 4 +- 2 files changed, 64 insertions(+), 45 deletions(-) diff --git a/package-lock.json b/package-lock.json index 23b07eaa7..26a51c037 100644 --- a/package-lock.json +++ b/package-lock.json @@ -104,24 +104,6 @@ "node": "^18 || >=20" } }, - "apps/api/node_modules/sanitize-html": { - "version": "2.17.7", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz", - "integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==", - "license": "MIT", - "dependencies": { - "deepmerge": "^4.2.2", - "escape-string-regexp": "^4.0.0", - "htmlparser2": "^12.0.0", - "is-plain-object": "^5.0.0", - "launder": "^1.7.1", - "parse-srcset": "^1.0.2", - "postcss": "^8.3.11" - }, - "engines": { - "node": ">=22.12.0" - } - }, "apps/web": { "name": "@space/web", "version": "0.2.5", @@ -5567,9 +5549,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.21", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", - "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", "license": "MIT" }, "node_modules/debug": { @@ -5616,15 +5598,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6945,9 +6918,9 @@ } }, "node_modules/hono": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", - "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -7271,9 +7244,9 @@ } }, "node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7502,12 +7475,12 @@ } }, "node_modules/launder": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", - "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.2.tgz", + "integrity": "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==", "license": "MIT", "dependencies": { - "dayjs": "^1.11.7" + "dayjs": "^1.11.23" } }, "node_modules/levn": { @@ -8880,9 +8853,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -9342,6 +9315,7 @@ "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -9946,6 +9920,51 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sanitize-html": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.1.tgz", + "integrity": "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^12.0.0", + "is-plain-object": "^5.1.0", + "launder": "^1.7.2", + "parse-srcset": "^1.0.2", + "postcss": "^8.5.27" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/sanitize-html/node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", diff --git a/package.json b/package.json index c3f244ec7..3614e8871 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,9 @@ "fast-json-stringify@7": { "fast-uri": "4.2.2" }, - "sanitize-html": "2.17.7", + "sanitize-html": "2.18.1", "@fastify/busboy": "3.2.2", - "hono": "4.13.7", + "hono": "4.13.13", "qs": "6.16.0", "proxy-addr": "2.0.8", "http-cache-semantics": "4.3.0", From 19414e382a870f3b880c955b03a3954d8a179b7c Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Thu, 22 Oct 2026 23:36:12 -0700 Subject: [PATCH 26/29] fix: prepare macOS PTY helper before source stack startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- scripts/dev-stack.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/dev-stack.mjs b/scripts/dev-stack.mjs index e2652f41c..50c551580 100644 --- a/scripts/dev-stack.mjs +++ b/scripts/dev-stack.mjs @@ -5,7 +5,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { once } from "node:events"; import { assertOwnedState, composeArgs, composeDocument, configuration, openCodePackage, prepareState, sourceEnvironment } from "./dev-stack/config.mjs"; -import { Processes, redact, startWeb, waitForReady } from "./dev-stack/processes.mjs"; +import { Processes, ensureMacNodePtySpawnHelper, redact, startWeb, waitForReady } from "./dev-stack/processes.mjs"; const root = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..")); const config = configuration(root); @@ -167,6 +167,7 @@ async function main() { console.log("[dev:stack] Building source packages and installing pinned OpenCode."); await build(processes); await prepareOpenCode(processes); + await ensureMacNodePtySpawnHelper(root); signal.throwIfAborted(); composeStarted = true; console.log("[dev:stack] Starting PostgreSQL and Temporal."); From ab8e14946931ac39aa137a5bc4b3c267ce69e5ce Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Thu, 22 Oct 2026 23:38:07 -0700 Subject: [PATCH 27/29] fix: repair macOS node-pty helper execute bit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- scripts/dev-stack/processes.mjs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/dev-stack/processes.mjs b/scripts/dev-stack/processes.mjs index 352d3eee7..8f0d5ddf4 100644 --- a/scripts/dev-stack/processes.mjs +++ b/scripts/dev-stack/processes.mjs @@ -2,6 +2,21 @@ import { spawn } from "node:child_process"; import { createInterface } from "node:readline"; import { setTimeout as delay } from "node:timers/promises"; import { join } from "node:path"; +import { chmod, lstat } from "node:fs/promises"; + +// node-pty 1.1.0 may install macOS spawn-helper without its execute bit +// (microsoft/node-pty#850). Repair the local dependency, not system files. +export async function ensureMacNodePtySpawnHelper(root, platform = process.platform, arch = process.arch) { + if (platform !== "darwin") return false; + const helper = join(root, "node_modules", "node-pty", "prebuilds", `darwin-${arch}`, "spawn-helper"); + const info = await lstat(helper); + if (!info.isFile() || info.isSymbolicLink()) { + throw new Error("Refusing invalid or symlinked node-pty spawn-helper."); + } + if ((info.mode & 0o111) !== 0) return false; + await chmod(helper, info.mode | 0o111); + return true; +} export function startWeb(processes, root, env) { // npm workspaces hoists Vite to the checkout root, while its working From 78539a0021505a30b01d3cc1c195d5781d38880e Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Thu, 22 Oct 2026 23:41:15 -0700 Subject: [PATCH 28/29] test: cover macOS PTY helper permission repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- scripts/tests/dev-stack.test.mjs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs index 6c327a485..41a1dba51 100644 --- a/scripts/tests/dev-stack.test.mjs +++ b/scripts/tests/dev-stack.test.mjs @@ -1,12 +1,12 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { copyFile, mkdtemp, mkdir, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { chmod, copyFile, mkdtemp, mkdir, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { createServer } from "node:http"; import { once } from "node:events"; import { configuration, prepareState, assertOwnedState, composeDocument, sourceEnvironment, openCodePackage } from "../dev-stack/config.mjs"; -import { Processes, redact, waitForReady } from "../dev-stack/processes.mjs"; +import { Processes, ensureMacNodePtySpawnHelper, redact, waitForReady } from "../dev-stack/processes.mjs"; async function fixture(t) { // macOS exposes /var through /private/var; match the launcher's canonical root. @@ -154,3 +154,21 @@ test("smoke cleanup resets resources even when the launcher has already failed", await assert.rejects(stat(config.state), { code: "ENOENT" }); } finally { await processes.stop(); } }); + + +test("macOS node-pty spawn-helper repairs only the checkout dependency execute bit", async (t) => { + const config = await fixture(t); + const dir = join(config.root, "node_modules", "node-pty", "prebuilds", "darwin-x64"); + const helper = join(dir, "spawn-helper"); + await mkdir(dir, { recursive: true }); + await writeFile(helper, "native helper fixture", { mode: 0o644 }); + await chmod(helper, 0o644); + assert.equal(await ensureMacNodePtySpawnHelper(config.root, "linux", "x64"), false); + assert.equal((await stat(helper)).mode & 0o111, 0); + assert.equal(await ensureMacNodePtySpawnHelper(config.root, "darwin", "x64"), true); + assert.equal((await stat(helper)).mode & 0o111, 0o111); + assert.equal(await ensureMacNodePtySpawnHelper(config.root, "darwin", "x64"), false); + await rm(helper); + await symlink(join(config.root, "starter-memory", "space_memory.md"), helper); + await assert.rejects(ensureMacNodePtySpawnHelper(config.root, "darwin", "x64"), /symlinked/); +}); From 3e8e8f6b138fbea6074205e5148a2b7a32e94f2b Mon Sep 17 00:00:00 2001 From: 345942185-eng <345942185@qq.com> Date: Thu, 22 Oct 2026 23:42:39 -0700 Subject: [PATCH 29/29] test: normalize CRLF in Codex mount assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦世为 <345942185@qq.com> --- packages/run-spaceapp/tests/codex-sessions-mount.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/run-spaceapp/tests/codex-sessions-mount.test.mjs b/packages/run-spaceapp/tests/codex-sessions-mount.test.mjs index 82c831f12..1a3c1bd1e 100644 --- a/packages/run-spaceapp/tests/codex-sessions-mount.test.mjs +++ b/packages/run-spaceapp/tests/codex-sessions-mount.test.mjs @@ -4,7 +4,8 @@ import { readFile } from "node:fs/promises"; // 1.1.7 (Nous decision 2026-10-18): the API reads Codex's session logs to see a Codex pane's turns. It gets only // Codex's sessions/ (its own volume, read-only), never the Codex home, auth.json or any other credential. -const template = await readFile(new URL("../templates/compose.yml", import.meta.url), "utf8"); +// Git for Windows checks out CRLF; regex checks below operate on LF-normalized YAML. +const template = (await readFile(new URL("../templates/compose.yml", import.meta.url), "utf8")).replace(/\r\n/g, "\n"); const credentialInstall = await readFile(new URL("../../../deploy/docker/credential-install.mjs", import.meta.url), "utf8"); const service = (name) => template.match(new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [a-z][a-z-]*:\\n|^[a-z])`, "m"))?.[1] ?? ""; const mounts = (block) => [...(block.match(/^ volumes:\n((?: (?:- |#).*\n)+)/m)?.[1] ?? "").matchAll(/^ - (\S+)\s*$/gm)].map((match) => match[1]);