From 2face4267a3ccd3844aefd83a8927343ac588306 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 15:54:08 +0900 Subject: [PATCH 01/71] feat(dev): add isolated source stack runner --- scripts/dev-stack.mjs | 559 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 559 insertions(+) create mode 100644 scripts/dev-stack.mjs diff --git a/scripts/dev-stack.mjs b/scripts/dev-stack.mjs new file mode 100644 index 000000000..8382bd050 --- /dev/null +++ b/scripts/dev-stack.mjs @@ -0,0 +1,559 @@ +#!/usr/bin/env node +import { createHash, randomBytes } from "node:crypto"; +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { + chmod, + lstat, + mkdir, + readFile, + readdir, + realpath, + rm, + stat, + writeFile +} from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptPath = fileURLToPath(import.meta.url); +export const repoRoot = resolve(dirname(scriptPath), ".."); +const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); + +function parsePort(raw, fallback, name) { + const value = raw == null || raw === "" ? fallback : Number(raw); + if (!Number.isInteger(value) || value < 1024 || value > 65535) { + throw new Error(`${name} must be an integer port between 1024 and 65535.`); + } + return value; +} + +function assertDistinctPorts(ports) { + const values = Object.entries(ports); + const seen = new Map(); + for (const [name, value] of values) { + if (seen.has(value)) throw new Error(`${name} conflicts with ${seen.get(value)} on port ${value}.`); + seen.set(value, name); + } + for (const [name, value] of [["api", ports.api], ["web", ports.web]]) { + if (value === 4910 || value === 4911) { + throw new Error(`${name} port ${value} is reserved for an installed SpaceApp; choose another dev port.`); + } + } +} + +function inside(parent, child) { + const rel = relative(parent, child); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +export async function assertSafeStateRoot(stateRoot, root = repoRoot) { + const resolvedState = resolve(stateRoot); + const resolvedRoot = resolve(root); + const resolvedHome = resolve(homedir()); + if (resolvedState === resolve(sep) || resolvedState === resolvedRoot || resolvedState === resolvedHome) { + throw new Error(`Unsafe dev-stack state directory: ${resolvedState}`); + } + if (inside(resolvedState, resolvedRoot) && resolvedState !== resolvedRoot) { + throw new Error("The dev-stack state directory may not be an ancestor of the repository."); + } + if (existsSync(resolvedState)) { + const info = await lstat(resolvedState); + if (info.isSymbolicLink()) throw new Error("The dev-stack state directory may not be a symlink."); + } + return resolvedState; +} + +export function parsePinnedImages(composeText) { + const postgres = composeText.match(/image:\s*(pgvector\/pgvector:[^\s]+@sha256:[a-f0-9]{64})/i)?.[1]; + const temporal = composeText.match(/image:\s*(temporalio\/auto-setup:[^\s]+@sha256:[a-f0-9]{64})/i)?.[1]; + if (!postgres || !temporal) throw new Error("Could not read digest-pinned PostgreSQL and Temporal images from the launcher Compose template."); + return { postgres, temporal }; +} + +export function parseOpenCodeVersion(dockerfileText) { + const version = dockerfileText.match(/opencode-ai@([0-9]+\.[0-9]+\.[0-9]+)/)?.[1]; + if (!version) throw new Error("Could not read the pinned opencode-ai version from Dockerfile."); + return version; +} + +export async function resolveDevStackConfig(env = process.env, root = repoRoot) { + const rootHash = createHash("sha256").update(await realpath(root)).digest("hex").slice(0, 10); + const stateRoot = await assertSafeStateRoot(env.SPACE_DEV_STACK_DIR || join(root, ".dev-stack"), root); + const ports = { + api: parsePort(env.SPACE_DEV_API_PORT, 5910, "SPACE_DEV_API_PORT"), + web: parsePort(env.SPACE_DEV_WEB_PORT, 5911, "SPACE_DEV_WEB_PORT"), + postgres: parsePort(env.SPACE_DEV_POSTGRES_PORT, 55432, "SPACE_DEV_POSTGRES_PORT"), + temporal: parsePort(env.SPACE_DEV_TEMPORAL_PORT, 57233, "SPACE_DEV_TEMPORAL_PORT") + }; + assertDistinctPorts(ports); + const project = env.SPACE_DEV_COMPOSE_PROJECT || `spaceapp-dev-${rootHash}`; + if (!/^[a-z0-9][a-z0-9_-]{2,50}$/i.test(project)) throw new Error("SPACE_DEV_COMPOSE_PROJECT contains unsupported characters."); + const preferredSocket = join(stateRoot, "run", "cli-host.sock"); + const socketPath = Buffer.byteLength(preferredSocket) <= 96 + ? preferredSocket + : join(tmpdir(), `spaceapp-dev-${rootHash}`, "cli-host.sock"); + return { + root, + rootHash, + stateRoot, + project, + ports, + composeFile: join(stateRoot, "compose.yml"), + configFile: join(stateRoot, "config.json"), + pidFile: join(stateRoot, "supervisor.pid"), + buildMarker: join(stateRoot, "build-marker.json"), + secretsDir: join(stateRoot, "secrets"), + socketPath, + socketRoot: dirname(socketPath), + binDir: join(stateRoot, "bin"), + vendorDir: join(stateRoot, "vendor", "opencode"), + providerRoot: join(stateRoot, "providers", "opencode"), + workspacesRoot: join(stateRoot, "workspaces"), + memoryRoot: join(stateRoot, "memory"), + memoryGraphRoot: join(stateRoot, "memory-graph"), + artifactRoot: join(stateRoot, "artifacts"), + projectsRoot: join(stateRoot, "projects"), + diagnosticsRoot: join(stateRoot, "diagnostics"), + openCodeChatRoot: join(stateRoot, "opencode-chat") + }; +} + +export function renderCompose(config, images) { + return `services:\n postgres:\n image: ${images.postgres}\n restart: unless-stopped\n environment:\n POSTGRES_DB: spaceapp_dev\n POSTGRES_USER: spaceapp\n POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password\n ports:\n - \"127.0.0.1:${config.ports.postgres}:5432\"\n secrets:\n - postgres-password\n volumes:\n - postgres-data:/var/lib/postgresql/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U spaceapp -d spaceapp_dev\"]\n interval: 2s\n timeout: 3s\n retries: 30\n\n temporal:\n image: ${images.temporal}\n restart: unless-stopped\n depends_on:\n postgres:\n condition: service_healthy\n environment:\n DB: postgres12\n DB_PORT: 5432\n POSTGRES_USER: spaceapp\n POSTGRES_SEEDS: postgres\n DYNAMIC_CONFIG_FILE_PATH: config/dynamicconfig/docker.yaml\n entrypoint: [\"/bin/sh\", \"-ec\"]\n command:\n - export POSTGRES_PWD=\"$$(cat /run/secrets/postgres-password)\"; exec /etc/temporal/entrypoint.sh autosetup\n ports:\n - \"127.0.0.1:${config.ports.temporal}:7233\"\n secrets:\n - postgres-password\n healthcheck:\n test: [\"CMD-SHELL\", \"temporal operator cluster health --address temporal:7233 >/dev/null && temporal operator namespace describe --namespace default --address temporal:7233 >/dev/null\"]\n interval: 3s\n timeout: 5s\n retries: 40\n\nsecrets:\n postgres-password:\n file: ./secrets/postgres-password\n\nvolumes:\n postgres-data:\n`; +} + +async function run(command, args, options = {}) { + const { capture = false, allowFailure = false, env = process.env, cwd = repoRoot } = options; + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { + cwd, + env, + stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit" + }); + let stdout = ""; + let stderr = ""; + if (capture) { + child.stdout.on("data", (chunk) => { stdout += chunk.toString(); }); + child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); + } + child.once("error", rejectPromise); + child.once("exit", (code, signal) => { + const result = { code: code ?? 1, signal, stdout: stdout.trim(), stderr: stderr.trim() }; + if (!allowFailure && code !== 0) { + rejectPromise(new Error(`${command} ${args.join(" ")} failed (${code ?? signal}).${stderr ? `\n${stderr}` : ""}`)); + } else { + resolvePromise(result); + } + }); + }); +} + +function composeArgs(config, ...args) { + return ["compose", "-p", config.project, "-f", config.composeFile, ...args]; +} + +async function compose(config, args, options = {}) { + return run("docker", composeArgs(config, ...args), options); +} + +async function privateFile(path, value) { + if (!existsSync(path)) await writeFile(path, `${value}\n`, { mode: 0o600, flag: "wx" }); + await chmod(path, 0o600); +} + +async function prepareState(config) { + for (const path of [ + config.stateRoot, config.secretsDir, config.socketRoot, config.binDir, config.vendorDir, + config.providerRoot, config.workspacesRoot, config.memoryRoot, config.memoryGraphRoot, + config.artifactRoot, config.projectsRoot, config.diagnosticsRoot, config.openCodeChatRoot + ]) await mkdir(path, { recursive: true, mode: 0o700 }); + const postgresPassword = randomBytes(24).toString("base64url"); + await privateFile(join(config.secretsDir, "postgres-password"), postgresPassword); + await privateFile(join(config.secretsDir, "session-secret"), randomBytes(32).toString("hex")); + await privateFile(join(config.secretsDir, "setup-token"), randomBytes(32).toString("hex")); + const storedPassword = (await readFile(join(config.secretsDir, "postgres-password"), "utf8")).trim(); + const databaseUrl = `postgresql://spaceapp:${encodeURIComponent(storedPassword)}@127.0.0.1:${config.ports.postgres}/spaceapp_dev`; + await writeFile(join(config.secretsDir, "database-url"), `${databaseUrl}\n`, { mode: 0o600 }); + await chmod(join(config.secretsDir, "database-url"), 0o600); + return databaseUrl; +} + +async function newestMtime(path) { + if (!existsSync(path)) return 0; + const info = await stat(path); + if (!info.isDirectory()) return info.mtimeMs; + let newest = info.mtimeMs; + for (const entry of await readdir(path, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage") continue; + newest = Math.max(newest, await newestMtime(join(path, entry.name))); + } + return newest; +} + +async function ensureBuild(config, force = false) { + if (process.env.SPACE_DEV_SKIP_BUILD === "true" && !force) return; + const keyArtifacts = [ + "packages/contracts/dist/index.js", + "packages/runtime/dist/index.js", + "packages/db/dist/migrate.js", + "packages/cli-host/dist/main.js", + "apps/worker/dist/worker.js" + ].map((path) => join(config.root, path)); + const marker = existsSync(config.buildMarker) ? JSON.parse(await readFile(config.buildMarker, "utf8")) : null; + const sourceMtime = Math.max( + await newestMtime(join(config.root, "packages")), + await newestMtime(join(config.root, "apps", "worker", "src")) + ); + const needsBuild = force || process.env.SPACE_DEV_FORCE_BUILD === "true" || !marker || + keyArtifacts.some((path) => !existsSync(path)) || sourceMtime > Number(marker.sourceMtime || 0); + if (!needsBuild) return; + console.log("[dev:stack] Building source workspaces before migrations..."); + await run("npm", ["run", "build"], { cwd: config.root }); + await writeFile(config.buildMarker, `${JSON.stringify({ sourceMtime, builtAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 }); +} + +async function installOpenCode(config, version) { + const packageJson = join(config.vendorDir, "node_modules", "opencode-ai", "package.json"); + let installedVersion = null; + if (existsSync(packageJson)) { + try { installedVersion = JSON.parse(await readFile(packageJson, "utf8")).version; } catch {} + } + if (installedVersion !== version) { + console.log(`[dev:stack] Installing pinned opencode-ai@${version} inside the dev state directory...`); + await rm(config.vendorDir, { recursive: true, force: true }); + await mkdir(config.vendorDir, { recursive: true, mode: 0o700 }); + await run("npm", ["install", "--prefix", config.vendorDir, "--no-package-lock", "--no-save", "--no-audit", "--no-fund", `opencode-ai@${version}`]); + } + const wrapper = join(config.binDir, "opencode-vscode-parity"); + const binary = join(config.vendorDir, "node_modules", ".bin", "opencode"); + const wrapperText = `#!/bin/sh\nset -eu\nexport HOME=${JSON.stringify(join(config.providerRoot, "home"))}\nexport XDG_CONFIG_HOME=${JSON.stringify(join(config.providerRoot, "config"))}\nexport XDG_DATA_HOME=${JSON.stringify(join(config.providerRoot, "data"))}\nexport XDG_CACHE_HOME=${JSON.stringify(join(config.providerRoot, "cache"))}\nexec ${JSON.stringify(binary)} \"$@\"\n`; + await writeFile(wrapper, wrapperText, { mode: 0o700 }); + await chmod(wrapper, 0o700); + return wrapper; +} + +async function waitContainerHealthy(config, service, timeoutMs = 120_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const id = (await compose(config, ["ps", "-q", service], { capture: true })).stdout; + if (id) { + const statusResult = await run("docker", ["inspect", "--format", "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}", id], { capture: true, allowFailure: true }); + if (statusResult.stdout === "healthy") return; + if (statusResult.stdout === "unhealthy" || statusResult.stdout === "exited" || statusResult.stdout === "dead") { + throw new Error(`${service} became ${statusResult.stdout} during dev-stack startup.`); + } + } + await sleep(1000); + } + throw new Error(`${service} did not become healthy within ${Math.round(timeoutMs / 1000)} seconds.`); +} + +async function ensureTemporalNamespace(config) { + const describe = await compose(config, ["exec", "-T", "temporal", "temporal", "operator", "namespace", "describe", "--namespace", "spaceapp-dev", "--address", "temporal:7233"], { capture: true, allowFailure: true }); + if (describe.code === 0) return; + await compose(config, ["exec", "-T", "temporal", "temporal", "operator", "namespace", "create", "--namespace", "spaceapp-dev", "--address", "temporal:7233"]); +} + +function sanitizedBaseEnv() { + const env = { ...process.env }; + for (const key of [ + "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "GROK_API_KEY", + "DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GITHUB_TOKEN", "GH_TOKEN" + ]) delete env[key]; + return env; +} + +function sourceEnv(config, databaseUrl, openCodeWrapper) { + return { + ...sanitizedBaseEnv(), + SPACE_INSTANCE: "dev", + SPACE_INSTALL_ROOT: config.root, + SPACE_RUNTIME_STORE: "postgres", + SPACE_DATABASE_URL: databaseUrl, + SPACE_DATABASE_URL_FILE: join(config.secretsDir, "database-url"), + SPACE_SESSION_SECRET_FILE: join(config.secretsDir, "session-secret"), + SPACE_SETUP_TOKEN_FILE: join(config.secretsDir, "setup-token"), + SPACE_DEV_LOGIN: "false", + SPACE_GOOGLE_AUTH_DISABLED: "true", + SPACE_PUBLIC_DISTRIBUTION: "true", + SPACE_OPTIONAL_MODULES: "none", + SPACEAPP_WORKFLOWS_ENABLED: "true", + SPACE_API_HOST: "127.0.0.1", + SPACE_API_PORT: String(config.ports.api), + SPACE_WEB_HOST: "127.0.0.1", + SPACE_WEB_PORT: String(config.ports.web), + SPACE_API_ORIGIN: `http://127.0.0.1:${config.ports.api}`, + SPACE_TEMPORAL_ADDRESS: `127.0.0.1:${config.ports.temporal}`, + SPACE_TEMPORAL_NAMESPACE: "spaceapp-dev", + SPACE_TEMPORAL_TASK_QUEUE: "space-dev-agent-turns", + SPACE_CLI_ENABLED: "true", + SPACE_CLI_HOST_SOCKET: config.socketPath, + SPACE_CLI_COMMAND_PATH: config.binDir, + SPACE_CLI_OPENCODE_COMMAND: openCodeWrapper, + SPACE_CLI_WORKSPACE_ROOT: config.workspacesRoot, + SPACE_CLI_ROOT_ENABLED: "false", + SPACE_BROWSER_SESSIONS_ENABLED: "false", + SPACE_BROWSER_HOST_TRANSPORT: "in-process", + SPACE_MEMORY_ROOT: config.memoryRoot, + SPACE_MEMORY_GRAPH_ROOT: config.memoryGraphRoot, + SPACE_ARTIFACT_ROOT: config.artifactRoot, + SPACE_PROJECTS_ROOT: config.projectsRoot, + SPACE_APP_DIAGNOSTICS_ROOT: config.diagnosticsRoot, + SPACE_OPENCODE_CHAT_STATE_ROOT: config.openCodeChatRoot, + SPACE_ENABLE_DUMMY_TURNS: "false", + SPACE_ROOM_AGENT_AUTOPILOT_ENABLED: "false" + }; +} + +function prefixStream(stream, label, target) { + let pending = ""; + stream.on("data", (chunk) => { + pending += chunk.toString(); + const lines = pending.split(/\r?\n/); + pending = lines.pop() || ""; + for (const line of lines) target.write(`[${label}] ${line}\n`); + }); + stream.on("end", () => { if (pending) target.write(`[${label}] ${pending}\n`); }); +} + +function spawnService(config, name, command, args, env, onUnexpectedExit) { + const child = spawn(command, args, { + cwd: config.root, + env, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"] + }); + prefixStream(child.stdout, name, process.stdout); + prefixStream(child.stderr, name, process.stderr); + child.once("exit", (code, signal) => onUnexpectedExit(name, child, code, signal)); + return child; +} + +function terminateTree(child, signal = "SIGTERM") { + if (!child || child.exitCode != null) return; + try { + if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal); + else child.kill(signal); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } +} + +async function waitDead(child, timeoutMs = 7000) { + if (!child || child.exitCode != null) return; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline && child.exitCode == null) await sleep(100); + if (child.exitCode == null) terminateTree(child, "SIGKILL"); +} + +async function waitReady(config, timeoutMs = 120_000) { + const deadline = Date.now() + timeoutMs; + const url = `http://127.0.0.1:${config.ports.api}/readyz`; + while (Date.now() < deadline) { + try { + const response = await fetch(url); + if (response.ok) { + const body = await response.json(); + if (body.ok === true && body.dependencies?.database === "RUNNING" && body.dependencies?.worker === "RUNNING" && body.dependencies?.cliHost === "RUNNING") return body; + } + } catch {} + await sleep(500); + } + throw new Error("The source stack did not reach /readyz with database, worker and CLI host RUNNING."); +} + +function pidAlive(pid) { + if (!Number.isInteger(pid) || pid <= 1) return false; + try { process.kill(pid, 0); return true; } + catch (error) { if (error?.code === "ESRCH") return false; throw error; } +} + +async function readSupervisorPid(config) { + if (!existsSync(config.pidFile)) return null; + const value = Number.parseInt((await readFile(config.pidFile, "utf8")).trim(), 10); + return Number.isInteger(value) ? value : null; +} + +async function waitSupervisorExit(pid, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline && pidAlive(pid)) await sleep(100); + if (pidAlive(pid)) throw new Error(`Dev-stack supervisor ${pid} did not stop cleanly.`); +} + +async function remainingDockerResources(config) { + const containers = await run("docker", ["ps", "-aq", "--filter", `label=com.docker.compose.project=${config.project}`], { capture: true, allowFailure: true }); + const volumes = await run("docker", ["volume", "ls", "-q", "--filter", `label=com.docker.compose.project=${config.project}`], { capture: true, allowFailure: true }); + return { containers: containers.stdout.split(/\s+/).filter(Boolean), volumes: volumes.stdout.split(/\s+/).filter(Boolean) }; +} + +export async function down(config) { + const pid = await readSupervisorPid(config); + if (pid && pid !== process.pid && pidAlive(pid)) { + process.kill(pid, "SIGTERM"); + await waitSupervisorExit(pid); + } + if (existsSync(config.composeFile)) await compose(config, ["down", "--remove-orphans"]); +} + +export async function reset(config) { + await down(config); + if (existsSync(config.composeFile)) { + await compose(config, ["down", "-v", "--remove-orphans"]); + } + const remaining = await remainingDockerResources(config); + if (remaining.containers.length || remaining.volumes.length) { + throw new Error(`Reset left owned Docker resources behind; state preserved for retry: ${JSON.stringify(remaining)}`); + } + if (config.socketRoot.startsWith(tmpdir()) && config.socketRoot !== tmpdir()) await rm(config.socketRoot, { recursive: true, force: true }); + await rm(config.stateRoot, { recursive: true, force: true }); + console.log("[dev:stack] Reset complete; owned containers, volumes and state were removed."); +} + +export async function status(config) { + const pid = await readSupervisorPid(config); + const resources = await remainingDockerResources(config); + const payload = { + running: Boolean(pid && pidAlive(pid)), + supervisorPid: pid, + project: config.project, + stateRoot: config.stateRoot, + ports: config.ports, + docker: resources + }; + console.log(JSON.stringify(payload, null, 2)); + return payload; +} + +async function writePublicConfig(config, openCodeVersion) { + await writeFile(config.configFile, `${JSON.stringify({ + root: config.root, + stateRoot: config.stateRoot, + project: config.project, + ports: config.ports, + socketPath: config.socketPath, + openCodeVersion + }, null, 2)}\n`, { mode: 0o600 }); +} + +export async function start(config) { + const existingPid = await readSupervisorPid(config); + if (existingPid && pidAlive(existingPid)) throw new Error(`Dev stack is already running under supervisor ${existingPid}.`); + + const composeTemplate = await readFile(join(config.root, "packages", "run-spaceapp", "templates", "compose.yml"), "utf8"); + const dockerfile = await readFile(join(config.root, "Dockerfile"), "utf8"); + const images = parsePinnedImages(composeTemplate); + const openCodeVersion = parseOpenCodeVersion(dockerfile); + const databaseUrl = await prepareState(config); + await writeFile(config.composeFile, renderCompose(config, images), { mode: 0o600 }); + await writePublicConfig(config, openCodeVersion); + await run("docker", ["compose", "version"]); + + let children = new Map(); + let stopping = false; + let reloadInFlight = Promise.resolve(); + const stopChildren = async () => { + for (const child of children.values()) terminateTree(child); + await Promise.all([...children.values()].map((child) => waitDead(child))); + children = new Map(); + try { await rm(config.socketPath, { force: true }); } catch {} + }; + const fail = (name, child, code, signal) => { + if (stopping || child !== children.get(name)) return; + console.error(`[dev:stack] ${name} exited unexpectedly (${code ?? signal}); stopping the stack.`); + process.exitCode = 1; + void shutdown("child-failure"); + }; + const spawnChildren = async (forceBuild = false) => { + await ensureBuild(config, forceBuild); + const openCodeWrapper = await installOpenCode(config, openCodeVersion); + const env = sourceEnv(config, databaseUrl, openCodeWrapper); + children.set("cli-host", spawnService(config, "cli-host", process.execPath, ["packages/cli-host/dist/main.js"], env, fail)); + children.set("worker", spawnService(config, "worker", process.execPath, ["apps/worker/dist/worker.js"], env, fail)); + children.set("api", spawnService(config, "api", "npm", ["exec", "--", "tsx", "watch", "apps/api/src/server.ts"], env, fail)); + children.set("web", spawnService(config, "web", "npm", ["run", "dev", "-w", "@space/web", "--", "--host", "127.0.0.1", "--port", String(config.ports.web)], env, fail)); + }; + const reload = () => { + reloadInFlight = reloadInFlight.then(async () => { + if (stopping) return; + console.log("[dev:stack] Rebuilding source workspaces and restarting source services..."); + await stopChildren(); + await spawnChildren(true); + await waitReady(config); + console.log("[dev:stack] Rebuild complete; source services are ready."); + }).catch((error) => { + console.error(`[dev:stack] rebuild failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + void shutdown("rebuild-failure"); + }); + }; + const shutdown = async (reason) => { + if (stopping) return; + stopping = true; + console.log(`[dev:stack] Stopping (${reason})...`); + await stopChildren(); + if (existsSync(config.composeFile)) await compose(config, ["down", "--remove-orphans"], { allowFailure: true }); + try { await rm(config.pidFile, { force: true }); } catch {} + }; + + process.once("SIGINT", () => { void shutdown("SIGINT").finally(() => process.exit(process.exitCode || 0)); }); + process.once("SIGTERM", () => { void shutdown("SIGTERM").finally(() => process.exit(process.exitCode || 0)); }); + process.on("SIGHUP", reload); + + await writeFile(config.pidFile, `${process.pid}\n`, { mode: 0o600 }); + try { + console.log(`[dev:stack] Starting digest-pinned PostgreSQL and Temporal (${config.project})...`); + await compose(config, ["up", "-d", "postgres", "temporal"]); + await Promise.all([waitContainerHealthy(config, "postgres"), waitContainerHealthy(config, "temporal")]); + await ensureTemporalNamespace(config); + await ensureBuild(config); + console.log("[dev:stack] Applying database migrations..."); + await run("npm", ["run", "db:migrate"], { cwd: config.root, env: { ...sanitizedBaseEnv(), SPACE_DATABASE_URL: databaseUrl } }); + await spawnChildren(false); + await waitReady(config); + const setupToken = (await readFile(join(config.secretsDir, "setup-token"), "utf8")).trim(); + console.log("\n============================================================"); + console.log("SpaceApp dev stack is READY"); + console.log(`Web: http://127.0.0.1:${config.ports.web}`); + console.log(`API: http://127.0.0.1:${config.ports.api}`); + console.log(`Owner setup token: ${setupToken}`); + console.log("Package changes: npm run dev:stack -- rebuild"); + console.log("Stop: npm run dev:stack -- down | Reset: npm run dev:stack -- reset"); + console.log("============================================================\n"); + await new Promise(() => {}); + } catch (error) { + process.exitCode = 1; + await shutdown("startup-failure"); + throw error; + } +} + +export async function rebuild(config) { + const pid = await readSupervisorPid(config); + if (!pid || !pidAlive(pid)) { + await ensureBuild(config, true); + console.log("[dev:stack] Rebuilt source workspaces; no running supervisor to restart."); + return; + } + process.kill(pid, "SIGHUP"); + console.log(`[dev:stack] Rebuild requested from supervisor ${pid}.`); +} + +async function main() { + const command = process.argv[2] || "up"; + const config = await resolveDevStackConfig(); + if (command === "up" || command === "start") return start(config); + if (command === "down" || command === "stop") return down(config); + if (command === "reset") return reset(config); + if (command === "status") return status(config); + if (command === "rebuild") return rebuild(config); + throw new Error(`Unknown dev:stack command ${JSON.stringify(command)}. Use up, down, reset, status, or rebuild.`); +} + +if (process.argv[1] && resolve(process.argv[1]) === scriptPath) { + main().catch((error) => { + process.stderr.write(`[dev:stack] ERROR: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} From a00b3503d9a307a1be0562a0798d6abdbd1522dd Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 15:54:53 +0900 Subject: [PATCH 02/71] test(dev): add real source-stack acceptance proof --- scripts/dev-stack-verify.mjs | 112 +++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 scripts/dev-stack-verify.mjs diff --git a/scripts/dev-stack-verify.mjs b/scripts/dev-stack-verify.mjs new file mode 100644 index 000000000..eca1178d0 --- /dev/null +++ b/scripts/dev-stack-verify.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptPath = fileURLToPath(import.meta.url); +const root = resolve(dirname(scriptPath), ".."); +const stateRoot = resolve(process.env.SPACE_DEV_STACK_DIR || join(root, ".dev-stack")); +const config = JSON.parse(await readFile(join(stateRoot, "config.json"), "utf8")); +const api = `http://127.0.0.1:${config.ports.api}`; +const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); + +function setCookieHeader(response) { + const values = typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : []; + return values[0] || response.headers.get("set-cookie") || ""; +} + +function cookiePair(raw) { + return raw.split(";", 1)[0] || ""; +} + +async function jsonRequest(path, { method = "GET", body, cookie, csrf } = {}) { + const headers = { accept: "application/json" }; + if (body !== undefined) headers["content-type"] = "application/json"; + if (cookie) headers.cookie = cookie; + if (csrf) headers["x-space-csrf-token"] = csrf; + const response = await fetch(`${api}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body) + }); + const text = await response.text(); + let payload = null; + try { payload = text ? JSON.parse(text) : null; } catch { payload = text; } + if (!response.ok) throw new Error(`${method} ${path} failed (${response.status}): ${text.slice(0, 800)}`); + return { response, payload }; +} + +async function waitReady() { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + try { + const { payload } = await jsonRequest("/readyz"); + if (payload?.ok === true && payload.dependencies?.database === "RUNNING" && payload.dependencies?.worker === "RUNNING" && payload.dependencies?.cliHost === "RUNNING") return payload; + } catch {} + await sleep(500); + } + throw new Error("Timed out waiting for the real source stack to become ready."); +} + +const ready = await waitReady(); +console.log(`[dev-stack-verify] PASS readiness database=${ready.dependencies.database} worker=${ready.dependencies.worker} cliHost=${ready.dependencies.cliHost}`); + +const setupToken = (await readFile(join(stateRoot, "secrets", "setup-token"), "utf8")).trim(); +const email = "dev-stack-ci@example.com"; +const password = "dev-stack-ci-password-42!"; +const claimed = await jsonRequest("/api/setup/claim", { + method: "POST", + body: { token: setupToken, email, password } +}); +const cookie = cookiePair(setCookieHeader(claimed.response)); +if (!cookie) throw new Error("Owner setup succeeded without returning a session cookie."); +const starterRoomId = claimed.payload?.starterRoomId; +if (typeof starterRoomId !== "string" || !starterRoomId.startsWith("room:")) throw new Error("Owner setup did not return a starter room id."); +console.log(`[dev-stack-verify] PASS owner setup starterRoom=${starterRoomId}`); + +const csrfResponse = await jsonRequest("/api/auth/csrf", { cookie }); +const csrf = csrfResponse.payload?.csrfToken; +if (typeof csrf !== "string" || !csrf) throw new Error("Authenticated owner session did not return a CSRF token."); + +await jsonRequest(`/api/rooms/${encodeURIComponent(starterRoomId)}`, { + method: "PATCH", + cookie, + csrf, + body: { projectPath: root } +}); +console.log("[dev-stack-verify] PASS starter room uses the source checkout instead of /opt/spaceapp"); + +const panesResponse = await jsonRequest(`/api/panes?roomId=${encodeURIComponent(starterRoomId)}`, { cookie }); +const panesPayload = panesResponse.payload; +const panes = Array.isArray(panesPayload) ? panesPayload : (panesPayload?.data ?? panesPayload?.panes ?? []); +const openCodePane = panes.find((pane) => pane?.terminalRuntimeId === "cli:opencode" || (pane?.mode === "TERMINAL" && /OpenCode/i.test(pane?.title || ""))); +if (!openCodePane?.id) throw new Error("Getting Started does not contain an OpenCode terminal pane."); + +const started = await jsonRequest(`/api/panes/${encodeURIComponent(openCodePane.id)}/cli/session`, { + method: "POST", + cookie, + csrf, + body: { runtimeId: "cli:opencode" } +}); +const initialStatus = started.payload?.session?.status ?? started.payload?.status ?? null; +console.log(`[dev-stack-verify] OpenCode start returned status=${initialStatus ?? "unknown"}`); + +const deadline = Date.now() + 60_000; +let session = null; +while (Date.now() < deadline) { + const current = await jsonRequest(`/api/panes/${encodeURIComponent(openCodePane.id)}/cli/session`, { cookie }); + session = current.payload?.session ?? current.payload; + if (["RUNNING", "READY", "ATTACHED"].includes(session?.status)) break; + if (["ERROR", "EXITED", "FAILED"].includes(session?.status)) { + throw new Error(`OpenCode pane entered ${session.status}: ${session.statusReason || "no reason"}`); + } + await sleep(500); +} +if (!session || !["RUNNING", "READY", "ATTACHED"].includes(session.status)) { + throw new Error(`OpenCode pane did not reach an active status; last status=${session?.status ?? "missing"}.`); +} +console.log(`[dev-stack-verify] PASS OpenCode pane active status=${session.status}`); + +const finalReady = await jsonRequest("/readyz"); +if (finalReady.payload?.ok !== true) throw new Error("Stack lost readiness after starting the OpenCode pane."); +console.log("[dev-stack-verify] PASS source-stack acceptance"); From 4610e1d3e9ab46297dc6cfef51ce5aa14a714e04 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 15:55:04 +0900 Subject: [PATCH 03/71] test(dev): cover source stack isolation contracts --- scripts/tests/dev-stack.test.mjs | 66 ++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 scripts/tests/dev-stack.test.mjs diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs new file mode 100644 index 000000000..4d139fe1d --- /dev/null +++ b/scripts/tests/dev-stack.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + assertSafeStateRoot, + parseOpenCodeVersion, + parsePinnedImages, + renderCompose, + resolveDevStackConfig +} from "../dev-stack.mjs"; + +test("dev stack derives third-party pins from canonical source files", () => { + const images = parsePinnedImages(` +services: + postgres: + image: pgvector/pgvector:0.8.3-pg17-trixie@sha256:${"a".repeat(64)} + temporal: + image: temporalio/auto-setup:1.29.1@sha256:${"b".repeat(64)} +`); + assert.equal(images.postgres, `pgvector/pgvector:0.8.3-pg17-trixie@sha256:${"a".repeat(64)}`); + assert.equal(images.temporal, `temporalio/auto-setup:1.29.1@sha256:${"b".repeat(64)}`); + assert.equal(parseOpenCodeVersion("RUN npm install --global opencode-ai@1.18.4"), "1.18.4"); + assert.throws(() => parsePinnedImages("services: {}"), /digest-pinned/); + assert.throws(() => parseOpenCodeVersion("RUN npm install opencode-ai@latest"), /pinned/); +}); + +test("dev stack defaults are isolated from installed SpaceApp and bind Docker ports to loopback", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-dev-test-")); + try { + const config = await resolveDevStackConfig({}, root); + assert.notEqual(config.ports.api, 4910); + assert.notEqual(config.ports.web, 4911); + assert.match(config.project, /^spaceapp-dev-[a-f0-9]{10}$/); + const compose = renderCompose(config, { + postgres: `pgvector/pgvector:0.8.3@sha256:${"c".repeat(64)}`, + temporal: `temporalio/auto-setup:1.29.1@sha256:${"d".repeat(64)}` + }); + for (const port of [config.ports.postgres, config.ports.temporal]) { + assert.match(compose, new RegExp(`127\\.0\\.0\\.1:${port}:`)); + } + assert.match(compose, /POSTGRES_PASSWORD_FILE: \/run\/secrets\/postgres-password/); + assert.match(compose, /condition: service_healthy/); + assert.match(compose, /temporal operator cluster health --address temporal:7233/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("dev stack rejects unsafe state roots and installed-instance ports", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-dev-root-")); + try { + await assert.rejects(() => assertSafeStateRoot(root, root), /Unsafe dev-stack state directory/); + await assert.rejects( + () => resolveDevStackConfig({ SPACE_DEV_API_PORT: "4910" }, root), + /reserved for an installed SpaceApp/ + ); + await assert.rejects( + () => resolveDevStackConfig({ SPACE_DEV_API_PORT: "6000", SPACE_DEV_WEB_PORT: "6000" }, root), + /conflicts/ + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From d53576cad1933cc59cb321771788d59769c06b79 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 15:55:26 +0900 Subject: [PATCH 04/71] chore(dev): wire source stack scripts --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index b488fbbcb..ce03c14af 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,9 @@ "dev:api": "npm run dev -w @space/api", "dev:web": "npm run dev -w @space/web", "dev:worker": "npm run dev -w @space/worker", + "dev:stack": "node scripts/dev-stack.mjs", + "verify:dev-stack": "node scripts/dev-stack-verify.mjs", + "test:dev-stack": "node --test scripts/tests/dev-stack.test.mjs", "db:migrate": "npm run migrate -w @space/db", "build": "npm run build -w @space/contracts && npm run build -w @space/icons && npm run build -w @space/streaming && npm run build -w @space/opencode-control && npm run build -w @space/memory-graph && npm run build -w @space/browser-host && npm run build -w @space/cli-host && npm run build -w @space/codex-app-server && npm run build -w @space/runtime && npm run build -w @space/db && npm run build -w @space/gateway && node scripts/build-demo-projects.mjs && npm run build -w @space/api && npm run build -w @space/worker && npm run build -w @space/web && node scripts/fix-build-permissions.mjs", "public:export:verify": "node scripts/public-export.mjs --verify", From c5e69b1264f6e56770b3963567e7b31320411980 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 15:56:54 +0900 Subject: [PATCH 05/71] feat(dev): add source stack lifecycle helpers --- deploy/docker/dev-stack-lib.mjs | 274 ++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 deploy/docker/dev-stack-lib.mjs diff --git a/deploy/docker/dev-stack-lib.mjs b/deploy/docker/dev-stack-lib.mjs new file mode 100644 index 000000000..bf63782e1 --- /dev/null +++ b/deploy/docker/dev-stack-lib.mjs @@ -0,0 +1,274 @@ +import { createHash, randomBytes } from "node:crypto"; +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { chmod, lstat, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +export const repoRoot = resolve(here, "../.."); +export const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); + +function parsePort(raw, fallback, name) { + const value = raw == null || raw === "" ? fallback : Number(raw); + if (!Number.isInteger(value) || value < 1024 || value > 65535) { + throw new Error(`${name} must be an integer port between 1024 and 65535.`); + } + return value; +} + +function inside(parent, child) { + const rel = relative(parent, child); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +export async function assertSafeStateRoot(stateRoot, root = repoRoot) { + const value = resolve(stateRoot); + const repo = resolve(root); + const home = resolve(homedir()); + if (value === resolve(sep) || value === repo || value === home) throw new Error(`Unsafe dev-stack state directory: ${value}`); + if (inside(value, repo) && value !== repo) throw new Error("The dev-stack state directory may not be an ancestor of the repository."); + if (existsSync(value) && (await lstat(value)).isSymbolicLink()) throw new Error("The dev-stack state directory may not be a symlink."); + return value; +} + +export function parsePinnedImages(text) { + const postgres = text.match(/image:\s*(pgvector\/pgvector:[^\s]+@sha256:[a-f0-9]{64})/i)?.[1]; + const temporal = text.match(/image:\s*(temporalio\/auto-setup:[^\s]+@sha256:[a-f0-9]{64})/i)?.[1]; + if (!postgres || !temporal) throw new Error("Could not read digest-pinned PostgreSQL and Temporal images from the launcher Compose template."); + return { postgres, temporal }; +} + +export function parseOpenCodeVersion(text) { + const version = text.match(/opencode-ai@([0-9]+\.[0-9]+\.[0-9]+)/)?.[1]; + if (!version) throw new Error("Could not read the pinned opencode-ai version from Dockerfile."); + return version; +} + +export async function resolveConfig(env = process.env, root = repoRoot) { + const rootHash = createHash("sha256").update(await realpath(root)).digest("hex").slice(0, 10); + const stateRoot = await assertSafeStateRoot(env.SPACE_DEV_STACK_DIR || join(root, ".dev-stack"), root); + const ports = { + api: parsePort(env.SPACE_DEV_API_PORT, 5910, "SPACE_DEV_API_PORT"), + web: parsePort(env.SPACE_DEV_WEB_PORT, 5911, "SPACE_DEV_WEB_PORT"), + postgres: parsePort(env.SPACE_DEV_POSTGRES_PORT, 55432, "SPACE_DEV_POSTGRES_PORT"), + temporal: parsePort(env.SPACE_DEV_TEMPORAL_PORT, 57233, "SPACE_DEV_TEMPORAL_PORT") + }; + const seen = new Map(); + for (const [name, value] of Object.entries(ports)) { + if (seen.has(value)) throw new Error(`${name} conflicts with ${seen.get(value)} on port ${value}.`); + seen.set(value, name); + } + for (const [name, value] of [["api", ports.api], ["web", ports.web]]) { + if (value === 4910 || value === 4911) throw new Error(`${name} port ${value} is reserved for an installed SpaceApp.`); + } + const project = env.SPACE_DEV_COMPOSE_PROJECT || `spaceapp-dev-${rootHash}`; + if (!/^[a-z0-9][a-z0-9_-]{2,50}$/i.test(project)) throw new Error("SPACE_DEV_COMPOSE_PROJECT contains unsupported characters."); + const preferredSocket = join(stateRoot, "run", "cli-host.sock"); + const socketPath = Buffer.byteLength(preferredSocket) <= 96 ? preferredSocket : join(tmpdir(), `spaceapp-dev-${rootHash}`, "cli-host.sock"); + return { + root, rootHash, stateRoot, project, ports, socketPath, + composeFile: join(stateRoot, "compose.yml"), + configFile: join(stateRoot, "config.json"), + pidFile: join(stateRoot, "supervisor.pid"), + buildMarker: join(stateRoot, "build-marker.json"), + secretsDir: join(stateRoot, "secrets"), + binDir: join(stateRoot, "bin"), + vendorDir: join(stateRoot, "vendor", "opencode"), + providerRoot: join(stateRoot, "providers", "opencode"), + workspacesRoot: join(stateRoot, "workspaces"), + memoryRoot: join(stateRoot, "memory"), + memoryGraphRoot: join(stateRoot, "memory-graph"), + artifactRoot: join(stateRoot, "artifacts"), + projectsRoot: join(stateRoot, "projects"), + diagnosticsRoot: join(stateRoot, "diagnostics"), + openCodeChatRoot: join(stateRoot, "opencode-chat") + }; +} + +export function renderCompose(config, images) { + return `services:\n postgres:\n image: ${images.postgres}\n restart: unless-stopped\n environment:\n POSTGRES_DB: spaceapp_dev\n POSTGRES_USER: spaceapp\n POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password\n ports:\n - \"127.0.0.1:${config.ports.postgres}:5432\"\n secrets: [postgres-password]\n volumes:\n - postgres-data:/var/lib/postgresql/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U spaceapp -d spaceapp_dev\"]\n interval: 2s\n timeout: 3s\n retries: 30\n temporal:\n image: ${images.temporal}\n restart: unless-stopped\n depends_on:\n postgres:\n condition: service_healthy\n environment:\n DB: postgres12\n DB_PORT: 5432\n POSTGRES_USER: spaceapp\n POSTGRES_SEEDS: postgres\n DYNAMIC_CONFIG_FILE_PATH: config/dynamicconfig/docker.yaml\n entrypoint: [\"/bin/sh\", \"-ec\"]\n command:\n - export POSTGRES_PWD=\"$$(cat /run/secrets/postgres-password)\"; exec /etc/temporal/entrypoint.sh autosetup\n ports:\n - \"127.0.0.1:${config.ports.temporal}:7233\"\n secrets: [postgres-password]\n healthcheck:\n test: [\"CMD-SHELL\", \"temporal operator cluster health --address temporal:7233 >/dev/null && temporal operator namespace describe --namespace default --address temporal:7233 >/dev/null\"]\n interval: 3s\n timeout: 5s\n retries: 40\nsecrets:\n postgres-password:\n file: ./secrets/postgres-password\nvolumes:\n postgres-data:\n`; +} + +export async function run(command, args, { capture = false, allowFailure = false, env = process.env, cwd = repoRoot } = {}) { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { cwd, env, stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit" }); + let stdout = ""; + let stderr = ""; + if (capture) { + child.stdout.on("data", (chunk) => { stdout += chunk.toString(); }); + child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); + } + child.once("error", rejectPromise); + child.once("exit", (code, signal) => { + const result = { code: code ?? 1, signal, stdout: stdout.trim(), stderr: stderr.trim() }; + if (!allowFailure && code !== 0) rejectPromise(new Error(`${command} ${args.join(" ")} failed (${code ?? signal}).${stderr ? `\n${stderr}` : ""}`)); + else resolvePromise(result); + }); + }); +} + +export function composeArgs(config, ...args) { + return ["compose", "-p", config.project, "-f", config.composeFile, ...args]; +} +export const compose = (config, args, options = {}) => run("docker", composeArgs(config, ...args), options); + +async function privateFile(path, value) { + if (!existsSync(path)) await writeFile(path, `${value}\n`, { mode: 0o600, flag: "wx" }); + await chmod(path, 0o600); +} + +export async function prepareState(config) { + for (const path of [config.stateRoot, config.secretsDir, dirname(config.socketPath), config.binDir, config.vendorDir, config.providerRoot, + config.workspacesRoot, config.memoryRoot, config.memoryGraphRoot, config.artifactRoot, config.projectsRoot, config.diagnosticsRoot, config.openCodeChatRoot]) { + await mkdir(path, { recursive: true, mode: 0o700 }); + } + await privateFile(join(config.secretsDir, "postgres-password"), randomBytes(24).toString("base64url")); + await privateFile(join(config.secretsDir, "session-secret"), randomBytes(32).toString("hex")); + await privateFile(join(config.secretsDir, "setup-token"), randomBytes(32).toString("hex")); + const password = (await readFile(join(config.secretsDir, "postgres-password"), "utf8")).trim(); + const sessionSecret = (await readFile(join(config.secretsDir, "session-secret"), "utf8")).trim(); + const databaseUrl = `postgresql://spaceapp:${encodeURIComponent(password)}@127.0.0.1:${config.ports.postgres}/spaceapp_dev`; + await writeFile(join(config.secretsDir, "database-url"), `${databaseUrl}\n`, { mode: 0o600 }); + await chmod(join(config.secretsDir, "database-url"), 0o600); + return { databaseUrl, sessionSecret }; +} + +async function newestMtime(path) { + if (!existsSync(path)) return 0; + const info = await stat(path); + if (!info.isDirectory()) return info.mtimeMs; + let newest = info.mtimeMs; + for (const entry of await readdir(path, { withFileTypes: true })) { + if (["node_modules", "dist", "coverage"].includes(entry.name)) continue; + newest = Math.max(newest, await newestMtime(join(path, entry.name))); + } + return newest; +} + +export async function ensureBuild(config, force = false) { + if (process.env.SPACE_DEV_SKIP_BUILD === "true" && !force) return; + const artifacts = ["packages/contracts/dist/index.js", "packages/runtime/dist/index.js", "packages/db/dist/migrate.js", "packages/cli-host/dist/main.js", "apps/worker/dist/worker.js"].map((p) => join(config.root, p)); + const marker = existsSync(config.buildMarker) ? JSON.parse(await readFile(config.buildMarker, "utf8")) : null; + const sourceMtime = Math.max(await newestMtime(join(config.root, "packages")), await newestMtime(join(config.root, "apps", "worker", "src"))); + if (!force && process.env.SPACE_DEV_FORCE_BUILD !== "true" && marker && artifacts.every(existsSync) && sourceMtime <= Number(marker.sourceMtime || 0)) return; + console.log("[dev:stack] Building source workspaces before migrations..."); + await run("npm", ["run", "build"], { cwd: config.root }); + await writeFile(config.buildMarker, `${JSON.stringify({ sourceMtime, builtAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 }); +} + +export async function installOpenCode(config, version) { + const packageJson = join(config.vendorDir, "node_modules", "opencode-ai", "package.json"); + let installed = null; + if (existsSync(packageJson)) { + try { installed = JSON.parse(await readFile(packageJson, "utf8")).version; } catch {} + } + if (installed !== version) { + await rm(config.vendorDir, { recursive: true, force: true }); + await mkdir(config.vendorDir, { recursive: true, mode: 0o700 }); + console.log(`[dev:stack] Installing pinned opencode-ai@${version} into dev state...`); + await run("npm", ["install", "--prefix", config.vendorDir, "--no-package-lock", "--no-save", "--no-audit", "--no-fund", `opencode-ai@${version}`]); + } + const wrapper = join(config.binDir, "opencode-vscode-parity"); + const binary = join(config.vendorDir, "node_modules", ".bin", "opencode"); + await writeFile(wrapper, `#!/bin/sh\nset -eu\nexport HOME=${JSON.stringify(join(config.providerRoot, "home"))}\nexport XDG_CONFIG_HOME=${JSON.stringify(join(config.providerRoot, "config"))}\nexport XDG_DATA_HOME=${JSON.stringify(join(config.providerRoot, "data"))}\nexport XDG_CACHE_HOME=${JSON.stringify(join(config.providerRoot, "cache"))}\nexec ${JSON.stringify(binary)} \"$@\"\n`, { mode: 0o700 }); + await chmod(wrapper, 0o700); + return wrapper; +} + +export async function waitHealthy(config, service, timeoutMs = 120_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const id = (await compose(config, ["ps", "-q", service], { capture: true })).stdout; + if (id) { + const status = await run("docker", ["inspect", "--format", "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}", id], { capture: true, allowFailure: true }); + if (status.stdout === "healthy") return; + if (["unhealthy", "exited", "dead"].includes(status.stdout)) throw new Error(`${service} became ${status.stdout}.`); + } + await sleep(1000); + } + throw new Error(`${service} did not become healthy within ${Math.round(timeoutMs / 1000)} seconds.`); +} + +export async function ensureTemporalNamespace(config) { + const base = ["exec", "-T", "temporal", "temporal", "operator", "namespace"]; + const described = await compose(config, [...base, "describe", "--namespace", "spaceapp-dev", "--address", "temporal:7233"], { capture: true, allowFailure: true }); + if (described.code !== 0) await compose(config, [...base, "create", "--namespace", "spaceapp-dev", "--address", "temporal:7233"]); +} + +export function sourceEnv(config, databaseUrl, sessionSecret, openCodeWrapper) { + const env = { ...process.env }; + for (const key of ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "GROK_API_KEY", "DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GITHUB_TOKEN", "GH_TOKEN"]) delete env[key]; + return { + ...env, + SPACE_INSTANCE: "dev", + SPACE_INSTALL_ROOT: config.root, + SPACE_RUNTIME_STORE: "postgres", + SPACE_DATABASE_URL: databaseUrl, + SPACE_SESSION_SECRET: sessionSecret, + SPACE_SETUP_TOKEN_FILE: join(config.secretsDir, "setup-token"), + SPACE_DEV_LOGIN: "false", + SPACE_GOOGLE_AUTH_DISABLED: "true", + SPACE_PUBLIC_DISTRIBUTION: "true", + SPACE_OPTIONAL_MODULES: "none", + SPACEAPP_WORKFLOWS_ENABLED: "true", + SPACE_API_HOST: "127.0.0.1", + SPACE_API_PORT: String(config.ports.api), + SPACE_WEB_HOST: "127.0.0.1", + SPACE_WEB_PORT: String(config.ports.web), + SPACE_API_ORIGIN: `http://127.0.0.1:${config.ports.api}`, + SPACE_TEMPORAL_ADDRESS: `127.0.0.1:${config.ports.temporal}`, + SPACE_TEMPORAL_NAMESPACE: "spaceapp-dev", + SPACE_TEMPORAL_TASK_QUEUE: "space-dev-agent-turns", + SPACE_CLI_ENABLED: "true", + SPACE_CLI_HOST_SOCKET: config.socketPath, + SPACE_CLI_COMMAND_PATH: config.binDir, + SPACE_CLI_OPENCODE_COMMAND: openCodeWrapper, + SPACE_CLI_WORKSPACE_ROOT: config.workspacesRoot, + SPACE_CLI_ROOT_ENABLED: "false", + SPACE_BROWSER_SESSIONS_ENABLED: "false", + SPACE_BROWSER_HOST_TRANSPORT: "in-process", + SPACE_MEMORY_ROOT: config.memoryRoot, + SPACE_MEMORY_GRAPH_ROOT: config.memoryGraphRoot, + SPACE_ARTIFACT_ROOT: config.artifactRoot, + SPACE_PROJECTS_ROOT: config.projectsRoot, + SPACE_APP_DIAGNOSTICS_ROOT: config.diagnosticsRoot, + SPACE_OPENCODE_CHAT_STATE_ROOT: config.openCodeChatRoot, + SPACE_ENABLE_DUMMY_TURNS: "false", + SPACE_ROOM_AGENT_AUTOPILOT_ENABLED: "false" + }; +} + +export async function waitReady(config, timeoutMs = 120_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const response = await fetch(`http://127.0.0.1:${config.ports.api}/readyz`); + if (response.ok) { + const body = await response.json(); + if (body.ok === true && body.dependencies?.database === "RUNNING" && body.dependencies?.worker === "RUNNING" && body.dependencies?.cliHost === "RUNNING") return body; + } + } catch {} + await sleep(500); + } + throw new Error("The source stack did not reach /readyz with database, worker and CLI host RUNNING."); +} + +export function pidAlive(pid) { + if (!Number.isInteger(pid) || pid <= 1) return false; + try { process.kill(pid, 0); return true; } + catch (error) { if (error?.code === "ESRCH") return false; throw error; } +} + +export async function readSupervisorPid(config) { + if (!existsSync(config.pidFile)) return null; + const value = Number.parseInt((await readFile(config.pidFile, "utf8")).trim(), 10); + return Number.isInteger(value) ? value : null; +} + +export async function dockerResources(config) { + const containers = await run("docker", ["ps", "-aq", "--filter", `label=com.docker.compose.project=${config.project}`], { capture: true, allowFailure: true }); + const volumes = await run("docker", ["volume", "ls", "-q", "--filter", `label=com.docker.compose.project=${config.project}`], { capture: true, allowFailure: true }); + return { containers: containers.stdout.split(/\s+/).filter(Boolean), volumes: volumes.stdout.split(/\s+/).filter(Boolean) }; +} From 4f1eca3dd16663c5b24935ccc030de7a6576e6f7 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 15:57:16 +0900 Subject: [PATCH 06/71] feat(dev): orchestrate full source stack --- deploy/docker/dev-stack.mjs | 248 ++++++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 deploy/docker/dev-stack.mjs diff --git a/deploy/docker/dev-stack.mjs b/deploy/docker/dev-stack.mjs new file mode 100644 index 000000000..a5d2987af --- /dev/null +++ b/deploy/docker/dev-stack.mjs @@ -0,0 +1,248 @@ +#!/usr/bin/env node +import { existsSync } from "node:fs"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +import { + compose, + dockerResources, + ensureBuild, + ensureTemporalNamespace, + installOpenCode, + parseOpenCodeVersion, + parsePinnedImages, + pidAlive, + prepareState, + readSupervisorPid, + renderCompose, + repoRoot, + resolveConfig, + run, + sleep, + sourceEnv, + waitHealthy, + waitReady +} from "./dev-stack-lib.mjs"; + +const scriptPath = fileURLToPath(import.meta.url); + +function prefixStream(stream, label, target) { + let pending = ""; + stream.on("data", (chunk) => { + pending += chunk.toString(); + const lines = pending.split(/\r?\n/); + pending = lines.pop() || ""; + for (const line of lines) target.write(`[${label}] ${line}\n`); + }); + stream.on("end", () => { if (pending) target.write(`[${label}] ${pending}\n`); }); +} + +function terminateTree(child, signal = "SIGTERM") { + if (!child || child.exitCode != null) return; + try { + if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal); + else child.kill(signal); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } +} + +async function waitDead(child, timeoutMs = 7000) { + if (!child || child.exitCode != null) return; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline && child.exitCode == null) await sleep(100); + if (child.exitCode == null) terminateTree(child, "SIGKILL"); +} + +function spawnService(config, name, command, args, env, onUnexpectedExit) { + const child = spawn(command, args, { + cwd: config.root, + env, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"] + }); + prefixStream(child.stdout, name, process.stdout); + prefixStream(child.stderr, name, process.stderr); + child.once("exit", (code, signal) => onUnexpectedExit(name, child, code, signal)); + return child; +} + +async function waitSupervisorExit(pid, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline && pidAlive(pid)) await sleep(100); + if (pidAlive(pid)) throw new Error(`Dev-stack supervisor ${pid} did not stop cleanly.`); +} + +export async function down(config) { + const pid = await readSupervisorPid(config); + if (pid && pid !== process.pid && pidAlive(pid)) { + process.kill(pid, "SIGTERM"); + await waitSupervisorExit(pid); + } + if (existsSync(config.composeFile)) await compose(config, ["down", "--remove-orphans"]); +} + +export async function reset(config) { + await down(config); + if (existsSync(config.composeFile)) await compose(config, ["down", "-v", "--remove-orphans"]); + const remaining = await dockerResources(config); + if (remaining.containers.length || remaining.volumes.length) { + throw new Error(`Reset left owned Docker resources behind; state preserved for retry: ${JSON.stringify(remaining)}`); + } + await rm(config.stateRoot, { recursive: true, force: true }); + const tmpSocketRoot = dirname(config.socketPath); + if (!tmpSocketRoot.startsWith(config.stateRoot) && tmpSocketRoot.includes(`spaceapp-dev-${config.rootHash}`)) { + await rm(tmpSocketRoot, { recursive: true, force: true }); + } + console.log("[dev:stack] Reset complete; owned containers, volumes and state were removed."); +} + +export async function status(config) { + const pid = await readSupervisorPid(config); + const resources = await dockerResources(config); + const payload = { + running: Boolean(pid && pidAlive(pid)), + supervisorPid: pid, + project: config.project, + stateRoot: config.stateRoot, + ports: config.ports, + docker: resources + }; + console.log(JSON.stringify(payload, null, 2)); + return payload; +} + +export async function rebuild(config) { + const pid = await readSupervisorPid(config); + if (!pid || !pidAlive(pid)) { + await ensureBuild(config, true); + console.log("[dev:stack] Rebuilt source workspaces; no running supervisor to restart."); + return; + } + process.kill(pid, "SIGHUP"); + console.log(`[dev:stack] Rebuild requested from supervisor ${pid}.`); +} + +export async function start(config) { + const existingPid = await readSupervisorPid(config); + if (existingPid && pidAlive(existingPid)) throw new Error(`Dev stack is already running under supervisor ${existingPid}.`); + + const composeTemplate = await readFile(resolve(config.root, "packages/run-spaceapp/templates/compose.yml"), "utf8"); + const dockerfile = await readFile(resolve(config.root, "Dockerfile"), "utf8"); + const images = parsePinnedImages(composeTemplate); + const openCodeVersion = parseOpenCodeVersion(dockerfile); + const { databaseUrl, sessionSecret } = await prepareState(config); + await mkdir(dirname(config.composeFile), { recursive: true, mode: 0o700 }); + await writeFile(config.composeFile, renderCompose(config, images), { mode: 0o600 }); + await writeFile(config.configFile, `${JSON.stringify({ + root: config.root, + stateRoot: config.stateRoot, + project: config.project, + ports: config.ports, + socketPath: config.socketPath, + openCodeVersion + }, null, 2)}\n`, { mode: 0o600 }); + + await run("docker", ["compose", "version"]); + let children = new Map(); + let stopping = false; + let reloadChain = Promise.resolve(); + + const stopChildren = async () => { + for (const child of children.values()) terminateTree(child); + await Promise.all([...children.values()].map((child) => waitDead(child))); + children = new Map(); + try { await rm(config.socketPath, { force: true }); } catch {} + }; + + const shutdown = async (reason) => { + if (stopping) return; + stopping = true; + console.log(`[dev:stack] Stopping (${reason})...`); + await stopChildren(); + if (existsSync(config.composeFile)) await compose(config, ["down", "--remove-orphans"], { allowFailure: true }); + try { await rm(config.pidFile, { force: true }); } catch {} + }; + + const unexpectedExit = (name, child, code, signal) => { + if (stopping || child !== children.get(name)) return; + console.error(`[dev:stack] ${name} exited unexpectedly (${code ?? signal}); stopping the stack.`); + process.exitCode = 1; + void shutdown("child-failure").finally(() => process.exit(1)); + }; + + const spawnChildren = async (forceBuild = false) => { + await ensureBuild(config, forceBuild); + const openCodeWrapper = await installOpenCode(config, openCodeVersion); + const env = sourceEnv(config, databaseUrl, sessionSecret, openCodeWrapper); + children.set("cli-host", spawnService(config, "cli-host", process.execPath, ["packages/cli-host/dist/main.js"], env, unexpectedExit)); + children.set("worker", spawnService(config, "worker", process.execPath, ["apps/worker/dist/worker.js"], env, unexpectedExit)); + children.set("api", spawnService(config, "api", "npm", ["exec", "--", "tsx", "watch", "apps/api/src/server.ts"], env, unexpectedExit)); + children.set("web", spawnService(config, "web", "npm", ["run", "dev", "-w", "@space/web", "--", "--host", "127.0.0.1", "--port", String(config.ports.web)], env, unexpectedExit)); + }; + + const reload = () => { + reloadChain = reloadChain.then(async () => { + if (stopping) return; + console.log("[dev:stack] Rebuilding workspaces and restarting source services..."); + await stopChildren(); + await spawnChildren(true); + await waitReady(config); + console.log("[dev:stack] Rebuild complete; source services are ready."); + }).catch((error) => { + console.error(`[dev:stack] rebuild failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + void shutdown("rebuild-failure").finally(() => process.exit(1)); + }); + }; + + process.once("SIGINT", () => { void shutdown("SIGINT").finally(() => process.exit(process.exitCode || 0)); }); + process.once("SIGTERM", () => { void shutdown("SIGTERM").finally(() => process.exit(process.exitCode || 0)); }); + process.on("SIGHUP", reload); + + await writeFile(config.pidFile, `${process.pid}\n`, { mode: 0o600 }); + try { + console.log(`[dev:stack] Starting digest-pinned PostgreSQL and Temporal (${config.project})...`); + await compose(config, ["up", "-d", "postgres", "temporal"]); + await Promise.all([waitHealthy(config, "postgres"), waitHealthy(config, "temporal")]); + await ensureTemporalNamespace(config); + await ensureBuild(config); + console.log("[dev:stack] Applying database migrations..."); + await run("npm", ["run", "db:migrate"], { cwd: config.root, env: { ...process.env, SPACE_DATABASE_URL: databaseUrl } }); + await spawnChildren(false); + await waitReady(config); + const setupToken = (await readFile(resolve(config.secretsDir, "setup-token"), "utf8")).trim(); + console.log("\n============================================================"); + console.log("SpaceApp dev stack is READY"); + console.log(`Web: http://127.0.0.1:${config.ports.web}`); + console.log(`API: http://127.0.0.1:${config.ports.api}`); + console.log(`Owner setup token: ${setupToken}`); + console.log("Package changes: npm run dev:stack -- rebuild"); + console.log("Stop: npm run dev:stack -- down | Reset: npm run dev:stack -- reset"); + console.log("============================================================\n"); + await new Promise(() => {}); + } catch (error) { + process.exitCode = 1; + await shutdown("startup-failure"); + throw error; + } +} + +async function main() { + const command = process.argv[2] || "up"; + const config = await resolveConfig(); + if (["up", "start"].includes(command)) return start(config); + if (["down", "stop"].includes(command)) return down(config); + if (command === "reset") return reset(config); + if (command === "status") return status(config); + if (command === "rebuild") return rebuild(config); + throw new Error(`Unknown dev:stack command ${JSON.stringify(command)}. Use up, down, reset, status, or rebuild.`); +} + +if (process.argv[1] && resolve(process.argv[1]) === scriptPath) { + main().catch((error) => { + process.stderr.write(`[dev:stack] ERROR: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} From 7899091964a1efdf1d4b92adb7aefe98b5fc6c1f Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 15:57:31 +0900 Subject: [PATCH 07/71] test(dev): verify owner setup and OpenCode pane --- deploy/docker/dev-stack-verify.mjs | 103 +++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 deploy/docker/dev-stack-verify.mjs diff --git a/deploy/docker/dev-stack-verify.mjs b/deploy/docker/dev-stack-verify.mjs new file mode 100644 index 000000000..111397e65 --- /dev/null +++ b/deploy/docker/dev-stack-verify.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const stateRoot = resolve(process.env.SPACE_DEV_STACK_DIR || join(root, ".dev-stack")); +const config = JSON.parse(await readFile(join(stateRoot, "config.json"), "utf8")); +const api = `http://127.0.0.1:${config.ports.api}`; +const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); + +function firstCookie(response) { + const values = typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : []; + return (values[0] || response.headers.get("set-cookie") || "").split(";", 1)[0] || ""; +} + +async function request(path, { method = "GET", body, cookie, csrf, csrfHeader = "x-space-csrf-token" } = {}) { + const headers = { accept: "application/json" }; + if (body !== undefined) headers["content-type"] = "application/json"; + if (cookie) headers.cookie = cookie; + if (csrf) headers[csrfHeader] = csrf; + const response = await fetch(`${api}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body) + }); + const text = await response.text(); + let payload = null; + try { payload = text ? JSON.parse(text) : null; } catch { payload = text; } + if (!response.ok) throw new Error(`${method} ${path} failed (${response.status}): ${text.slice(0, 800)}`); + return { response, payload }; +} + +async function waitReady() { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + try { + const { payload } = await request("/readyz"); + if (payload?.ok === true && payload.dependencies?.database === "RUNNING" && payload.dependencies?.worker === "RUNNING" && payload.dependencies?.cliHost === "RUNNING") return payload; + } catch {} + await sleep(500); + } + throw new Error("Timed out waiting for real source-stack readiness."); +} + +const ready = await waitReady(); +console.log(`[dev-stack-verify] PASS readiness database=${ready.dependencies.database} worker=${ready.dependencies.worker} cliHost=${ready.dependencies.cliHost}`); + +const setupToken = (await readFile(join(stateRoot, "secrets", "setup-token"), "utf8")).trim(); +const claimed = await request("/api/setup/claim", { + method: "POST", + body: { token: setupToken, email: "dev-stack-ci@example.com", password: "dev-stack-ci-password-42!" } +}); +const cookie = firstCookie(claimed.response); +if (!cookie) throw new Error("Owner setup succeeded without returning a session cookie."); +const starterRoomId = claimed.payload?.starterRoomId; +if (typeof starterRoomId !== "string" || !starterRoomId.startsWith("room:")) throw new Error("Owner setup did not return the Getting Started room id."); +console.log(`[dev-stack-verify] PASS owner setup starterRoom=${starterRoomId}`); + +const csrfResponse = await request("/api/auth/csrf", { cookie }); +const csrf = csrfResponse.payload?.csrfToken; +const csrfHeader = csrfResponse.payload?.headerName || "x-space-csrf-token"; +if (typeof csrf !== "string" || !csrf) throw new Error("Authenticated owner session did not return a CSRF token."); + +await request(`/api/rooms/${encodeURIComponent(starterRoomId)}`, { + method: "PATCH", + cookie, + csrf, + csrfHeader, + body: { projectPath: root } +}); +console.log("[dev-stack-verify] PASS starter room points at the source checkout"); + +const panesResponse = await request(`/api/panes?roomId=${encodeURIComponent(starterRoomId)}`, { cookie }); +const payload = panesResponse.payload; +const panes = Array.isArray(payload) ? payload : (payload?.data ?? payload?.panes ?? []); +const pane = panes.find((candidate) => candidate?.terminalRuntimeId === "cli:opencode" || (candidate?.mode === "TERMINAL" && /OpenCode/i.test(candidate?.title || ""))); +if (!pane?.id) throw new Error("Getting Started does not contain an OpenCode terminal pane."); + +const started = await request(`/api/panes/${encodeURIComponent(pane.id)}/cli/session`, { + method: "POST", + cookie, + csrf, + csrfHeader, + body: { runtimeId: "cli:opencode" } +}); +console.log(`[dev-stack-verify] OpenCode start returned status=${started.payload?.session?.status ?? started.payload?.status ?? "unknown"}`); + +const deadline = Date.now() + 60_000; +let session = null; +while (Date.now() < deadline) { + const current = await request(`/api/panes/${encodeURIComponent(pane.id)}/cli/session`, { cookie }); + session = current.payload?.session ?? current.payload; + if (["RUNNING", "READY", "ATTACHED"].includes(session?.status)) break; + if (["ERROR", "EXITED", "FAILED"].includes(session?.status)) throw new Error(`OpenCode pane entered ${session.status}: ${session.statusReason || "no reason"}`); + await sleep(500); +} +if (!session || !["RUNNING", "READY", "ATTACHED"].includes(session.status)) throw new Error(`OpenCode pane did not become active; last status=${session?.status ?? "missing"}.`); +console.log(`[dev-stack-verify] PASS OpenCode pane active status=${session.status}`); + +const finalReady = await request("/readyz"); +if (finalReady.payload?.ok !== true) throw new Error("Stack lost readiness after starting OpenCode."); +console.log("[dev-stack-verify] PASS source-stack acceptance"); From a160f582d5123430346001c39fa78061d00628b4 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 15:57:41 +0900 Subject: [PATCH 08/71] test(dev): cover source stack contracts --- deploy/docker/dev-stack.test.mjs | 58 ++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 deploy/docker/dev-stack.test.mjs diff --git a/deploy/docker/dev-stack.test.mjs b/deploy/docker/dev-stack.test.mjs new file mode 100644 index 000000000..28b19b5f5 --- /dev/null +++ b/deploy/docker/dev-stack.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + assertSafeStateRoot, + parseOpenCodeVersion, + parsePinnedImages, + renderCompose, + resolveConfig +} from "./dev-stack-lib.mjs"; + +test("source stack derives third-party pins from canonical files", () => { + const images = parsePinnedImages(` +services: + postgres: + image: pgvector/pgvector:0.8.3-pg17-trixie@sha256:${"a".repeat(64)} + temporal: + image: temporalio/auto-setup:1.29.1@sha256:${"b".repeat(64)} +`); + assert.equal(images.postgres, `pgvector/pgvector:0.8.3-pg17-trixie@sha256:${"a".repeat(64)}`); + assert.equal(images.temporal, `temporalio/auto-setup:1.29.1@sha256:${"b".repeat(64)}`); + assert.equal(parseOpenCodeVersion("RUN npm install --global opencode-ai@1.18.4"), "1.18.4"); + assert.throws(() => parsePinnedImages("services: {}"), /digest-pinned/); + assert.throws(() => parseOpenCodeVersion("RUN npm install opencode-ai@latest"), /pinned/); +}); + +test("defaults isolate installed SpaceApp and bind Docker ports to loopback", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-dev-test-")); + try { + const config = await resolveConfig({}, root); + assert.notEqual(config.ports.api, 4910); + assert.notEqual(config.ports.web, 4911); + assert.match(config.project, /^spaceapp-dev-[a-f0-9]{10}$/); + const compose = renderCompose(config, { + postgres: `pgvector/pgvector:0.8.3@sha256:${"c".repeat(64)}`, + temporal: `temporalio/auto-setup:1.29.1@sha256:${"d".repeat(64)}` + }); + for (const port of [config.ports.postgres, config.ports.temporal]) assert.match(compose, new RegExp(`127\\.0\\.0\\.1:${port}:`)); + assert.match(compose, /POSTGRES_PASSWORD_FILE: \/run\/secrets\/postgres-password/); + assert.match(compose, /condition: service_healthy/); + assert.match(compose, /temporal operator cluster health --address temporal:7233/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("unsafe state roots and installed-instance ports fail closed", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-dev-root-")); + try { + await assert.rejects(() => assertSafeStateRoot(root, root), /Unsafe dev-stack state directory/); + await assert.rejects(() => resolveConfig({ SPACE_DEV_API_PORT: "4910" }, root), /reserved for an installed SpaceApp/); + await assert.rejects(() => resolveConfig({ SPACE_DEV_API_PORT: "6000", SPACE_DEV_WEB_PORT: "6000" }, root), /conflicts/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From 2e64eeba2b642b1d6f9966e021f9d72c9fb0d0ad Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 15:57:56 +0900 Subject: [PATCH 09/71] chore(dev): point source stack scripts at public deploy tooling --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index ce03c14af..2bebe7ce0 100644 --- a/package.json +++ b/package.json @@ -46,9 +46,9 @@ "dev:api": "npm run dev -w @space/api", "dev:web": "npm run dev -w @space/web", "dev:worker": "npm run dev -w @space/worker", - "dev:stack": "node scripts/dev-stack.mjs", - "verify:dev-stack": "node scripts/dev-stack-verify.mjs", - "test:dev-stack": "node --test scripts/tests/dev-stack.test.mjs", + "dev:stack": "node deploy/docker/dev-stack.mjs", + "verify:dev-stack": "node deploy/docker/dev-stack-verify.mjs", + "test:dev-stack": "node --test deploy/docker/dev-stack.test.mjs", "db:migrate": "npm run migrate -w @space/db", "build": "npm run build -w @space/contracts && npm run build -w @space/icons && npm run build -w @space/streaming && npm run build -w @space/opencode-control && npm run build -w @space/memory-graph && npm run build -w @space/browser-host && npm run build -w @space/cli-host && npm run build -w @space/codex-app-server && npm run build -w @space/runtime && npm run build -w @space/db && npm run build -w @space/gateway && node scripts/build-demo-projects.mjs && npm run build -w @space/api && npm run build -w @space/worker && npm run build -w @space/web && node scripts/fix-build-permissions.mjs", "public:export:verify": "node scripts/public-export.mjs --verify", From 568e199407b44e929de7d469c704796b30a52057 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 15:58:08 +0900 Subject: [PATCH 10/71] chore(dev): ignore source stack state --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 81e68b26a..604f0123c 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,8 @@ bin/ var/ projects/ /lost+found/ +.dev-stack/ +.dev-stack*/ # Local credentials and machine-specific environment. .env From 38fa8763673d72baaac9ea026532c09e4bde4bed Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 15:58:27 +0900 Subject: [PATCH 11/71] fix(dev): proxy Vite to the configured API origin --- apps/web/vite.config.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index cd7612d98..5cca67e77 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -33,13 +33,17 @@ export default defineConfig({ server: { 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" - } + proxy: (() => { + const apiOrigin = process.env.SPACE_API_ORIGIN ?? `http://127.0.0.1:${process.env.SPACE_API_PORT ?? "4910"}`; + return { + "/api": { target: apiOrigin, ws: true }, + "/plugins": apiOrigin, + "/healthz": apiOrigin, + "/readyz": apiOrigin, + "/version": apiOrigin, + "/logout": apiOrigin + }; + })() }, test: { environment: "jsdom", From e24aa2e6811c241c5225b033027d2d83a56c997b Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 15:58:49 +0900 Subject: [PATCH 12/71] ci(dev): exercise full source stack lifecycle --- .github/workflows/dev-stack.yml | 73 +++++++++++++++++++++++++++++++++ 1 file changed, 73 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..d21ed3e3c --- /dev/null +++ b/.github/workflows/dev-stack.yml @@ -0,0 +1,73 @@ +name: Dev stack from source + +on: + pull_request: + paths: + - ".github/workflows/dev-stack.yml" + - ".nvmrc" + - "Dockerfile" + - "package.json" + - "package-lock.json" + - "CONTRIBUTING.md" + - "apps/api/**" + - "apps/web/**" + - "apps/worker/**" + - "packages/**" + - "deploy/docker/**" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: dev-stack-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + source-stack: + name: Real source stack lifecycle + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install dependencies + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + run: npm ci + + - name: Unit-test dev-stack contracts + run: npm run test:dev-stack + + - name: Start full stack without a TTY + shell: bash + run: | + set -euo pipefail + npm run dev:stack >"$RUNNER_TEMP/dev-stack.log" 2>&1 & + echo $! >"$RUNNER_TEMP/dev-stack-shell.pid" + echo "source stack launched in background" + + - name: Verify readiness, owner setup, and OpenCode pane + run: npm run verify:dev-stack + + - name: Stop and reset owned resources + if: always() + shell: bash + run: | + set +e + npm run dev:stack -- down + down_status=$? + npm run dev:stack -- reset + reset_status=$? + echo "--- dev-stack log ---" + cat "$RUNNER_TEMP/dev-stack.log" 2>/dev/null || true + if [ "$down_status" -ne 0 ] || [ "$reset_status" -ne 0 ]; then + exit 1 + fi From 0fcad943b261e8144fb1850a26b81679c238a42b Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 15:59:30 +0900 Subject: [PATCH 13/71] docs(dev): document full source stack workflow --- CONTRIBUTING.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03bcdad05..b144b1cb7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,6 +50,66 @@ select a short temporary directory you own and set `TMPDIR` for the command. On Windows run this particular smoke check inside WSL or a Linux development container. It does not certify the Windows/macOS installer. +## Run the complete app from source + +With Docker Engine or Docker Desktop available, start the contributor stack: + +```sh +npm run dev:stack +``` + +The command starts digest-pinned PostgreSQL with pgvector and Temporal in an +isolated Compose project, builds the workspace before migrations on a fresh +checkout, installs the `opencode-ai` version pinned by `Dockerfile` into local +state, and launches the CLI host, worker, API and Vite web server from this +checkout. The API uses normal single-owner setup: `SPACE_DEV_LOGIN` stays off. +When `/readyz` reports the database, worker and CLI host as running, the command +prints the local Web URL and one-time owner setup token. + +The default source-stack ports are deliberately separate from an installed +SpaceApp: API `5910`, Web `5911`, PostgreSQL `55432`, and Temporal `57233`. +Docker-published ports bind to `127.0.0.1`. Runtime state and generated secrets +live under the git-ignored `.dev-stack/`; secret files are mode `0600`. Provider +API keys and GitHub tokens inherited by the shell are not passed to source +services. + +Useful lifecycle commands are: + +```sh +npm run dev:stack -- status +npm run dev:stack -- rebuild +npm run dev:stack -- down +npm run dev:stack -- reset +``` + +`rebuild` rebuilds workspace packages and restarts the source services under the +same supervisor. `down` stops only this checkout's supervised processes and +Compose project. `reset` additionally removes this checkout's Compose volumes +and state directory; if Docker cannot remove an owned volume, reset fails and +keeps the state needed to retry cleanup instead of reporting success. + +The isolated ports can be overridden with `SPACE_DEV_API_PORT`, +`SPACE_DEV_WEB_PORT`, `SPACE_DEV_POSTGRES_PORT`, and +`SPACE_DEV_TEMPORAL_PORT`. `SPACE_DEV_STACK_DIR` selects another state +directory and `SPACE_DEV_COMPOSE_PROJECT` selects another Compose project. +The runner rejects installed-instance ports `4910` and `4911`, duplicate ports, +unsafe state roots, and symlink state roots. + +For an automated end-to-end proof after the stack is ready, run: + +```sh +npm run verify:dev-stack +``` + +That proof performs normal owner setup, points the Getting Started room at the +current checkout rather than `/opt/spaceapp`, starts its OpenCode terminal pane +through the real CLI host, requires an active CLI session status, and verifies +that the stack remains ready. The dedicated GitHub Actions workflow runs this +proof on Linux. Windows contributors should use WSL2 with Docker integration; +macOS contributors should use Docker Desktop or another compatible Docker +engine. Those host paths are contributor environments, not installer-release +certification. + ## Make and verify a small change Work on a branch, not on `main`, so the change can become a pull request: From f11be8a29a2e97da5defb3d7938d2f7e1e0c7c0a Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 15:59:46 +0900 Subject: [PATCH 14/71] chore(dev): remove superseded stack prototype --- scripts/dev-stack.mjs | 559 ------------------------------------------ 1 file changed, 559 deletions(-) delete mode 100644 scripts/dev-stack.mjs diff --git a/scripts/dev-stack.mjs b/scripts/dev-stack.mjs deleted file mode 100644 index 8382bd050..000000000 --- a/scripts/dev-stack.mjs +++ /dev/null @@ -1,559 +0,0 @@ -#!/usr/bin/env node -import { createHash, randomBytes } from "node:crypto"; -import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { - chmod, - lstat, - mkdir, - readFile, - readdir, - realpath, - rm, - stat, - writeFile -} from "node:fs/promises"; -import { homedir, tmpdir } from "node:os"; -import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptPath = fileURLToPath(import.meta.url); -export const repoRoot = resolve(dirname(scriptPath), ".."); -const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); - -function parsePort(raw, fallback, name) { - const value = raw == null || raw === "" ? fallback : Number(raw); - if (!Number.isInteger(value) || value < 1024 || value > 65535) { - throw new Error(`${name} must be an integer port between 1024 and 65535.`); - } - return value; -} - -function assertDistinctPorts(ports) { - const values = Object.entries(ports); - const seen = new Map(); - for (const [name, value] of values) { - if (seen.has(value)) throw new Error(`${name} conflicts with ${seen.get(value)} on port ${value}.`); - seen.set(value, name); - } - for (const [name, value] of [["api", ports.api], ["web", ports.web]]) { - if (value === 4910 || value === 4911) { - throw new Error(`${name} port ${value} is reserved for an installed SpaceApp; choose another dev port.`); - } - } -} - -function inside(parent, child) { - const rel = relative(parent, child); - return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); -} - -export async function assertSafeStateRoot(stateRoot, root = repoRoot) { - const resolvedState = resolve(stateRoot); - const resolvedRoot = resolve(root); - const resolvedHome = resolve(homedir()); - if (resolvedState === resolve(sep) || resolvedState === resolvedRoot || resolvedState === resolvedHome) { - throw new Error(`Unsafe dev-stack state directory: ${resolvedState}`); - } - if (inside(resolvedState, resolvedRoot) && resolvedState !== resolvedRoot) { - throw new Error("The dev-stack state directory may not be an ancestor of the repository."); - } - if (existsSync(resolvedState)) { - const info = await lstat(resolvedState); - if (info.isSymbolicLink()) throw new Error("The dev-stack state directory may not be a symlink."); - } - return resolvedState; -} - -export function parsePinnedImages(composeText) { - const postgres = composeText.match(/image:\s*(pgvector\/pgvector:[^\s]+@sha256:[a-f0-9]{64})/i)?.[1]; - const temporal = composeText.match(/image:\s*(temporalio\/auto-setup:[^\s]+@sha256:[a-f0-9]{64})/i)?.[1]; - if (!postgres || !temporal) throw new Error("Could not read digest-pinned PostgreSQL and Temporal images from the launcher Compose template."); - return { postgres, temporal }; -} - -export function parseOpenCodeVersion(dockerfileText) { - const version = dockerfileText.match(/opencode-ai@([0-9]+\.[0-9]+\.[0-9]+)/)?.[1]; - if (!version) throw new Error("Could not read the pinned opencode-ai version from Dockerfile."); - return version; -} - -export async function resolveDevStackConfig(env = process.env, root = repoRoot) { - const rootHash = createHash("sha256").update(await realpath(root)).digest("hex").slice(0, 10); - const stateRoot = await assertSafeStateRoot(env.SPACE_DEV_STACK_DIR || join(root, ".dev-stack"), root); - const ports = { - api: parsePort(env.SPACE_DEV_API_PORT, 5910, "SPACE_DEV_API_PORT"), - web: parsePort(env.SPACE_DEV_WEB_PORT, 5911, "SPACE_DEV_WEB_PORT"), - postgres: parsePort(env.SPACE_DEV_POSTGRES_PORT, 55432, "SPACE_DEV_POSTGRES_PORT"), - temporal: parsePort(env.SPACE_DEV_TEMPORAL_PORT, 57233, "SPACE_DEV_TEMPORAL_PORT") - }; - assertDistinctPorts(ports); - const project = env.SPACE_DEV_COMPOSE_PROJECT || `spaceapp-dev-${rootHash}`; - if (!/^[a-z0-9][a-z0-9_-]{2,50}$/i.test(project)) throw new Error("SPACE_DEV_COMPOSE_PROJECT contains unsupported characters."); - const preferredSocket = join(stateRoot, "run", "cli-host.sock"); - const socketPath = Buffer.byteLength(preferredSocket) <= 96 - ? preferredSocket - : join(tmpdir(), `spaceapp-dev-${rootHash}`, "cli-host.sock"); - return { - root, - rootHash, - stateRoot, - project, - ports, - composeFile: join(stateRoot, "compose.yml"), - configFile: join(stateRoot, "config.json"), - pidFile: join(stateRoot, "supervisor.pid"), - buildMarker: join(stateRoot, "build-marker.json"), - secretsDir: join(stateRoot, "secrets"), - socketPath, - socketRoot: dirname(socketPath), - binDir: join(stateRoot, "bin"), - vendorDir: join(stateRoot, "vendor", "opencode"), - providerRoot: join(stateRoot, "providers", "opencode"), - workspacesRoot: join(stateRoot, "workspaces"), - memoryRoot: join(stateRoot, "memory"), - memoryGraphRoot: join(stateRoot, "memory-graph"), - artifactRoot: join(stateRoot, "artifacts"), - projectsRoot: join(stateRoot, "projects"), - diagnosticsRoot: join(stateRoot, "diagnostics"), - openCodeChatRoot: join(stateRoot, "opencode-chat") - }; -} - -export function renderCompose(config, images) { - return `services:\n postgres:\n image: ${images.postgres}\n restart: unless-stopped\n environment:\n POSTGRES_DB: spaceapp_dev\n POSTGRES_USER: spaceapp\n POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password\n ports:\n - \"127.0.0.1:${config.ports.postgres}:5432\"\n secrets:\n - postgres-password\n volumes:\n - postgres-data:/var/lib/postgresql/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U spaceapp -d spaceapp_dev\"]\n interval: 2s\n timeout: 3s\n retries: 30\n\n temporal:\n image: ${images.temporal}\n restart: unless-stopped\n depends_on:\n postgres:\n condition: service_healthy\n environment:\n DB: postgres12\n DB_PORT: 5432\n POSTGRES_USER: spaceapp\n POSTGRES_SEEDS: postgres\n DYNAMIC_CONFIG_FILE_PATH: config/dynamicconfig/docker.yaml\n entrypoint: [\"/bin/sh\", \"-ec\"]\n command:\n - export POSTGRES_PWD=\"$$(cat /run/secrets/postgres-password)\"; exec /etc/temporal/entrypoint.sh autosetup\n ports:\n - \"127.0.0.1:${config.ports.temporal}:7233\"\n secrets:\n - postgres-password\n healthcheck:\n test: [\"CMD-SHELL\", \"temporal operator cluster health --address temporal:7233 >/dev/null && temporal operator namespace describe --namespace default --address temporal:7233 >/dev/null\"]\n interval: 3s\n timeout: 5s\n retries: 40\n\nsecrets:\n postgres-password:\n file: ./secrets/postgres-password\n\nvolumes:\n postgres-data:\n`; -} - -async function run(command, args, options = {}) { - const { capture = false, allowFailure = false, env = process.env, cwd = repoRoot } = options; - return new Promise((resolvePromise, rejectPromise) => { - const child = spawn(command, args, { - cwd, - env, - stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit" - }); - let stdout = ""; - let stderr = ""; - if (capture) { - child.stdout.on("data", (chunk) => { stdout += chunk.toString(); }); - child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); - } - child.once("error", rejectPromise); - child.once("exit", (code, signal) => { - const result = { code: code ?? 1, signal, stdout: stdout.trim(), stderr: stderr.trim() }; - if (!allowFailure && code !== 0) { - rejectPromise(new Error(`${command} ${args.join(" ")} failed (${code ?? signal}).${stderr ? `\n${stderr}` : ""}`)); - } else { - resolvePromise(result); - } - }); - }); -} - -function composeArgs(config, ...args) { - return ["compose", "-p", config.project, "-f", config.composeFile, ...args]; -} - -async function compose(config, args, options = {}) { - return run("docker", composeArgs(config, ...args), options); -} - -async function privateFile(path, value) { - if (!existsSync(path)) await writeFile(path, `${value}\n`, { mode: 0o600, flag: "wx" }); - await chmod(path, 0o600); -} - -async function prepareState(config) { - for (const path of [ - config.stateRoot, config.secretsDir, config.socketRoot, config.binDir, config.vendorDir, - config.providerRoot, config.workspacesRoot, config.memoryRoot, config.memoryGraphRoot, - config.artifactRoot, config.projectsRoot, config.diagnosticsRoot, config.openCodeChatRoot - ]) await mkdir(path, { recursive: true, mode: 0o700 }); - const postgresPassword = randomBytes(24).toString("base64url"); - await privateFile(join(config.secretsDir, "postgres-password"), postgresPassword); - await privateFile(join(config.secretsDir, "session-secret"), randomBytes(32).toString("hex")); - await privateFile(join(config.secretsDir, "setup-token"), randomBytes(32).toString("hex")); - const storedPassword = (await readFile(join(config.secretsDir, "postgres-password"), "utf8")).trim(); - const databaseUrl = `postgresql://spaceapp:${encodeURIComponent(storedPassword)}@127.0.0.1:${config.ports.postgres}/spaceapp_dev`; - await writeFile(join(config.secretsDir, "database-url"), `${databaseUrl}\n`, { mode: 0o600 }); - await chmod(join(config.secretsDir, "database-url"), 0o600); - return databaseUrl; -} - -async function newestMtime(path) { - if (!existsSync(path)) return 0; - const info = await stat(path); - if (!info.isDirectory()) return info.mtimeMs; - let newest = info.mtimeMs; - for (const entry of await readdir(path, { withFileTypes: true })) { - if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage") continue; - newest = Math.max(newest, await newestMtime(join(path, entry.name))); - } - return newest; -} - -async function ensureBuild(config, force = false) { - if (process.env.SPACE_DEV_SKIP_BUILD === "true" && !force) return; - const keyArtifacts = [ - "packages/contracts/dist/index.js", - "packages/runtime/dist/index.js", - "packages/db/dist/migrate.js", - "packages/cli-host/dist/main.js", - "apps/worker/dist/worker.js" - ].map((path) => join(config.root, path)); - const marker = existsSync(config.buildMarker) ? JSON.parse(await readFile(config.buildMarker, "utf8")) : null; - const sourceMtime = Math.max( - await newestMtime(join(config.root, "packages")), - await newestMtime(join(config.root, "apps", "worker", "src")) - ); - const needsBuild = force || process.env.SPACE_DEV_FORCE_BUILD === "true" || !marker || - keyArtifacts.some((path) => !existsSync(path)) || sourceMtime > Number(marker.sourceMtime || 0); - if (!needsBuild) return; - console.log("[dev:stack] Building source workspaces before migrations..."); - await run("npm", ["run", "build"], { cwd: config.root }); - await writeFile(config.buildMarker, `${JSON.stringify({ sourceMtime, builtAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 }); -} - -async function installOpenCode(config, version) { - const packageJson = join(config.vendorDir, "node_modules", "opencode-ai", "package.json"); - let installedVersion = null; - if (existsSync(packageJson)) { - try { installedVersion = JSON.parse(await readFile(packageJson, "utf8")).version; } catch {} - } - if (installedVersion !== version) { - console.log(`[dev:stack] Installing pinned opencode-ai@${version} inside the dev state directory...`); - await rm(config.vendorDir, { recursive: true, force: true }); - await mkdir(config.vendorDir, { recursive: true, mode: 0o700 }); - await run("npm", ["install", "--prefix", config.vendorDir, "--no-package-lock", "--no-save", "--no-audit", "--no-fund", `opencode-ai@${version}`]); - } - const wrapper = join(config.binDir, "opencode-vscode-parity"); - const binary = join(config.vendorDir, "node_modules", ".bin", "opencode"); - const wrapperText = `#!/bin/sh\nset -eu\nexport HOME=${JSON.stringify(join(config.providerRoot, "home"))}\nexport XDG_CONFIG_HOME=${JSON.stringify(join(config.providerRoot, "config"))}\nexport XDG_DATA_HOME=${JSON.stringify(join(config.providerRoot, "data"))}\nexport XDG_CACHE_HOME=${JSON.stringify(join(config.providerRoot, "cache"))}\nexec ${JSON.stringify(binary)} \"$@\"\n`; - await writeFile(wrapper, wrapperText, { mode: 0o700 }); - await chmod(wrapper, 0o700); - return wrapper; -} - -async function waitContainerHealthy(config, service, timeoutMs = 120_000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const id = (await compose(config, ["ps", "-q", service], { capture: true })).stdout; - if (id) { - const statusResult = await run("docker", ["inspect", "--format", "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}", id], { capture: true, allowFailure: true }); - if (statusResult.stdout === "healthy") return; - if (statusResult.stdout === "unhealthy" || statusResult.stdout === "exited" || statusResult.stdout === "dead") { - throw new Error(`${service} became ${statusResult.stdout} during dev-stack startup.`); - } - } - await sleep(1000); - } - throw new Error(`${service} did not become healthy within ${Math.round(timeoutMs / 1000)} seconds.`); -} - -async function ensureTemporalNamespace(config) { - const describe = await compose(config, ["exec", "-T", "temporal", "temporal", "operator", "namespace", "describe", "--namespace", "spaceapp-dev", "--address", "temporal:7233"], { capture: true, allowFailure: true }); - if (describe.code === 0) return; - await compose(config, ["exec", "-T", "temporal", "temporal", "operator", "namespace", "create", "--namespace", "spaceapp-dev", "--address", "temporal:7233"]); -} - -function sanitizedBaseEnv() { - const env = { ...process.env }; - for (const key of [ - "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "GROK_API_KEY", - "DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GITHUB_TOKEN", "GH_TOKEN" - ]) delete env[key]; - return env; -} - -function sourceEnv(config, databaseUrl, openCodeWrapper) { - return { - ...sanitizedBaseEnv(), - SPACE_INSTANCE: "dev", - SPACE_INSTALL_ROOT: config.root, - SPACE_RUNTIME_STORE: "postgres", - SPACE_DATABASE_URL: databaseUrl, - SPACE_DATABASE_URL_FILE: join(config.secretsDir, "database-url"), - SPACE_SESSION_SECRET_FILE: join(config.secretsDir, "session-secret"), - SPACE_SETUP_TOKEN_FILE: join(config.secretsDir, "setup-token"), - SPACE_DEV_LOGIN: "false", - SPACE_GOOGLE_AUTH_DISABLED: "true", - SPACE_PUBLIC_DISTRIBUTION: "true", - SPACE_OPTIONAL_MODULES: "none", - SPACEAPP_WORKFLOWS_ENABLED: "true", - SPACE_API_HOST: "127.0.0.1", - SPACE_API_PORT: String(config.ports.api), - SPACE_WEB_HOST: "127.0.0.1", - SPACE_WEB_PORT: String(config.ports.web), - SPACE_API_ORIGIN: `http://127.0.0.1:${config.ports.api}`, - SPACE_TEMPORAL_ADDRESS: `127.0.0.1:${config.ports.temporal}`, - SPACE_TEMPORAL_NAMESPACE: "spaceapp-dev", - SPACE_TEMPORAL_TASK_QUEUE: "space-dev-agent-turns", - SPACE_CLI_ENABLED: "true", - SPACE_CLI_HOST_SOCKET: config.socketPath, - SPACE_CLI_COMMAND_PATH: config.binDir, - SPACE_CLI_OPENCODE_COMMAND: openCodeWrapper, - SPACE_CLI_WORKSPACE_ROOT: config.workspacesRoot, - SPACE_CLI_ROOT_ENABLED: "false", - SPACE_BROWSER_SESSIONS_ENABLED: "false", - SPACE_BROWSER_HOST_TRANSPORT: "in-process", - SPACE_MEMORY_ROOT: config.memoryRoot, - SPACE_MEMORY_GRAPH_ROOT: config.memoryGraphRoot, - SPACE_ARTIFACT_ROOT: config.artifactRoot, - SPACE_PROJECTS_ROOT: config.projectsRoot, - SPACE_APP_DIAGNOSTICS_ROOT: config.diagnosticsRoot, - SPACE_OPENCODE_CHAT_STATE_ROOT: config.openCodeChatRoot, - SPACE_ENABLE_DUMMY_TURNS: "false", - SPACE_ROOM_AGENT_AUTOPILOT_ENABLED: "false" - }; -} - -function prefixStream(stream, label, target) { - let pending = ""; - stream.on("data", (chunk) => { - pending += chunk.toString(); - const lines = pending.split(/\r?\n/); - pending = lines.pop() || ""; - for (const line of lines) target.write(`[${label}] ${line}\n`); - }); - stream.on("end", () => { if (pending) target.write(`[${label}] ${pending}\n`); }); -} - -function spawnService(config, name, command, args, env, onUnexpectedExit) { - const child = spawn(command, args, { - cwd: config.root, - env, - detached: process.platform !== "win32", - stdio: ["ignore", "pipe", "pipe"] - }); - prefixStream(child.stdout, name, process.stdout); - prefixStream(child.stderr, name, process.stderr); - child.once("exit", (code, signal) => onUnexpectedExit(name, child, code, signal)); - return child; -} - -function terminateTree(child, signal = "SIGTERM") { - if (!child || child.exitCode != null) return; - try { - if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal); - else child.kill(signal); - } catch (error) { - if (error?.code !== "ESRCH") throw error; - } -} - -async function waitDead(child, timeoutMs = 7000) { - if (!child || child.exitCode != null) return; - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline && child.exitCode == null) await sleep(100); - if (child.exitCode == null) terminateTree(child, "SIGKILL"); -} - -async function waitReady(config, timeoutMs = 120_000) { - const deadline = Date.now() + timeoutMs; - const url = `http://127.0.0.1:${config.ports.api}/readyz`; - while (Date.now() < deadline) { - try { - const response = await fetch(url); - if (response.ok) { - const body = await response.json(); - if (body.ok === true && body.dependencies?.database === "RUNNING" && body.dependencies?.worker === "RUNNING" && body.dependencies?.cliHost === "RUNNING") return body; - } - } catch {} - await sleep(500); - } - throw new Error("The source stack did not reach /readyz with database, worker and CLI host RUNNING."); -} - -function pidAlive(pid) { - if (!Number.isInteger(pid) || pid <= 1) return false; - try { process.kill(pid, 0); return true; } - catch (error) { if (error?.code === "ESRCH") return false; throw error; } -} - -async function readSupervisorPid(config) { - if (!existsSync(config.pidFile)) return null; - const value = Number.parseInt((await readFile(config.pidFile, "utf8")).trim(), 10); - return Number.isInteger(value) ? value : null; -} - -async function waitSupervisorExit(pid, timeoutMs = 15_000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline && pidAlive(pid)) await sleep(100); - if (pidAlive(pid)) throw new Error(`Dev-stack supervisor ${pid} did not stop cleanly.`); -} - -async function remainingDockerResources(config) { - const containers = await run("docker", ["ps", "-aq", "--filter", `label=com.docker.compose.project=${config.project}`], { capture: true, allowFailure: true }); - const volumes = await run("docker", ["volume", "ls", "-q", "--filter", `label=com.docker.compose.project=${config.project}`], { capture: true, allowFailure: true }); - return { containers: containers.stdout.split(/\s+/).filter(Boolean), volumes: volumes.stdout.split(/\s+/).filter(Boolean) }; -} - -export async function down(config) { - const pid = await readSupervisorPid(config); - if (pid && pid !== process.pid && pidAlive(pid)) { - process.kill(pid, "SIGTERM"); - await waitSupervisorExit(pid); - } - if (existsSync(config.composeFile)) await compose(config, ["down", "--remove-orphans"]); -} - -export async function reset(config) { - await down(config); - if (existsSync(config.composeFile)) { - await compose(config, ["down", "-v", "--remove-orphans"]); - } - const remaining = await remainingDockerResources(config); - if (remaining.containers.length || remaining.volumes.length) { - throw new Error(`Reset left owned Docker resources behind; state preserved for retry: ${JSON.stringify(remaining)}`); - } - if (config.socketRoot.startsWith(tmpdir()) && config.socketRoot !== tmpdir()) await rm(config.socketRoot, { recursive: true, force: true }); - await rm(config.stateRoot, { recursive: true, force: true }); - console.log("[dev:stack] Reset complete; owned containers, volumes and state were removed."); -} - -export async function status(config) { - const pid = await readSupervisorPid(config); - const resources = await remainingDockerResources(config); - const payload = { - running: Boolean(pid && pidAlive(pid)), - supervisorPid: pid, - project: config.project, - stateRoot: config.stateRoot, - ports: config.ports, - docker: resources - }; - console.log(JSON.stringify(payload, null, 2)); - return payload; -} - -async function writePublicConfig(config, openCodeVersion) { - await writeFile(config.configFile, `${JSON.stringify({ - root: config.root, - stateRoot: config.stateRoot, - project: config.project, - ports: config.ports, - socketPath: config.socketPath, - openCodeVersion - }, null, 2)}\n`, { mode: 0o600 }); -} - -export async function start(config) { - const existingPid = await readSupervisorPid(config); - if (existingPid && pidAlive(existingPid)) throw new Error(`Dev stack is already running under supervisor ${existingPid}.`); - - const composeTemplate = await readFile(join(config.root, "packages", "run-spaceapp", "templates", "compose.yml"), "utf8"); - const dockerfile = await readFile(join(config.root, "Dockerfile"), "utf8"); - const images = parsePinnedImages(composeTemplate); - const openCodeVersion = parseOpenCodeVersion(dockerfile); - const databaseUrl = await prepareState(config); - await writeFile(config.composeFile, renderCompose(config, images), { mode: 0o600 }); - await writePublicConfig(config, openCodeVersion); - await run("docker", ["compose", "version"]); - - let children = new Map(); - let stopping = false; - let reloadInFlight = Promise.resolve(); - const stopChildren = async () => { - for (const child of children.values()) terminateTree(child); - await Promise.all([...children.values()].map((child) => waitDead(child))); - children = new Map(); - try { await rm(config.socketPath, { force: true }); } catch {} - }; - const fail = (name, child, code, signal) => { - if (stopping || child !== children.get(name)) return; - console.error(`[dev:stack] ${name} exited unexpectedly (${code ?? signal}); stopping the stack.`); - process.exitCode = 1; - void shutdown("child-failure"); - }; - const spawnChildren = async (forceBuild = false) => { - await ensureBuild(config, forceBuild); - const openCodeWrapper = await installOpenCode(config, openCodeVersion); - const env = sourceEnv(config, databaseUrl, openCodeWrapper); - children.set("cli-host", spawnService(config, "cli-host", process.execPath, ["packages/cli-host/dist/main.js"], env, fail)); - children.set("worker", spawnService(config, "worker", process.execPath, ["apps/worker/dist/worker.js"], env, fail)); - children.set("api", spawnService(config, "api", "npm", ["exec", "--", "tsx", "watch", "apps/api/src/server.ts"], env, fail)); - children.set("web", spawnService(config, "web", "npm", ["run", "dev", "-w", "@space/web", "--", "--host", "127.0.0.1", "--port", String(config.ports.web)], env, fail)); - }; - const reload = () => { - reloadInFlight = reloadInFlight.then(async () => { - if (stopping) return; - console.log("[dev:stack] Rebuilding source workspaces and restarting source services..."); - await stopChildren(); - await spawnChildren(true); - await waitReady(config); - console.log("[dev:stack] Rebuild complete; source services are ready."); - }).catch((error) => { - console.error(`[dev:stack] rebuild failed: ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; - void shutdown("rebuild-failure"); - }); - }; - const shutdown = async (reason) => { - if (stopping) return; - stopping = true; - console.log(`[dev:stack] Stopping (${reason})...`); - await stopChildren(); - if (existsSync(config.composeFile)) await compose(config, ["down", "--remove-orphans"], { allowFailure: true }); - try { await rm(config.pidFile, { force: true }); } catch {} - }; - - process.once("SIGINT", () => { void shutdown("SIGINT").finally(() => process.exit(process.exitCode || 0)); }); - process.once("SIGTERM", () => { void shutdown("SIGTERM").finally(() => process.exit(process.exitCode || 0)); }); - process.on("SIGHUP", reload); - - await writeFile(config.pidFile, `${process.pid}\n`, { mode: 0o600 }); - try { - console.log(`[dev:stack] Starting digest-pinned PostgreSQL and Temporal (${config.project})...`); - await compose(config, ["up", "-d", "postgres", "temporal"]); - await Promise.all([waitContainerHealthy(config, "postgres"), waitContainerHealthy(config, "temporal")]); - await ensureTemporalNamespace(config); - await ensureBuild(config); - console.log("[dev:stack] Applying database migrations..."); - await run("npm", ["run", "db:migrate"], { cwd: config.root, env: { ...sanitizedBaseEnv(), SPACE_DATABASE_URL: databaseUrl } }); - await spawnChildren(false); - await waitReady(config); - const setupToken = (await readFile(join(config.secretsDir, "setup-token"), "utf8")).trim(); - console.log("\n============================================================"); - console.log("SpaceApp dev stack is READY"); - console.log(`Web: http://127.0.0.1:${config.ports.web}`); - console.log(`API: http://127.0.0.1:${config.ports.api}`); - console.log(`Owner setup token: ${setupToken}`); - console.log("Package changes: npm run dev:stack -- rebuild"); - console.log("Stop: npm run dev:stack -- down | Reset: npm run dev:stack -- reset"); - console.log("============================================================\n"); - await new Promise(() => {}); - } catch (error) { - process.exitCode = 1; - await shutdown("startup-failure"); - throw error; - } -} - -export async function rebuild(config) { - const pid = await readSupervisorPid(config); - if (!pid || !pidAlive(pid)) { - await ensureBuild(config, true); - console.log("[dev:stack] Rebuilt source workspaces; no running supervisor to restart."); - return; - } - process.kill(pid, "SIGHUP"); - console.log(`[dev:stack] Rebuild requested from supervisor ${pid}.`); -} - -async function main() { - const command = process.argv[2] || "up"; - const config = await resolveDevStackConfig(); - if (command === "up" || command === "start") return start(config); - if (command === "down" || command === "stop") return down(config); - if (command === "reset") return reset(config); - if (command === "status") return status(config); - if (command === "rebuild") return rebuild(config); - throw new Error(`Unknown dev:stack command ${JSON.stringify(command)}. Use up, down, reset, status, or rebuild.`); -} - -if (process.argv[1] && resolve(process.argv[1]) === scriptPath) { - main().catch((error) => { - process.stderr.write(`[dev:stack] ERROR: ${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; - }); -} From 0ba88948142414265c6b52b67f405ba0d8658f6d Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 15:59:53 +0900 Subject: [PATCH 15/71] chore(dev): remove superseded verification prototype --- scripts/dev-stack-verify.mjs | 112 ----------------------------------- 1 file changed, 112 deletions(-) delete mode 100644 scripts/dev-stack-verify.mjs diff --git a/scripts/dev-stack-verify.mjs b/scripts/dev-stack-verify.mjs deleted file mode 100644 index eca1178d0..000000000 --- a/scripts/dev-stack-verify.mjs +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env node -import { readFile } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptPath = fileURLToPath(import.meta.url); -const root = resolve(dirname(scriptPath), ".."); -const stateRoot = resolve(process.env.SPACE_DEV_STACK_DIR || join(root, ".dev-stack")); -const config = JSON.parse(await readFile(join(stateRoot, "config.json"), "utf8")); -const api = `http://127.0.0.1:${config.ports.api}`; -const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); - -function setCookieHeader(response) { - const values = typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : []; - return values[0] || response.headers.get("set-cookie") || ""; -} - -function cookiePair(raw) { - return raw.split(";", 1)[0] || ""; -} - -async function jsonRequest(path, { method = "GET", body, cookie, csrf } = {}) { - const headers = { accept: "application/json" }; - if (body !== undefined) headers["content-type"] = "application/json"; - if (cookie) headers.cookie = cookie; - if (csrf) headers["x-space-csrf-token"] = csrf; - const response = await fetch(`${api}${path}`, { - method, - headers, - body: body === undefined ? undefined : JSON.stringify(body) - }); - const text = await response.text(); - let payload = null; - try { payload = text ? JSON.parse(text) : null; } catch { payload = text; } - if (!response.ok) throw new Error(`${method} ${path} failed (${response.status}): ${text.slice(0, 800)}`); - return { response, payload }; -} - -async function waitReady() { - const deadline = Date.now() + 120_000; - while (Date.now() < deadline) { - try { - const { payload } = await jsonRequest("/readyz"); - if (payload?.ok === true && payload.dependencies?.database === "RUNNING" && payload.dependencies?.worker === "RUNNING" && payload.dependencies?.cliHost === "RUNNING") return payload; - } catch {} - await sleep(500); - } - throw new Error("Timed out waiting for the real source stack to become ready."); -} - -const ready = await waitReady(); -console.log(`[dev-stack-verify] PASS readiness database=${ready.dependencies.database} worker=${ready.dependencies.worker} cliHost=${ready.dependencies.cliHost}`); - -const setupToken = (await readFile(join(stateRoot, "secrets", "setup-token"), "utf8")).trim(); -const email = "dev-stack-ci@example.com"; -const password = "dev-stack-ci-password-42!"; -const claimed = await jsonRequest("/api/setup/claim", { - method: "POST", - body: { token: setupToken, email, password } -}); -const cookie = cookiePair(setCookieHeader(claimed.response)); -if (!cookie) throw new Error("Owner setup succeeded without returning a session cookie."); -const starterRoomId = claimed.payload?.starterRoomId; -if (typeof starterRoomId !== "string" || !starterRoomId.startsWith("room:")) throw new Error("Owner setup did not return a starter room id."); -console.log(`[dev-stack-verify] PASS owner setup starterRoom=${starterRoomId}`); - -const csrfResponse = await jsonRequest("/api/auth/csrf", { cookie }); -const csrf = csrfResponse.payload?.csrfToken; -if (typeof csrf !== "string" || !csrf) throw new Error("Authenticated owner session did not return a CSRF token."); - -await jsonRequest(`/api/rooms/${encodeURIComponent(starterRoomId)}`, { - method: "PATCH", - cookie, - csrf, - body: { projectPath: root } -}); -console.log("[dev-stack-verify] PASS starter room uses the source checkout instead of /opt/spaceapp"); - -const panesResponse = await jsonRequest(`/api/panes?roomId=${encodeURIComponent(starterRoomId)}`, { cookie }); -const panesPayload = panesResponse.payload; -const panes = Array.isArray(panesPayload) ? panesPayload : (panesPayload?.data ?? panesPayload?.panes ?? []); -const openCodePane = panes.find((pane) => pane?.terminalRuntimeId === "cli:opencode" || (pane?.mode === "TERMINAL" && /OpenCode/i.test(pane?.title || ""))); -if (!openCodePane?.id) throw new Error("Getting Started does not contain an OpenCode terminal pane."); - -const started = await jsonRequest(`/api/panes/${encodeURIComponent(openCodePane.id)}/cli/session`, { - method: "POST", - cookie, - csrf, - body: { runtimeId: "cli:opencode" } -}); -const initialStatus = started.payload?.session?.status ?? started.payload?.status ?? null; -console.log(`[dev-stack-verify] OpenCode start returned status=${initialStatus ?? "unknown"}`); - -const deadline = Date.now() + 60_000; -let session = null; -while (Date.now() < deadline) { - const current = await jsonRequest(`/api/panes/${encodeURIComponent(openCodePane.id)}/cli/session`, { cookie }); - session = current.payload?.session ?? current.payload; - if (["RUNNING", "READY", "ATTACHED"].includes(session?.status)) break; - if (["ERROR", "EXITED", "FAILED"].includes(session?.status)) { - throw new Error(`OpenCode pane entered ${session.status}: ${session.statusReason || "no reason"}`); - } - await sleep(500); -} -if (!session || !["RUNNING", "READY", "ATTACHED"].includes(session.status)) { - throw new Error(`OpenCode pane did not reach an active status; last status=${session?.status ?? "missing"}.`); -} -console.log(`[dev-stack-verify] PASS OpenCode pane active status=${session.status}`); - -const finalReady = await jsonRequest("/readyz"); -if (finalReady.payload?.ok !== true) throw new Error("Stack lost readiness after starting the OpenCode pane."); -console.log("[dev-stack-verify] PASS source-stack acceptance"); From 9fdb21b38f8c60e6ec61db20f0d2e1feddbc9dfb Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:00:00 +0900 Subject: [PATCH 16/71] chore(dev): remove superseded test prototype --- scripts/tests/dev-stack.test.mjs | 66 -------------------------------- 1 file changed, 66 deletions(-) delete mode 100644 scripts/tests/dev-stack.test.mjs diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs deleted file mode 100644 index 4d139fe1d..000000000 --- a/scripts/tests/dev-stack.test.mjs +++ /dev/null @@ -1,66 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import test from "node:test"; -import { - assertSafeStateRoot, - parseOpenCodeVersion, - parsePinnedImages, - renderCompose, - resolveDevStackConfig -} from "../dev-stack.mjs"; - -test("dev stack derives third-party pins from canonical source files", () => { - const images = parsePinnedImages(` -services: - postgres: - image: pgvector/pgvector:0.8.3-pg17-trixie@sha256:${"a".repeat(64)} - temporal: - image: temporalio/auto-setup:1.29.1@sha256:${"b".repeat(64)} -`); - assert.equal(images.postgres, `pgvector/pgvector:0.8.3-pg17-trixie@sha256:${"a".repeat(64)}`); - assert.equal(images.temporal, `temporalio/auto-setup:1.29.1@sha256:${"b".repeat(64)}`); - assert.equal(parseOpenCodeVersion("RUN npm install --global opencode-ai@1.18.4"), "1.18.4"); - assert.throws(() => parsePinnedImages("services: {}"), /digest-pinned/); - assert.throws(() => parseOpenCodeVersion("RUN npm install opencode-ai@latest"), /pinned/); -}); - -test("dev stack defaults are isolated from installed SpaceApp and bind Docker ports to loopback", async () => { - const root = await mkdtemp(join(tmpdir(), "spaceapp-dev-test-")); - try { - const config = await resolveDevStackConfig({}, root); - assert.notEqual(config.ports.api, 4910); - assert.notEqual(config.ports.web, 4911); - assert.match(config.project, /^spaceapp-dev-[a-f0-9]{10}$/); - const compose = renderCompose(config, { - postgres: `pgvector/pgvector:0.8.3@sha256:${"c".repeat(64)}`, - temporal: `temporalio/auto-setup:1.29.1@sha256:${"d".repeat(64)}` - }); - for (const port of [config.ports.postgres, config.ports.temporal]) { - assert.match(compose, new RegExp(`127\\.0\\.0\\.1:${port}:`)); - } - assert.match(compose, /POSTGRES_PASSWORD_FILE: \/run\/secrets\/postgres-password/); - assert.match(compose, /condition: service_healthy/); - assert.match(compose, /temporal operator cluster health --address temporal:7233/); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test("dev stack rejects unsafe state roots and installed-instance ports", async () => { - const root = await mkdtemp(join(tmpdir(), "spaceapp-dev-root-")); - try { - await assert.rejects(() => assertSafeStateRoot(root, root), /Unsafe dev-stack state directory/); - await assert.rejects( - () => resolveDevStackConfig({ SPACE_DEV_API_PORT: "4910" }, root), - /reserved for an installed SpaceApp/ - ); - await assert.rejects( - () => resolveDevStackConfig({ SPACE_DEV_API_PORT: "6000", SPACE_DEV_WEB_PORT: "6000" }, root), - /conflicts/ - ); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); From 08555a2ab3b76d38e850d5a7827953079a6035b5 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:00:46 +0900 Subject: [PATCH 17/71] docs(dev): add platform troubleshooting details --- CONTRIBUTING.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b144b1cb7..58c273c30 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,10 +105,26 @@ That proof performs normal owner setup, points the Getting Started room at the current checkout rather than `/opt/spaceapp`, starts its OpenCode terminal pane through the real CLI host, requires an active CLI session status, and verifies that the stack remains ready. The dedicated GitHub Actions workflow runs this -proof on Linux. Windows contributors should use WSL2 with Docker integration; -macOS contributors should use Docker Desktop or another compatible Docker -engine. Those host paths are contributor environments, not installer-release -certification. +proof on Linux. Windows 11 contributors should use WSL2 with Docker Desktop's +WSL integration or a Docker engine running inside WSL2. macOS contributors can +use Docker Desktop or Colima. Those host paths are contributor environments, +not installer-release certification. + +### Troubleshooting the source stack + +- If Docker is unavailable, run `docker compose version` first and start Docker + Desktop, Colima, or the Docker daemon before `npm run dev:stack`. +- If a default dev port is occupied, set the corresponding + `SPACE_DEV_*_PORT` variable rather than reusing installed ports `4910/4911`. +- If a package change is not reflected in a running source service, run + `npm run dev:stack -- rebuild`; Vite handles `apps/web/src` edits directly + and the API runs under `tsx watch`. +- If startup is interrupted, `npm run dev:stack -- down` stops the owned + supervisor and Compose project. Use `npm run dev:stack -- reset` for a clean + database and state directory; a failed volume removal is reported and leaves + the state in place so cleanup can be retried safely. +- Unix socket paths are automatically shortened under the system temporary + directory when `.dev-stack` would exceed the platform socket-path limit. ## Make and verify a small change From 854ec09afad5c86e73a9ab6427f828977acc2c85 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:00:59 +0900 Subject: [PATCH 18/71] ci(dev): assert full source-stack cleanup --- .github/workflows/dev-stack.yml | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml index d21ed3e3c..425608e2f 100644 --- a/.github/workflows/dev-stack.yml +++ b/.github/workflows/dev-stack.yml @@ -66,8 +66,38 @@ jobs: down_status=$? npm run dev:stack -- reset reset_status=$? + + shell_pid="$(cat "$RUNNER_TEMP/dev-stack-shell.pid" 2>/dev/null || true)" + for _ in $(seq 1 50); do + if [ -z "$shell_pid" ] || ! kill -0 "$shell_pid" 2>/dev/null; then + break + fi + sleep 0.1 + done + process_status=0 + if [ -n "$shell_pid" ] && kill -0 "$shell_pid" 2>/dev/null; then + echo "dev-stack launcher process $shell_pid is still alive after reset" >&2 + process_status=1 + kill "$shell_pid" 2>/dev/null || true + fi + + status_json="$(npm run --silent dev:stack -- status 2>/dev/null)" + echo "$status_json" + if echo "$status_json" | grep -q '"running": true'; then + echo "dev-stack supervisor still reports running after reset" >&2 + process_status=1 + fi + if ! echo "$status_json" | grep -q '"containers": \[\]'; then + echo "dev-stack containers remain after reset" >&2 + process_status=1 + fi + if ! echo "$status_json" | grep -q '"volumes": \[\]'; then + echo "dev-stack volumes remain after reset" >&2 + process_status=1 + fi + echo "--- dev-stack log ---" cat "$RUNNER_TEMP/dev-stack.log" 2>/dev/null || true - if [ "$down_status" -ne 0 ] || [ "$reset_status" -ne 0 ]; then + if [ "$down_status" -ne 0 ] || [ "$reset_status" -ne 0 ] || [ "$process_status" -ne 0 ]; then exit 1 fi From dfaa1a8b6c2e27e04769ee26007588169a608703 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:03:04 +0900 Subject: [PATCH 19/71] fix(dev): expose OpenCode wrapper by controlled command name --- deploy/docker/dev-stack-lib.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/deploy/docker/dev-stack-lib.mjs b/deploy/docker/dev-stack-lib.mjs index bf63782e1..28ad9909b 100644 --- a/deploy/docker/dev-stack-lib.mjs +++ b/deploy/docker/dev-stack-lib.mjs @@ -200,6 +200,9 @@ export async function ensureTemporalNamespace(config) { export function sourceEnv(config, databaseUrl, sessionSecret, openCodeWrapper) { const env = { ...process.env }; for (const key of ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "GROK_API_KEY", "DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GITHUB_TOKEN", "GH_TOKEN"]) delete env[key]; + if (openCodeWrapper !== join(config.binDir, "opencode-vscode-parity")) { + throw new Error("OpenCode wrapper must live inside the controlled dev-stack command directory."); + } return { ...env, SPACE_INSTANCE: "dev", @@ -224,7 +227,7 @@ export function sourceEnv(config, databaseUrl, sessionSecret, openCodeWrapper) { SPACE_CLI_ENABLED: "true", SPACE_CLI_HOST_SOCKET: config.socketPath, SPACE_CLI_COMMAND_PATH: config.binDir, - SPACE_CLI_OPENCODE_COMMAND: openCodeWrapper, + SPACE_CLI_OPENCODE_COMMAND: "opencode-vscode-parity", SPACE_CLI_WORKSPACE_ROOT: config.workspacesRoot, SPACE_CLI_ROOT_ENABLED: "false", SPACE_BROWSER_SESSIONS_ENABLED: "false", From 642710ec55ee4ebe419eec6b32cf45e8e8c73ced Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:03:19 +0900 Subject: [PATCH 20/71] test(dev): guard controlled OpenCode command resolution --- deploy/docker/dev-stack.test.mjs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/deploy/docker/dev-stack.test.mjs b/deploy/docker/dev-stack.test.mjs index 28b19b5f5..87064bb99 100644 --- a/deploy/docker/dev-stack.test.mjs +++ b/deploy/docker/dev-stack.test.mjs @@ -8,7 +8,8 @@ import { parseOpenCodeVersion, parsePinnedImages, renderCompose, - resolveConfig + resolveConfig, + sourceEnv } from "./dev-stack-lib.mjs"; test("source stack derives third-party pins from canonical files", () => { @@ -41,6 +42,15 @@ test("defaults isolate installed SpaceApp and bind Docker ports to loopback", as assert.match(compose, /POSTGRES_PASSWORD_FILE: \/run\/secrets\/postgres-password/); assert.match(compose, /condition: service_healthy/); assert.match(compose, /temporal operator cluster health --address temporal:7233/); + + const wrapper = join(config.binDir, "opencode-vscode-parity"); + const env = sourceEnv(config, "postgresql://spaceapp:test@127.0.0.1:55432/spaceapp_dev", "session-secret-with-enough-length", wrapper); + assert.equal(env.SPACE_CLI_COMMAND_PATH, config.binDir); + assert.equal(env.SPACE_CLI_OPENCODE_COMMAND, "opencode-vscode-parity"); + assert.equal(env.SPACE_DEV_LOGIN, "false"); + assert.equal(env.SPACE_API_HOST, "127.0.0.1"); + assert.equal(env.SPACE_WEB_HOST, "127.0.0.1"); + assert.throws(() => sourceEnv(config, "postgresql://x", "session-secret-with-enough-length", "/tmp/opencode-vscode-parity"), /controlled dev-stack command directory/); } finally { await rm(root, { recursive: true, force: true }); } From 58e0fdd60e3a86a0757171e8a60531f40f9da237 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:04:42 +0900 Subject: [PATCH 21/71] fix(ci): wait for source stack bootstrap state --- deploy/docker/dev-stack-verify.mjs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/deploy/docker/dev-stack-verify.mjs b/deploy/docker/dev-stack-verify.mjs index 111397e65..044a49add 100644 --- a/deploy/docker/dev-stack-verify.mjs +++ b/deploy/docker/dev-stack-verify.mjs @@ -5,10 +5,25 @@ import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const stateRoot = resolve(process.env.SPACE_DEV_STACK_DIR || join(root, ".dev-stack")); -const config = JSON.parse(await readFile(join(stateRoot, "config.json"), "utf8")); -const api = `http://127.0.0.1:${config.ports.api}`; const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +async function waitForJson(path, timeoutMs = 120_000) { + const deadline = Date.now() + timeoutMs; + let lastError = null; + while (Date.now() < deadline) { + try { + return JSON.parse(await readFile(path, "utf8")); + } catch (error) { + lastError = error; + await sleep(250); + } + } + throw new Error(`Timed out waiting for source-stack bootstrap file ${path}: ${lastError instanceof Error ? lastError.message : String(lastError)}`); +} + +const config = await waitForJson(join(stateRoot, "config.json")); +const api = `http://127.0.0.1:${config.ports.api}`; + function firstCookie(response) { const values = typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : []; return (values[0] || response.headers.get("set-cookie") || "").split(";", 1)[0] || ""; @@ -32,7 +47,7 @@ async function request(path, { method = "GET", body, cookie, csrf, csrfHeader = } async function waitReady() { - const deadline = Date.now() + 120_000; + const deadline = Date.now() + 180_000; while (Date.now() < deadline) { try { const { payload } = await request("/readyz"); From 18b18be227f2404a1c260d1589fca17ea552e21b Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:07:18 +0900 Subject: [PATCH 22/71] test(dev): verify owner credentials can sign in --- deploy/docker/dev-stack-verify.mjs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/deploy/docker/dev-stack-verify.mjs b/deploy/docker/dev-stack-verify.mjs index 044a49add..bc1c42f4e 100644 --- a/deploy/docker/dev-stack-verify.mjs +++ b/deploy/docker/dev-stack-verify.mjs @@ -62,16 +62,27 @@ const ready = await waitReady(); console.log(`[dev-stack-verify] PASS readiness database=${ready.dependencies.database} worker=${ready.dependencies.worker} cliHost=${ready.dependencies.cliHost}`); const setupToken = (await readFile(join(stateRoot, "secrets", "setup-token"), "utf8")).trim(); +const email = "dev-stack-ci@example.com"; +const password = "dev-stack-ci-password-42!"; const claimed = await request("/api/setup/claim", { method: "POST", - body: { token: setupToken, email: "dev-stack-ci@example.com", password: "dev-stack-ci-password-42!" } + body: { token: setupToken, email, password } }); -const cookie = firstCookie(claimed.response); -if (!cookie) throw new Error("Owner setup succeeded without returning a session cookie."); const starterRoomId = claimed.payload?.starterRoomId; if (typeof starterRoomId !== "string" || !starterRoomId.startsWith("room:")) throw new Error("Owner setup did not return the Getting Started room id."); +if (!firstCookie(claimed.response)) throw new Error("Owner setup succeeded without returning a session cookie."); console.log(`[dev-stack-verify] PASS owner setup starterRoom=${starterRoomId}`); +const login = await request("/api/auth/login", { + method: "POST", + body: { email, password } +}); +const cookie = firstCookie(login.response); +if (!cookie || login.payload?.isAuthenticated !== true || login.payload?.user?.role !== "ADMIN") { + throw new Error("The owner created by setup could not sign in with its configured credentials."); +} +console.log("[dev-stack-verify] PASS owner credentials sign in through /api/auth/login"); + const csrfResponse = await request("/api/auth/csrf", { cookie }); const csrf = csrfResponse.payload?.csrfToken; const csrfHeader = csrfResponse.payload?.headerName || "x-space-csrf-token"; From 59415c0651594cddc24203ca67fccaabb8eea5c3 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:12:49 +0900 Subject: [PATCH 23/71] fix(dev): tolerate Temporal cold-start health lag --- deploy/docker/dev-stack.mjs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/deploy/docker/dev-stack.mjs b/deploy/docker/dev-stack.mjs index a5d2987af..0c21c8c92 100644 --- a/deploy/docker/dev-stack.mjs +++ b/deploy/docker/dev-stack.mjs @@ -74,6 +74,29 @@ async function waitSupervisorExit(pid, timeoutMs = 15_000) { if (pidAlive(pid)) throw new Error(`Dev-stack supervisor ${pid} did not stop cleanly.`); } +async function waitServiceHealthyWithDiagnostics(config, service, timeoutMs) { + const deadline = Date.now() + timeoutMs; + let lastStatus = "missing"; + while (Date.now() < deadline) { + const id = (await compose(config, ["ps", "-q", service], { capture: true })).stdout; + if (id) { + const status = await run("docker", ["inspect", "--format", "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}", id], { + capture: true, + allowFailure: true + }); + lastStatus = status.stdout || `inspect-exit-${status.code}`; + if (lastStatus === "healthy") return; + if (["exited", "dead"].includes(lastStatus)) break; + } + await sleep(1000); + } + + const logs = await compose(config, ["logs", "--no-color", "--tail", "200", service], { capture: true, allowFailure: true }); + const diagnostic = [logs.stdout, logs.stderr].filter(Boolean).join("\n"); + if (diagnostic) process.stderr.write(`[dev:stack] ${service} diagnostic logs:\n${diagnostic}\n`); + throw new Error(`${service} did not become healthy within ${Math.round(timeoutMs / 1000)} seconds; last status=${lastStatus}.`); +} + export async function down(config) { const pid = await readSupervisorPid(config); if (pid && pid !== process.pid && pidAlive(pid)) { @@ -205,7 +228,10 @@ export async function start(config) { try { console.log(`[dev:stack] Starting digest-pinned PostgreSQL and Temporal (${config.project})...`); await compose(config, ["up", "-d", "postgres", "temporal"]); - await Promise.all([waitHealthy(config, "postgres"), waitHealthy(config, "temporal")]); + await Promise.all([ + waitHealthy(config, "postgres"), + waitServiceHealthyWithDiagnostics(config, "temporal", 240_000) + ]); await ensureTemporalNamespace(config); await ensureBuild(config); console.log("[dev:stack] Applying database migrations..."); From 6cac804bf88a323ab5b5e4f7cbcc1a19771182f8 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:13:13 +0900 Subject: [PATCH 24/71] fix(ci): budget for cold source-stack startup --- deploy/docker/dev-stack-verify.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/docker/dev-stack-verify.mjs b/deploy/docker/dev-stack-verify.mjs index bc1c42f4e..6515d6338 100644 --- a/deploy/docker/dev-stack-verify.mjs +++ b/deploy/docker/dev-stack-verify.mjs @@ -47,7 +47,7 @@ async function request(path, { method = "GET", body, cookie, csrf, csrfHeader = } async function waitReady() { - const deadline = Date.now() + 180_000; + const deadline = Date.now() + 420_000; while (Date.now() < deadline) { try { const { payload } = await request("/readyz"); From f925f5064fb0bef87431c213396b592c3ab2f495 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:14:22 +0900 Subject: [PATCH 25/71] fix(dev): pass database password to Temporal without secret bind --- deploy/docker/dev-stack-lib.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/deploy/docker/dev-stack-lib.mjs b/deploy/docker/dev-stack-lib.mjs index 28ad9909b..c9fa1d266 100644 --- a/deploy/docker/dev-stack-lib.mjs +++ b/deploy/docker/dev-stack-lib.mjs @@ -88,7 +88,7 @@ export async function resolveConfig(env = process.env, root = repoRoot) { } export function renderCompose(config, images) { - return `services:\n postgres:\n image: ${images.postgres}\n restart: unless-stopped\n environment:\n POSTGRES_DB: spaceapp_dev\n POSTGRES_USER: spaceapp\n POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password\n ports:\n - \"127.0.0.1:${config.ports.postgres}:5432\"\n secrets: [postgres-password]\n volumes:\n - postgres-data:/var/lib/postgresql/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U spaceapp -d spaceapp_dev\"]\n interval: 2s\n timeout: 3s\n retries: 30\n temporal:\n image: ${images.temporal}\n restart: unless-stopped\n depends_on:\n postgres:\n condition: service_healthy\n environment:\n DB: postgres12\n DB_PORT: 5432\n POSTGRES_USER: spaceapp\n POSTGRES_SEEDS: postgres\n DYNAMIC_CONFIG_FILE_PATH: config/dynamicconfig/docker.yaml\n entrypoint: [\"/bin/sh\", \"-ec\"]\n command:\n - export POSTGRES_PWD=\"$$(cat /run/secrets/postgres-password)\"; exec /etc/temporal/entrypoint.sh autosetup\n ports:\n - \"127.0.0.1:${config.ports.temporal}:7233\"\n secrets: [postgres-password]\n healthcheck:\n test: [\"CMD-SHELL\", \"temporal operator cluster health --address temporal:7233 >/dev/null && temporal operator namespace describe --namespace default --address temporal:7233 >/dev/null\"]\n interval: 3s\n timeout: 5s\n retries: 40\nsecrets:\n postgres-password:\n file: ./secrets/postgres-password\nvolumes:\n postgres-data:\n`; + return `services:\n postgres:\n image: ${images.postgres}\n restart: unless-stopped\n environment:\n POSTGRES_DB: spaceapp_dev\n POSTGRES_USER: spaceapp\n POSTGRES_PASSWORD: \${SPACEAPP_DEV_POSTGRES_PASSWORD:-dev-stack-password-not-supplied}\n ports:\n - \"127.0.0.1:${config.ports.postgres}:5432\"\n volumes:\n - postgres-data:/var/lib/postgresql/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U spaceapp -d spaceapp_dev\"]\n interval: 2s\n timeout: 3s\n retries: 30\n temporal:\n image: ${images.temporal}\n restart: unless-stopped\n depends_on:\n postgres:\n condition: service_healthy\n environment:\n DB: postgres12\n DB_PORT: 5432\n POSTGRES_USER: spaceapp\n POSTGRES_SEEDS: postgres\n POSTGRES_PWD: \${SPACEAPP_DEV_POSTGRES_PASSWORD:-dev-stack-password-not-supplied}\n DYNAMIC_CONFIG_FILE_PATH: config/dynamicconfig/docker.yaml\n ports:\n - \"127.0.0.1:${config.ports.temporal}:7233\"\n healthcheck:\n test: [\"CMD-SHELL\", \"temporal operator cluster health --address temporal:7233 >/dev/null && temporal operator namespace describe --namespace default --address temporal:7233 >/dev/null\"]\n interval: 3s\n timeout: 5s\n retries: 40\nvolumes:\n postgres-data:\n`; } export async function run(command, args, { capture = false, allowFailure = false, env = process.env, cwd = repoRoot } = {}) { @@ -129,6 +129,7 @@ export async function prepareState(config) { await privateFile(join(config.secretsDir, "setup-token"), randomBytes(32).toString("hex")); const password = (await readFile(join(config.secretsDir, "postgres-password"), "utf8")).trim(); const sessionSecret = (await readFile(join(config.secretsDir, "session-secret"), "utf8")).trim(); + process.env.SPACEAPP_DEV_POSTGRES_PASSWORD = password; const databaseUrl = `postgresql://spaceapp:${encodeURIComponent(password)}@127.0.0.1:${config.ports.postgres}/spaceapp_dev`; await writeFile(join(config.secretsDir, "database-url"), `${databaseUrl}\n`, { mode: 0o600 }); await chmod(join(config.secretsDir, "database-url"), 0o600); @@ -199,7 +200,7 @@ export async function ensureTemporalNamespace(config) { export function sourceEnv(config, databaseUrl, sessionSecret, openCodeWrapper) { const env = { ...process.env }; - for (const key of ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "GROK_API_KEY", "DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GITHUB_TOKEN", "GH_TOKEN"]) delete env[key]; + for (const key of ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "GROK_API_KEY", "DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GITHUB_TOKEN", "GH_TOKEN", "SPACEAPP_DEV_POSTGRES_PASSWORD"]) delete env[key]; if (openCodeWrapper !== join(config.binDir, "opencode-vscode-parity")) { throw new Error("OpenCode wrapper must live inside the controlled dev-stack command directory."); } From 8119e42f71f5863b0adcbd6d8db18319ae9235bc Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:14:37 +0900 Subject: [PATCH 26/71] test(dev): cover Temporal password handoff --- deploy/docker/dev-stack.test.mjs | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/deploy/docker/dev-stack.test.mjs b/deploy/docker/dev-stack.test.mjs index 87064bb99..604fcbac0 100644 --- a/deploy/docker/dev-stack.test.mjs +++ b/deploy/docker/dev-stack.test.mjs @@ -39,18 +39,28 @@ test("defaults isolate installed SpaceApp and bind Docker ports to loopback", as temporal: `temporalio/auto-setup:1.29.1@sha256:${"d".repeat(64)}` }); for (const port of [config.ports.postgres, config.ports.temporal]) assert.match(compose, new RegExp(`127\\.0\\.0\\.1:${port}:`)); - assert.match(compose, /POSTGRES_PASSWORD_FILE: \/run\/secrets\/postgres-password/); + assert.match(compose, /POSTGRES_PASSWORD: \$\{SPACEAPP_DEV_POSTGRES_PASSWORD/); + assert.match(compose, /POSTGRES_PWD: \$\{SPACEAPP_DEV_POSTGRES_PASSWORD/); + assert.doesNotMatch(compose, /\/run\/secrets\/postgres-password|secrets:\s*\[postgres-password\]/); assert.match(compose, /condition: service_healthy/); assert.match(compose, /temporal operator cluster health --address temporal:7233/); const wrapper = join(config.binDir, "opencode-vscode-parity"); - const env = sourceEnv(config, "postgresql://spaceapp:test@127.0.0.1:55432/spaceapp_dev", "session-secret-with-enough-length", wrapper); - assert.equal(env.SPACE_CLI_COMMAND_PATH, config.binDir); - assert.equal(env.SPACE_CLI_OPENCODE_COMMAND, "opencode-vscode-parity"); - assert.equal(env.SPACE_DEV_LOGIN, "false"); - assert.equal(env.SPACE_API_HOST, "127.0.0.1"); - assert.equal(env.SPACE_WEB_HOST, "127.0.0.1"); - assert.throws(() => sourceEnv(config, "postgresql://x", "session-secret-with-enough-length", "/tmp/opencode-vscode-parity"), /controlled dev-stack command directory/); + const oldComposePassword = process.env.SPACEAPP_DEV_POSTGRES_PASSWORD; + process.env.SPACEAPP_DEV_POSTGRES_PASSWORD = "compose-only-secret"; + try { + const env = sourceEnv(config, "postgresql://spaceapp:test@127.0.0.1:55432/spaceapp_dev", "session-secret-with-enough-length", wrapper); + assert.equal(env.SPACE_CLI_COMMAND_PATH, config.binDir); + assert.equal(env.SPACE_CLI_OPENCODE_COMMAND, "opencode-vscode-parity"); + assert.equal(env.SPACE_DEV_LOGIN, "false"); + assert.equal(env.SPACE_API_HOST, "127.0.0.1"); + assert.equal(env.SPACE_WEB_HOST, "127.0.0.1"); + assert.equal(env.SPACEAPP_DEV_POSTGRES_PASSWORD, undefined); + assert.throws(() => sourceEnv(config, "postgresql://x", "session-secret-with-enough-length", "/tmp/opencode-vscode-parity"), /controlled dev-stack command directory/); + } finally { + if (oldComposePassword === undefined) delete process.env.SPACEAPP_DEV_POSTGRES_PASSWORD; + else process.env.SPACEAPP_DEV_POSTGRES_PASSWORD = oldComposePassword; + } } finally { await rm(root, { recursive: true, force: true }); } From 0577291a242605ca43c87750e64e7ea4faa36e9d Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:22:17 +0900 Subject: [PATCH 27/71] test(dev): honor room update contract in verifier --- deploy/docker/dev-stack-verify.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/deploy/docker/dev-stack-verify.mjs b/deploy/docker/dev-stack-verify.mjs index 6515d6338..044d74648 100644 --- a/deploy/docker/dev-stack-verify.mjs +++ b/deploy/docker/dev-stack-verify.mjs @@ -88,12 +88,19 @@ const csrf = csrfResponse.payload?.csrfToken; const csrfHeader = csrfResponse.payload?.headerName || "x-space-csrf-token"; if (typeof csrf !== "string" || !csrf) throw new Error("Authenticated owner session did not return a CSRF token."); +const roomsResponse = await request("/api/rooms", { cookie }); +const rooms = Array.isArray(roomsResponse.payload) ? roomsResponse.payload : (roomsResponse.payload?.data ?? []); +const starterRoom = rooms.find((candidate) => candidate?.id === starterRoomId); +if (!starterRoom || typeof starterRoom.name !== "string" || !starterRoom.name.trim()) { + throw new Error("Could not resolve the Getting Started room name required by the room update contract."); +} + await request(`/api/rooms/${encodeURIComponent(starterRoomId)}`, { method: "PATCH", cookie, csrf, csrfHeader, - body: { projectPath: root } + body: { name: starterRoom.name, projectPath: root } }); console.log("[dev-stack-verify] PASS starter room points at the source checkout"); From 0b79996d4a690fc55ea45c256df9556ab78b8d66 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:30:27 +0900 Subject: [PATCH 28/71] test(dev): accept ready idle CLI sessions --- deploy/docker/dev-stack-verify.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/deploy/docker/dev-stack-verify.mjs b/deploy/docker/dev-stack-verify.mjs index 044d74648..964b48a5f 100644 --- a/deploy/docker/dev-stack-verify.mjs +++ b/deploy/docker/dev-stack-verify.mjs @@ -6,6 +6,8 @@ import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const stateRoot = resolve(process.env.SPACE_DEV_STACK_DIR || join(root, ".dev-stack")); const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +const activeCliStatuses = new Set(["IDLE", "RUNNING", "READY", "ATTACHED"]); +const failedCliStatuses = new Set(["ERROR", "EXITED", "FAILED"]); async function waitForJson(path, timeoutMs = 120_000) { const deadline = Date.now() + timeoutMs; @@ -124,11 +126,11 @@ let session = null; while (Date.now() < deadline) { const current = await request(`/api/panes/${encodeURIComponent(pane.id)}/cli/session`, { cookie }); session = current.payload?.session ?? current.payload; - if (["RUNNING", "READY", "ATTACHED"].includes(session?.status)) break; - if (["ERROR", "EXITED", "FAILED"].includes(session?.status)) throw new Error(`OpenCode pane entered ${session.status}: ${session.statusReason || "no reason"}`); + if (activeCliStatuses.has(session?.status)) break; + if (failedCliStatuses.has(session?.status)) throw new Error(`OpenCode pane entered ${session.status}: ${session.statusReason || "no reason"}`); await sleep(500); } -if (!session || !["RUNNING", "READY", "ATTACHED"].includes(session.status)) throw new Error(`OpenCode pane did not become active; last status=${session?.status ?? "missing"}.`); +if (!session || !activeCliStatuses.has(session.status)) throw new Error(`OpenCode pane did not become active; last status=${session?.status ?? "missing"}.`); console.log(`[dev-stack-verify] PASS OpenCode pane active status=${session.status}`); const finalReady = await request("/readyz"); From f7f91bb1a05e5fcc79ef94b4a94ed6d81fb4cde5 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:31:26 +0900 Subject: [PATCH 29/71] test(dev): attach OpenCode terminal transport in verifier --- deploy/docker/dev-stack-verify.mjs | 79 +++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 12 deletions(-) diff --git a/deploy/docker/dev-stack-verify.mjs b/deploy/docker/dev-stack-verify.mjs index 964b48a5f..dd8b05460 100644 --- a/deploy/docker/dev-stack-verify.mjs +++ b/deploy/docker/dev-stack-verify.mjs @@ -1,14 +1,27 @@ #!/usr/bin/env node +import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import WebSocket from "ws"; const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const stateRoot = resolve(process.env.SPACE_DEV_STACK_DIR || join(root, ".dev-stack")); const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); -const activeCliStatuses = new Set(["IDLE", "RUNNING", "READY", "ATTACHED"]); +const activeCliStatuses = new Set(["RUNNING", "READY", "ATTACHED"]); const failedCliStatuses = new Set(["ERROR", "EXITED", "FAILED"]); +function timeoutFromEnv(name, fallback) { + const raw = process.env[name]; + if (raw == null || raw === "") return fallback; + const value = Number(raw); + if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number of milliseconds.`); + return value; +} + +const readyTimeoutMs = timeoutFromEnv("SPACE_DEV_VERIFY_READY_TIMEOUT_MS", 420_000); +const cliTimeoutMs = timeoutFromEnv("SPACE_DEV_VERIFY_CLI_TIMEOUT_MS", 180_000); + async function waitForJson(path, timeoutMs = 120_000) { const deadline = Date.now() + timeoutMs; let lastError = null; @@ -49,7 +62,7 @@ async function request(path, { method = "GET", body, cookie, csrf, csrfHeader = } async function waitReady() { - const deadline = Date.now() + 420_000; + const deadline = Date.now() + readyTimeoutMs; while (Date.now() < deadline) { try { const { payload } = await request("/readyz"); @@ -57,7 +70,7 @@ async function waitReady() { } catch {} await sleep(500); } - throw new Error("Timed out waiting for real source-stack readiness."); + throw new Error(`Timed out waiting ${readyTimeoutMs}ms for real source-stack readiness.`); } const ready = await waitReady(); @@ -121,17 +134,59 @@ const started = await request(`/api/panes/${encodeURIComponent(pane.id)}/cli/ses }); console.log(`[dev-stack-verify] OpenCode start returned status=${started.payload?.session?.status ?? started.payload?.status ?? "unknown"}`); -const deadline = Date.now() + 60_000; +const sessionView = await request(`/api/panes/${encodeURIComponent(pane.id)}/cli/session`, { cookie }); +const sessionId = sessionView.payload?.session?.sessionId ?? started.payload?.session?.sessionId; +const ticket = sessionView.payload?.websocket?.token; +if (typeof sessionId !== "string" || !sessionId || typeof ticket !== "string" || !ticket) { + throw new Error("OpenCode session did not expose the one-time terminal WebSocket ticket required to attach the UI transport."); +} + +const wsUrl = `${api.replace(/^http/, "ws")}/api/panes/${encodeURIComponent(pane.id)}/cli/terminal` + + `?sessionId=${encodeURIComponent(sessionId)}&token=${encodeURIComponent(ticket)}` + + `&clientId=${encodeURIComponent(randomUUID())}&initialCols=120&initialRows=32`; +let terminalOutput = ""; +let socketError = null; +let socketClosed = null; +const socket = new WebSocket(wsUrl, { headers: { cookie } }); +socket.on("message", (data) => { + if (terminalOutput.length < 200_000) terminalOutput += data.toString(); +}); +socket.on("error", (error) => { socketError = error; }); +socket.on("close", (code, reason) => { + socketClosed = `code=${code}${reason?.length ? ` reason=${reason.toString()}` : ""}`; +}); + +await new Promise((resolvePromise, rejectPromise) => { + socket.once("open", resolvePromise); + socket.once("error", rejectPromise); +}); +console.log(`[dev-stack-verify] terminal transport attached session=${sessionId}`); + let session = null; -while (Date.now() < deadline) { - const current = await request(`/api/panes/${encodeURIComponent(pane.id)}/cli/session`, { cookie }); - session = current.payload?.session ?? current.payload; - if (activeCliStatuses.has(session?.status)) break; - if (failedCliStatuses.has(session?.status)) throw new Error(`OpenCode pane entered ${session.status}: ${session.statusReason || "no reason"}`); - await sleep(500); +const deadline = Date.now() + cliTimeoutMs; +try { + while (Date.now() < deadline) { + const current = await request(`/api/panes/${encodeURIComponent(pane.id)}/cli/session`, { cookie }); + session = current.payload?.session ?? current.payload; + if (failedCliStatuses.has(session?.status)) { + throw new Error(`OpenCode pane entered ${session.status}: ${session.statusReason || "no reason"}`); + } + if (activeCliStatuses.has(session?.status) && terminalOutput.length > 0) break; + if (socketError) throw new Error(`OpenCode terminal WebSocket failed: ${socketError.message}`); + if (socketClosed) throw new Error(`OpenCode terminal WebSocket closed before activation (${socketClosed}).`); + await sleep(500); + } + + if (!session || !activeCliStatuses.has(session.status)) { + throw new Error(`OpenCode pane did not become active within ${cliTimeoutMs}ms; last status=${session?.status ?? "missing"}.`); + } + if (terminalOutput.length === 0) { + throw new Error(`OpenCode pane reached ${session.status} but emitted no terminal output within ${cliTimeoutMs}ms.`); + } + console.log(`[dev-stack-verify] PASS OpenCode pane active status=${session.status} terminalBytes=${terminalOutput.length}`); +} finally { + try { socket.close(); } catch {} } -if (!session || !activeCliStatuses.has(session.status)) throw new Error(`OpenCode pane did not become active; last status=${session?.status ?? "missing"}.`); -console.log(`[dev-stack-verify] PASS OpenCode pane active status=${session.status}`); const finalReady = await request("/readyz"); if (finalReady.payload?.ok !== true) throw new Error("Stack lost readiness after starting OpenCode."); From 226a52a4edd1ae1b549aa39f6c99935a97b5fb57 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:38:37 +0900 Subject: [PATCH 30/71] fix(dev): guard dev-stack process ownership --- deploy/docker/dev-stack-safety.mjs | 80 ++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 deploy/docker/dev-stack-safety.mjs diff --git a/deploy/docker/dev-stack-safety.mjs b/deploy/docker/dev-stack-safety.mjs new file mode 100644 index 000000000..eae93abb6 --- /dev/null +++ b/deploy/docker/dev-stack-safety.mjs @@ -0,0 +1,80 @@ +import { existsSync } from "node:fs"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { pidAlive, run } from "./dev-stack-lib.mjs"; + +const unsafeOverrideKeys = ["SPACE_DEV_STACK_DIR", "SPACE_DEV_COMPOSE_PROJECT"]; + +export function assertSafeDevStackOverrides(env = process.env) { + for (const key of unsafeOverrideKeys) { + if (env[key] != null && env[key] !== "") { + throw new Error(`${key} is intentionally unsupported: dev-stack state and Compose ownership are derived from this checkout so reset cannot target unrelated resources.`); + } + } +} + +async function psField(pid, field) { + const result = await run("ps", ["-p", String(pid), "-o", `${field}=`], { capture: true, allowFailure: true }); + if (result.code !== 0 || !result.stdout) return null; + return result.stdout.trim(); +} + +export async function processIdentity(pid) { + if (!pidAlive(pid)) return null; + const [startedAt, args] = await Promise.all([ + psField(pid, "lstart"), + psField(pid, "args") + ]); + if (!startedAt || !args) return null; + return { startedAt, args }; +} + +export function supervisorRecordMatches(config, record, identity) { + if (!record || !identity || record.legacy === true) return false; + if (!Number.isInteger(record.pid) || record.pid <= 1) return false; + if (record.rootHash !== config.rootHash || record.startedAt !== identity.startedAt) return false; + return /(?:^|[\\/\s])dev-stack\.mjs(?:\s|$)/.test(identity.args); +} + +export async function readSupervisorRecord(config) { + if (!existsSync(config.pidFile)) return null; + const raw = (await readFile(config.pidFile, "utf8")).trim(); + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + return Number.isInteger(parsed?.pid) ? parsed : null; + } catch { + const pid = Number.parseInt(raw, 10); + return Number.isInteger(pid) ? { pid, legacy: true } : null; + } +} + +export async function isOwnedSupervisor(config, record = null, identityProvider = processIdentity) { + const candidate = record ?? await readSupervisorRecord(config); + if (!candidate || !pidAlive(candidate.pid)) return false; + const identity = await identityProvider(candidate.pid); + return supervisorRecordMatches(config, candidate, identity); +} + +export async function claimSupervisor(config, identityProvider = processIdentity) { + const existing = await readSupervisorRecord(config); + if (existing && await isOwnedSupervisor(config, existing, identityProvider)) { + throw new Error(`Dev stack is already running under supervisor ${existing.pid}.`); + } + if (existing) await rm(config.pidFile, { force: true }); + + const identity = await identityProvider(process.pid); + if (!identity?.startedAt) throw new Error("Could not establish dev-stack supervisor process identity."); + const record = { pid: process.pid, rootHash: config.rootHash, startedAt: identity.startedAt }; + try { + await writeFile(config.pidFile, `${JSON.stringify(record)}\n`, { mode: 0o600, flag: "wx" }); + } catch (error) { + if (error?.code === "EEXIST") throw new Error("Another dev-stack supervisor claimed this checkout while startup was in progress."); + throw error; + } + return record; +} + +export async function releaseSupervisor(config, pid = process.pid) { + const record = await readSupervisorRecord(config); + if (record?.pid === pid) await rm(config.pidFile, { force: true }); +} From 58d88a30988cb70c47f95d3702705064662ab449 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:39:39 +0900 Subject: [PATCH 31/71] fix(dev): verify supervisor before signalling --- deploy/docker/dev-stack-safety.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/deploy/docker/dev-stack-safety.mjs b/deploy/docker/dev-stack-safety.mjs index eae93abb6..840e0559e 100644 --- a/deploy/docker/dev-stack-safety.mjs +++ b/deploy/docker/dev-stack-safety.mjs @@ -55,6 +55,20 @@ export async function isOwnedSupervisor(config, record = null, identityProvider return supervisorRecordMatches(config, candidate, identity); } +export async function ownedSupervisorPid(config, { strict = true, identityProvider = processIdentity } = {}) { + const record = await readSupervisorRecord(config); + if (!record) return null; + if (!pidAlive(record.pid)) { + await rm(config.pidFile, { force: true }); + return null; + } + if (await isOwnedSupervisor(config, record, identityProvider)) return record.pid; + if (strict) { + throw new Error(`Refusing to signal PID ${record.pid}: the pidfile does not identify this checkout's dev-stack supervisor.`); + } + return null; +} + export async function claimSupervisor(config, identityProvider = processIdentity) { const existing = await readSupervisorRecord(config); if (existing && await isOwnedSupervisor(config, existing, identityProvider)) { From b7bb2604167b2cb2ac6984a1d5b920ec81176a5d Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:40:19 +0900 Subject: [PATCH 32/71] fix(dev): scope compose database secret --- deploy/docker/dev-stack-lib.mjs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/deploy/docker/dev-stack-lib.mjs b/deploy/docker/dev-stack-lib.mjs index c9fa1d266..1e5088148 100644 --- a/deploy/docker/dev-stack-lib.mjs +++ b/deploy/docker/dev-stack-lib.mjs @@ -127,13 +127,12 @@ export async function prepareState(config) { await privateFile(join(config.secretsDir, "postgres-password"), randomBytes(24).toString("base64url")); await privateFile(join(config.secretsDir, "session-secret"), randomBytes(32).toString("hex")); await privateFile(join(config.secretsDir, "setup-token"), randomBytes(32).toString("hex")); - const password = (await readFile(join(config.secretsDir, "postgres-password"), "utf8")).trim(); + const postgresPassword = (await readFile(join(config.secretsDir, "postgres-password"), "utf8")).trim(); const sessionSecret = (await readFile(join(config.secretsDir, "session-secret"), "utf8")).trim(); - process.env.SPACEAPP_DEV_POSTGRES_PASSWORD = password; - const databaseUrl = `postgresql://spaceapp:${encodeURIComponent(password)}@127.0.0.1:${config.ports.postgres}/spaceapp_dev`; + const databaseUrl = `postgresql://spaceapp:${encodeURIComponent(postgresPassword)}@127.0.0.1:${config.ports.postgres}/spaceapp_dev`; await writeFile(join(config.secretsDir, "database-url"), `${databaseUrl}\n`, { mode: 0o600 }); await chmod(join(config.secretsDir, "database-url"), 0o600); - return { databaseUrl, sessionSecret }; + return { databaseUrl, sessionSecret, postgresPassword }; } async function newestMtime(path) { From 75bf7cbdcb5aea19bfcea9d40ab13be206bfa265 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:40:53 +0900 Subject: [PATCH 33/71] fix(dev): enforce supervisor and resource ownership --- deploy/docker/dev-stack.mjs | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/deploy/docker/dev-stack.mjs b/deploy/docker/dev-stack.mjs index 0c21c8c92..ca3ab1ef4 100644 --- a/deploy/docker/dev-stack.mjs +++ b/deploy/docker/dev-stack.mjs @@ -14,7 +14,6 @@ import { parsePinnedImages, pidAlive, prepareState, - readSupervisorPid, renderCompose, repoRoot, resolveConfig, @@ -24,6 +23,12 @@ import { waitHealthy, waitReady } from "./dev-stack-lib.mjs"; +import { + assertSafeDevStackOverrides, + claimSupervisor, + ownedSupervisorPid, + releaseSupervisor +} from "./dev-stack-safety.mjs"; const scriptPath = fileURLToPath(import.meta.url); @@ -98,8 +103,8 @@ async function waitServiceHealthyWithDiagnostics(config, service, timeoutMs) { } export async function down(config) { - const pid = await readSupervisorPid(config); - if (pid && pid !== process.pid && pidAlive(pid)) { + const pid = await ownedSupervisorPid(config); + if (pid && pid !== process.pid) { process.kill(pid, "SIGTERM"); await waitSupervisorExit(pid); } @@ -122,10 +127,10 @@ export async function reset(config) { } export async function status(config) { - const pid = await readSupervisorPid(config); + const pid = await ownedSupervisorPid(config, { strict: false }); const resources = await dockerResources(config); const payload = { - running: Boolean(pid && pidAlive(pid)), + running: Boolean(pid), supervisorPid: pid, project: config.project, stateRoot: config.stateRoot, @@ -137,8 +142,8 @@ export async function status(config) { } export async function rebuild(config) { - const pid = await readSupervisorPid(config); - if (!pid || !pidAlive(pid)) { + const pid = await ownedSupervisorPid(config); + if (!pid) { await ensureBuild(config, true); console.log("[dev:stack] Rebuilt source workspaces; no running supervisor to restart."); return; @@ -148,14 +153,11 @@ export async function rebuild(config) { } export async function start(config) { - const existingPid = await readSupervisorPid(config); - if (existingPid && pidAlive(existingPid)) throw new Error(`Dev stack is already running under supervisor ${existingPid}.`); - const composeTemplate = await readFile(resolve(config.root, "packages/run-spaceapp/templates/compose.yml"), "utf8"); const dockerfile = await readFile(resolve(config.root, "Dockerfile"), "utf8"); const images = parsePinnedImages(composeTemplate); const openCodeVersion = parseOpenCodeVersion(dockerfile); - const { databaseUrl, sessionSecret } = await prepareState(config); + const { databaseUrl, sessionSecret, postgresPassword } = await prepareState(config); await mkdir(dirname(config.composeFile), { recursive: true, mode: 0o700 }); await writeFile(config.composeFile, renderCompose(config, images), { mode: 0o600 }); await writeFile(config.configFile, `${JSON.stringify({ @@ -168,6 +170,7 @@ export async function start(config) { }, null, 2)}\n`, { mode: 0o600 }); await run("docker", ["compose", "version"]); + await claimSupervisor(config); let children = new Map(); let stopping = false; let reloadChain = Promise.resolve(); @@ -185,7 +188,7 @@ export async function start(config) { console.log(`[dev:stack] Stopping (${reason})...`); await stopChildren(); if (existsSync(config.composeFile)) await compose(config, ["down", "--remove-orphans"], { allowFailure: true }); - try { await rm(config.pidFile, { force: true }); } catch {} + await releaseSupervisor(config); }; const unexpectedExit = (name, child, code, signal) => { @@ -224,10 +227,11 @@ export async function start(config) { process.once("SIGTERM", () => { void shutdown("SIGTERM").finally(() => process.exit(process.exitCode || 0)); }); process.on("SIGHUP", reload); - await writeFile(config.pidFile, `${process.pid}\n`, { mode: 0o600 }); try { console.log(`[dev:stack] Starting digest-pinned PostgreSQL and Temporal (${config.project})...`); - await compose(config, ["up", "-d", "postgres", "temporal"]); + await compose(config, ["up", "-d", "postgres", "temporal"], { + env: { ...process.env, SPACEAPP_DEV_POSTGRES_PASSWORD: postgresPassword } + }); await Promise.all([ waitHealthy(config, "postgres"), waitServiceHealthyWithDiagnostics(config, "temporal", 240_000) @@ -257,6 +261,7 @@ export async function start(config) { async function main() { const command = process.argv[2] || "up"; + assertSafeDevStackOverrides(); const config = await resolveConfig(); if (["up", "start"].includes(command)) return start(config); if (["down", "stop"].includes(command)) return down(config); From c5fc4fbd9485588eadcf635251a7304a0754212e Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:41:21 +0900 Subject: [PATCH 34/71] test(dev): cover ownership safety guards --- deploy/docker/dev-stack.test.mjs | 37 +++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/deploy/docker/dev-stack.test.mjs b/deploy/docker/dev-stack.test.mjs index 604fcbac0..c8b386d0b 100644 --- a/deploy/docker/dev-stack.test.mjs +++ b/deploy/docker/dev-stack.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -11,6 +11,13 @@ import { resolveConfig, sourceEnv } from "./dev-stack-lib.mjs"; +import { + assertSafeDevStackOverrides, + claimSupervisor, + ownedSupervisorPid, + releaseSupervisor, + supervisorRecordMatches +} from "./dev-stack-safety.mjs"; test("source stack derives third-party pins from canonical files", () => { const images = parsePinnedImages(` @@ -76,3 +83,31 @@ test("unsafe state roots and installed-instance ports fail closed", async () => await rm(root, { recursive: true, force: true }); } }); + +test("destructive ownership selectors cannot be overridden", () => { + assert.doesNotThrow(() => assertSafeDevStackOverrides({})); + assert.throws(() => assertSafeDevStackOverrides({ SPACE_DEV_STACK_DIR: "/tmp/shared" }), /SPACE_DEV_STACK_DIR/); + assert.throws(() => assertSafeDevStackOverrides({ SPACE_DEV_COMPOSE_PROJECT: "someone-elses-project" }), /SPACE_DEV_COMPOSE_PROJECT/); +}); + +test("supervisor identity rejects PID reuse and atomically claims a checkout", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-supervisor-test-")); + const config = { + rootHash: "0123456789", + pidFile: join(root, "supervisor.pid") + }; + const ownedIdentity = async () => ({ startedAt: "Sun Oct 18 07:40:00 2026", args: "node deploy/docker/dev-stack.mjs" }); + try { + const record = await claimSupervisor(config, ownedIdentity); + assert.equal(record.pid, process.pid); + assert.equal(JSON.parse(await readFile(config.pidFile, "utf8")).rootHash, config.rootHash); + assert.equal(supervisorRecordMatches(config, record, await ownedIdentity()), true); + await assert.rejects(() => claimSupervisor(config, ownedIdentity), /already running/); + + const reusedIdentity = async () => ({ startedAt: "Sun Oct 18 07:41:00 2026", args: "node unrelated-tool.mjs" }); + await assert.rejects(() => ownedSupervisorPid(config, { identityProvider: reusedIdentity }), /Refusing to signal PID/); + } finally { + await releaseSupervisor(config, process.pid).catch(() => {}); + await rm(root, { recursive: true, force: true }); + } +}); From e4ffadf9cb2046b3b2e79fd642dcb170242c7316 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:42:44 +0900 Subject: [PATCH 35/71] ci(dev): add macOS Colima source-stack evidence --- .github/workflows/dev-stack-macos.yml | 178 ++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 .github/workflows/dev-stack-macos.yml diff --git a/.github/workflows/dev-stack-macos.yml b/.github/workflows/dev-stack-macos.yml new file mode 100644 index 000000000..504dc7e9d --- /dev/null +++ b/.github/workflows/dev-stack-macos.yml @@ -0,0 +1,178 @@ +name: Dev stack macOS evidence + +on: + pull_request: + paths: + - ".github/workflows/dev-stack-macos.yml" + - ".nvmrc" + - "Dockerfile" + - "package.json" + - "package-lock.json" + - "CONTRIBUTING.md" + - "apps/api/**" + - "apps/web/**" + - "apps/worker/**" + - "packages/**" + - "deploy/docker/**" + workflow_dispatch: + # Temporary contributor-fork trigger so this PR can collect the second-platform + # transcript without waiting for upstream approval of forked pull_request jobs. + # Remove this branch-specific trigger after a successful evidence run. + push: + branches: + - "feat/dev-stack-65" + +permissions: + contents: read + +concurrency: + group: dev-stack-macos-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + source-stack-macos: + name: Real source stack lifecycle (macOS + Colima) + runs-on: macos-15-intel + timeout-minutes: 60 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install pinned Colima and Docker client + shell: bash + run: | + set -euo pipefail + COLIMA_VERSION="0.10.3" + COLIMA_SHA256="3082737fe8a98afda11cba7d9a20b6e56fe80c6153464beda04bec630758770b" + curl -fsSL \ + "https://github.com/abiosoft/colima/releases/download/v${COLIMA_VERSION}/colima-Darwin-x86_64" \ + -o "$RUNNER_TEMP/colima" + printf '%s %s\n' "$COLIMA_SHA256" "$RUNNER_TEMP/colima" | shasum -a 256 -c - + chmod 0755 "$RUNNER_TEMP/colima" + sudo install -m 0755 "$RUNNER_TEMP/colima" /usr/local/bin/colima + + # Colima itself is pinned above. Lima/Docker are installed from the + # runner's Homebrew snapshot and their exact versions are printed into + # the evidence transcript below. + brew install lima docker docker-compose + mkdir -p "$HOME/.docker/cli-plugins" + ln -sfn "$(brew --prefix docker-compose)/bin/docker-compose" \ + "$HOME/.docker/cli-plugins/docker-compose" + + colima version + limactl --version + docker --version + docker compose version + + - name: Start Colima + shell: bash + run: | + set -euo pipefail + colima start --cpu 3 --memory 6 --disk 20 + docker context use colima + docker version + docker compose version + + - name: Install dependencies + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + run: npm ci + + - name: Unit-test dev-stack contracts + run: npm run test:dev-stack + + - name: Start full stack without a TTY + shell: bash + run: | + set -euo pipefail + npm run dev:stack >"$RUNNER_TEMP/dev-stack-macos.log" 2>&1 & + echo $! >"$RUNNER_TEMP/dev-stack-shell.pid" + echo "source stack launched in background on macOS" + + - name: Verify real source-stack acceptance + env: + SPACE_DEV_VERIFY_READY_TIMEOUT_MS: "600000" + SPACE_DEV_VERIFY_CLI_TIMEOUT_MS: "240000" + run: npm run verify:dev-stack + + - name: Print auditable macOS terminal evidence + shell: bash + run: | + set -euo pipefail + echo "=== platform ===" + sw_vers + uname -a + echo "=== container runtime ===" + colima version + limactl --version + docker version + docker compose version + echo "=== dev-stack status ===" + npm run --silent dev:stack -- status + echo "=== loopback listeners ===" + lsof -nP -iTCP:5910 -sTCP:LISTEN || true + lsof -nP -iTCP:5911 -sTCP:LISTEN || true + echo "=== secret modes ===" + for file in .dev-stack/secrets/*; do + [ -f "$file" ] || continue + mode="$(stat -f '%Lp' "$file")" + echo "$mode $file" + [ "$mode" = "600" ] || { echo "unexpected secret mode for $file" >&2; exit 1; } + done + echo "=== one-command transcript ===" + cat "$RUNNER_TEMP/dev-stack-macos.log" + + - name: Stop and reset owned resources + if: always() + shell: bash + run: | + set +e + npm run dev:stack -- down + down_status=$? + npm run dev:stack -- reset + reset_status=$? + + shell_pid="$(cat "$RUNNER_TEMP/dev-stack-shell.pid" 2>/dev/null || true)" + for _ in $(seq 1 50); do + if [ -z "$shell_pid" ] || ! kill -0 "$shell_pid" 2>/dev/null; then + break + fi + sleep 0.1 + done + + process_status=0 + if [ -n "$shell_pid" ] && kill -0 "$shell_pid" 2>/dev/null; then + echo "dev-stack launcher process $shell_pid is still alive after reset" >&2 + process_status=1 + kill "$shell_pid" 2>/dev/null || true + fi + + status_json="$(npm run --silent dev:stack -- status 2>/dev/null)" + echo "$status_json" + if echo "$status_json" | grep -q '"running": true'; then + echo "dev-stack supervisor still reports running after reset" >&2 + process_status=1 + fi + if ! echo "$status_json" | grep -q '"containers": \[\]'; then + echo "dev-stack containers remain after reset" >&2 + process_status=1 + fi + if ! echo "$status_json" | grep -q '"volumes": \[\]'; then + echo "dev-stack volumes remain after reset" >&2 + process_status=1 + fi + + echo "=== final macOS dev-stack log ===" + cat "$RUNNER_TEMP/dev-stack-macos.log" 2>/dev/null || true + colima stop >/dev/null 2>&1 || true + + if [ "$down_status" -ne 0 ] || [ "$reset_status" -ne 0 ] || [ "$process_status" -ne 0 ]; then + exit 1 + fi From 85ec4e1f2c3380438ae98ca704decdc7dcfb3f9a Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:42:47 +0900 Subject: [PATCH 36/71] docs(dev): align source-stack ownership guidance --- CONTRIBUTING.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58c273c30..d4460d4da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,10 +90,11 @@ keeps the state needed to retry cleanup instead of reporting success. The isolated ports can be overridden with `SPACE_DEV_API_PORT`, `SPACE_DEV_WEB_PORT`, `SPACE_DEV_POSTGRES_PORT`, and -`SPACE_DEV_TEMPORAL_PORT`. `SPACE_DEV_STACK_DIR` selects another state -directory and `SPACE_DEV_COMPOSE_PROJECT` selects another Compose project. -The runner rejects installed-instance ports `4910` and `4911`, duplicate ports, -unsafe state roots, and symlink state roots. +`SPACE_DEV_TEMPORAL_PORT`. The state directory and Compose project identity are +derived from this checkout and intentionally cannot be overridden, so lifecycle +commands cannot target another checkout's resources. The runner also rejects +installed-instance ports `4910` and `4911`, duplicate ports, unsafe state roots, +and symlink state roots. For an automated end-to-end proof after the stack is ready, run: From a4dcd1cde41a533078182334c1970f35ca586b0a Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:43:10 +0900 Subject: [PATCH 37/71] fix(dev): repair node-pty helper on macOS --- deploy/docker/dev-stack.mjs | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/deploy/docker/dev-stack.mjs b/deploy/docker/dev-stack.mjs index ca3ab1ef4..f13c309fb 100644 --- a/deploy/docker/dev-stack.mjs +++ b/deploy/docker/dev-stack.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { existsSync } from "node:fs"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { chmod, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; import { @@ -73,6 +73,26 @@ function spawnService(config, name, command, args, env, onUnexpectedExit) { return child; } +export async function ensureDarwinNodePtySpawnHelpers(root, platform = process.platform) { + if (platform !== "darwin") return []; + const prebuilds = join(root, "node_modules", "node-pty", "prebuilds"); + if (!existsSync(prebuilds)) return []; + + const repaired = []; + for (const entry of await readdir(prebuilds, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith("darwin-")) continue; + const helper = join(prebuilds, entry.name, "spawn-helper"); + if (!existsSync(helper)) continue; + const info = await stat(helper); + const currentMode = info.mode & 0o777; + const desiredMode = currentMode | 0o111; + if (currentMode === desiredMode) continue; + await chmod(helper, desiredMode); + repaired.push(helper); + } + return repaired; +} + async function waitSupervisorExit(pid, timeoutMs = 15_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline && pidAlive(pid)) await sleep(100); @@ -200,6 +220,10 @@ export async function start(config) { const spawnChildren = async (forceBuild = false) => { await ensureBuild(config, forceBuild); + const repairedHelpers = await ensureDarwinNodePtySpawnHelpers(config.root); + if (repairedHelpers.length) { + console.log(`[dev:stack] Repaired execute permission on ${repairedHelpers.length} node-pty macOS spawn helper(s).`); + } const openCodeWrapper = await installOpenCode(config, openCodeVersion); const env = sourceEnv(config, databaseUrl, sessionSecret, openCodeWrapper); children.set("cli-host", spawnService(config, "cli-host", process.execPath, ["packages/cli-host/dist/main.js"], env, unexpectedExit)); From d8e6b54165b7e931b5bfcba2bfe11b37349008fd Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:43:28 +0900 Subject: [PATCH 38/71] test(dev): cover macOS node-pty helper repair --- deploy/docker/dev-stack.test.mjs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/deploy/docker/dev-stack.test.mjs b/deploy/docker/dev-stack.test.mjs index c8b386d0b..2d74ca5b0 100644 --- a/deploy/docker/dev-stack.test.mjs +++ b/deploy/docker/dev-stack.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -11,6 +11,7 @@ import { resolveConfig, sourceEnv } from "./dev-stack-lib.mjs"; +import { ensureDarwinNodePtySpawnHelpers } from "./dev-stack.mjs"; import { assertSafeDevStackOverrides, claimSupervisor, @@ -111,3 +112,24 @@ test("supervisor identity rejects PID reuse and atomically claims a checkout", a await rm(root, { recursive: true, force: true }); } }); + +test("macOS source stack repairs node-pty spawn-helper execute permissions", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-pty-helper-")); + const helper = join(root, "node_modules", "node-pty", "prebuilds", "darwin-x64", "spawn-helper"); + try { + await mkdir(join(root, "node_modules", "node-pty", "prebuilds", "darwin-x64"), { recursive: true }); + await writeFile(helper, "#!/bin/sh\nexit 0\n", { mode: 0o644 }); + await chmod(helper, 0o644); + + const repaired = await ensureDarwinNodePtySpawnHelpers(root, "darwin"); + assert.deepEqual(repaired, [helper]); + assert.equal((await stat(helper)).mode & 0o111, 0o111); + assert.deepEqual(await ensureDarwinNodePtySpawnHelpers(root, "darwin"), []); + + await chmod(helper, 0o644); + assert.deepEqual(await ensureDarwinNodePtySpawnHelpers(root, "linux"), []); + assert.equal((await stat(helper)).mode & 0o111, 0); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From eac70d0263dd47dbfe72bb08c7ee7b60ff33527a Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:43:53 +0900 Subject: [PATCH 39/71] ci(dev): verify source stack on macOS Colima --- .github/workflows/dev-stack.yml | 110 ++++++++++++++++++++++++++++++-- 1 file changed, 106 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml index 425608e2f..706cd1ef8 100644 --- a/.github/workflows/dev-stack.yml +++ b/.github/workflows/dev-stack.yml @@ -24,10 +24,10 @@ concurrency: cancel-in-progress: true jobs: - source-stack: - name: Real source stack lifecycle + source-stack-linux: + name: Real source stack lifecycle (Linux) runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 35 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -68,10 +68,12 @@ jobs: reset_status=$? shell_pid="$(cat "$RUNNER_TEMP/dev-stack-shell.pid" 2>/dev/null || true)" - for _ in $(seq 1 50); do + attempt=0 + while [ "$attempt" -lt 50 ]; do if [ -z "$shell_pid" ] || ! kill -0 "$shell_pid" 2>/dev/null; then break fi + attempt=$((attempt + 1)) sleep 0.1 done process_status=0 @@ -101,3 +103,103 @@ jobs: if [ "$down_status" -ne 0 ] || [ "$reset_status" -ne 0 ] || [ "$process_status" -ne 0 ]; then exit 1 fi + + source-stack-macos: + name: Real source stack lifecycle (macOS, Colima) + runs-on: macos-15-intel + timeout-minutes: 90 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install Docker CLI and Colima + env: + HOMEBREW_NO_AUTO_UPDATE: "1" + HOMEBREW_NO_INSTALL_CLEANUP: "1" + run: | + brew install colima docker docker-compose + mkdir -p "$HOME/.docker/cli-plugins" + ln -sfn "$(brew --prefix docker-compose)/bin/docker-compose" "$HOME/.docker/cli-plugins/docker-compose" + + - name: Start Colima + timeout-minutes: 25 + run: | + colima start --cpu 2 --memory 6 + docker version + docker compose version + + - name: Install dependencies + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + run: npm ci + + - name: Unit-test dev-stack contracts + run: npm run test:dev-stack + + - name: Start full stack without a TTY + shell: bash + run: | + set -euo pipefail + npm run dev:stack >"$RUNNER_TEMP/dev-stack.log" 2>&1 & + echo $! >"$RUNNER_TEMP/dev-stack-shell.pid" + echo "source stack launched in background" + + - name: Verify readiness, owner setup, and OpenCode pane + env: + SPACE_DEV_VERIFY_READY_TIMEOUT_MS: "900000" + SPACE_DEV_VERIFY_CLI_TIMEOUT_MS: "300000" + run: npm run verify:dev-stack + + - name: Stop and reset owned resources + if: always() + shell: bash + run: | + set +e + npm run dev:stack -- down + down_status=$? + npm run dev:stack -- reset + reset_status=$? + + shell_pid="$(cat "$RUNNER_TEMP/dev-stack-shell.pid" 2>/dev/null || true)" + attempt=0 + while [ "$attempt" -lt 50 ]; do + if [ -z "$shell_pid" ] || ! kill -0 "$shell_pid" 2>/dev/null; then + break + fi + attempt=$((attempt + 1)) + sleep 0.1 + done + process_status=0 + if [ -n "$shell_pid" ] && kill -0 "$shell_pid" 2>/dev/null; then + echo "dev-stack launcher process $shell_pid is still alive after reset" >&2 + process_status=1 + kill "$shell_pid" 2>/dev/null || true + fi + + status_json="$(npm run --silent dev:stack -- status 2>/dev/null)" + echo "$status_json" + if echo "$status_json" | grep -q '"running": true'; then + echo "dev-stack supervisor still reports running after reset" >&2 + process_status=1 + fi + if ! echo "$status_json" | grep -q '"containers": \[\]'; then + echo "dev-stack containers remain after reset" >&2 + process_status=1 + fi + if ! echo "$status_json" | grep -q '"volumes": \[\]'; then + echo "dev-stack volumes remain after reset" >&2 + process_status=1 + fi + + echo "--- dev-stack log ---" + cat "$RUNNER_TEMP/dev-stack.log" 2>/dev/null || true + colima stop >/dev/null 2>&1 || true + if [ "$down_status" -ne 0 ] || [ "$reset_status" -ne 0 ] || [ "$process_status" -ne 0 ]; then + exit 1 + fi From 4c5bf983db2854997a5236d5c224adbce30a38c4 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:44:36 +0900 Subject: [PATCH 40/71] docs(dev): document terminal attach and macOS lifecycle --- CONTRIBUTING.md | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d4460d4da..b2f2bab7b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,7 +86,9 @@ npm run dev:stack -- reset same supervisor. `down` stops only this checkout's supervised processes and Compose project. `reset` additionally removes this checkout's Compose volumes and state directory; if Docker cannot remove an owned volume, reset fails and -keeps the state needed to retry cleanup instead of reporting success. +keeps the state needed to retry cleanup instead of reporting success. Lifecycle +commands verify the recorded supervisor start identity and checkout hash before +sending a signal, so a stale PID file cannot target a reused unrelated PID. The isolated ports can be overridden with `SPACE_DEV_API_PORT`, `SPACE_DEV_WEB_PORT`, `SPACE_DEV_POSTGRES_PORT`, and @@ -104,12 +106,14 @@ npm run verify:dev-stack That proof performs normal owner setup, points the Getting Started room at the current checkout rather than `/opt/spaceapp`, starts its OpenCode terminal pane -through the real CLI host, requires an active CLI session status, and verifies -that the stack remains ready. The dedicated GitHub Actions workflow runs this -proof on Linux. Windows 11 contributors should use WSL2 with Docker Desktop's -WSL integration or a Docker engine running inside WSL2. macOS contributors can -use Docker Desktop or Colima. Those host paths are contributor environments, -not installer-release certification. +through the real CLI host, attaches the same terminal WebSocket transport used +by the web UI, requires a running CLI session plus real terminal output, and +verifies that the stack remains ready. The dedicated GitHub Actions workflow +runs this lifecycle on Linux and on an Intel macOS runner using Colima. Windows +11 contributors should use WSL2 with Docker Desktop's WSL integration or a +Docker engine running inside WSL2; that path follows the Linux host model. +These host paths are contributor environments, not installer-release +certification. ### Troubleshooting the source stack @@ -124,6 +128,10 @@ not installer-release certification. supervisor and Compose project. Use `npm run dev:stack -- reset` for a clean database and state directory; a failed volume removal is reported and leaves the state in place so cleanup can be retried safely. +- On macOS, `npm run dev:stack` repairs the execute bits on node-pty's published + `prebuilds/darwin-*/spawn-helper` before the CLI host starts. If you launch + the CLI host outside this workflow and a terminal remains `IDLE` with an + `EACCES` spawn error, repair that helper's execute permission first. - Unix socket paths are automatically shortened under the system temporary directory when `.dev-stack` would exceed the platform socket-path limit. From 647f92f93b44197a45c16a38091ec8a8582745b4 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:44:48 +0900 Subject: [PATCH 41/71] ci(dev): keep one source-stack workflow --- .github/workflows/dev-stack-macos.yml | 178 -------------------------- 1 file changed, 178 deletions(-) delete mode 100644 .github/workflows/dev-stack-macos.yml diff --git a/.github/workflows/dev-stack-macos.yml b/.github/workflows/dev-stack-macos.yml deleted file mode 100644 index 504dc7e9d..000000000 --- a/.github/workflows/dev-stack-macos.yml +++ /dev/null @@ -1,178 +0,0 @@ -name: Dev stack macOS evidence - -on: - pull_request: - paths: - - ".github/workflows/dev-stack-macos.yml" - - ".nvmrc" - - "Dockerfile" - - "package.json" - - "package-lock.json" - - "CONTRIBUTING.md" - - "apps/api/**" - - "apps/web/**" - - "apps/worker/**" - - "packages/**" - - "deploy/docker/**" - workflow_dispatch: - # Temporary contributor-fork trigger so this PR can collect the second-platform - # transcript without waiting for upstream approval of forked pull_request jobs. - # Remove this branch-specific trigger after a successful evidence run. - push: - branches: - - "feat/dev-stack-65" - -permissions: - contents: read - -concurrency: - group: dev-stack-macos-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - source-stack-macos: - name: Real source stack lifecycle (macOS + Colima) - runs-on: macos-15-intel - timeout-minutes: 60 - - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version-file: .nvmrc - cache: npm - - - name: Install pinned Colima and Docker client - shell: bash - run: | - set -euo pipefail - COLIMA_VERSION="0.10.3" - COLIMA_SHA256="3082737fe8a98afda11cba7d9a20b6e56fe80c6153464beda04bec630758770b" - curl -fsSL \ - "https://github.com/abiosoft/colima/releases/download/v${COLIMA_VERSION}/colima-Darwin-x86_64" \ - -o "$RUNNER_TEMP/colima" - printf '%s %s\n' "$COLIMA_SHA256" "$RUNNER_TEMP/colima" | shasum -a 256 -c - - chmod 0755 "$RUNNER_TEMP/colima" - sudo install -m 0755 "$RUNNER_TEMP/colima" /usr/local/bin/colima - - # Colima itself is pinned above. Lima/Docker are installed from the - # runner's Homebrew snapshot and their exact versions are printed into - # the evidence transcript below. - brew install lima docker docker-compose - mkdir -p "$HOME/.docker/cli-plugins" - ln -sfn "$(brew --prefix docker-compose)/bin/docker-compose" \ - "$HOME/.docker/cli-plugins/docker-compose" - - colima version - limactl --version - docker --version - docker compose version - - - name: Start Colima - shell: bash - run: | - set -euo pipefail - colima start --cpu 3 --memory 6 --disk 20 - docker context use colima - docker version - docker compose version - - - name: Install dependencies - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" - run: npm ci - - - name: Unit-test dev-stack contracts - run: npm run test:dev-stack - - - name: Start full stack without a TTY - shell: bash - run: | - set -euo pipefail - npm run dev:stack >"$RUNNER_TEMP/dev-stack-macos.log" 2>&1 & - echo $! >"$RUNNER_TEMP/dev-stack-shell.pid" - echo "source stack launched in background on macOS" - - - name: Verify real source-stack acceptance - env: - SPACE_DEV_VERIFY_READY_TIMEOUT_MS: "600000" - SPACE_DEV_VERIFY_CLI_TIMEOUT_MS: "240000" - run: npm run verify:dev-stack - - - name: Print auditable macOS terminal evidence - shell: bash - run: | - set -euo pipefail - echo "=== platform ===" - sw_vers - uname -a - echo "=== container runtime ===" - colima version - limactl --version - docker version - docker compose version - echo "=== dev-stack status ===" - npm run --silent dev:stack -- status - echo "=== loopback listeners ===" - lsof -nP -iTCP:5910 -sTCP:LISTEN || true - lsof -nP -iTCP:5911 -sTCP:LISTEN || true - echo "=== secret modes ===" - for file in .dev-stack/secrets/*; do - [ -f "$file" ] || continue - mode="$(stat -f '%Lp' "$file")" - echo "$mode $file" - [ "$mode" = "600" ] || { echo "unexpected secret mode for $file" >&2; exit 1; } - done - echo "=== one-command transcript ===" - cat "$RUNNER_TEMP/dev-stack-macos.log" - - - name: Stop and reset owned resources - if: always() - shell: bash - run: | - set +e - npm run dev:stack -- down - down_status=$? - npm run dev:stack -- reset - reset_status=$? - - shell_pid="$(cat "$RUNNER_TEMP/dev-stack-shell.pid" 2>/dev/null || true)" - for _ in $(seq 1 50); do - if [ -z "$shell_pid" ] || ! kill -0 "$shell_pid" 2>/dev/null; then - break - fi - sleep 0.1 - done - - process_status=0 - if [ -n "$shell_pid" ] && kill -0 "$shell_pid" 2>/dev/null; then - echo "dev-stack launcher process $shell_pid is still alive after reset" >&2 - process_status=1 - kill "$shell_pid" 2>/dev/null || true - fi - - status_json="$(npm run --silent dev:stack -- status 2>/dev/null)" - echo "$status_json" - if echo "$status_json" | grep -q '"running": true'; then - echo "dev-stack supervisor still reports running after reset" >&2 - process_status=1 - fi - if ! echo "$status_json" | grep -q '"containers": \[\]'; then - echo "dev-stack containers remain after reset" >&2 - process_status=1 - fi - if ! echo "$status_json" | grep -q '"volumes": \[\]'; then - echo "dev-stack volumes remain after reset" >&2 - process_status=1 - fi - - echo "=== final macOS dev-stack log ===" - cat "$RUNNER_TEMP/dev-stack-macos.log" 2>/dev/null || true - colima stop >/dev/null 2>&1 || true - - if [ "$down_status" -ne 0 ] || [ "$reset_status" -ne 0 ] || [ "$process_status" -ne 0 ]; then - exit 1 - fi From 65750f10bf267536128609ef58bc81a4ebd74053 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:45:39 +0900 Subject: [PATCH 42/71] fix(dev): fail closed on stale supervisor records --- deploy/docker/dev-stack-safety.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/deploy/docker/dev-stack-safety.mjs b/deploy/docker/dev-stack-safety.mjs index 840e0559e..9a5d82997 100644 --- a/deploy/docker/dev-stack-safety.mjs +++ b/deploy/docker/dev-stack-safety.mjs @@ -71,10 +71,12 @@ export async function ownedSupervisorPid(config, { strict = true, identityProvid export async function claimSupervisor(config, identityProvider = processIdentity) { const existing = await readSupervisorRecord(config); - if (existing && await isOwnedSupervisor(config, existing, identityProvider)) { - throw new Error(`Dev stack is already running under supervisor ${existing.pid}.`); + if (existing) { + if (await isOwnedSupervisor(config, existing, identityProvider)) { + throw new Error(`Dev stack is already running under supervisor ${existing.pid}.`); + } + throw new Error("A stale or unverified dev-stack supervisor record exists. Run `npm run dev:stack -- status` (or `down`) to clear a dead record before starting again; live unowned PIDs are never signalled or replaced."); } - if (existing) await rm(config.pidFile, { force: true }); const identity = await identityProvider(process.pid); if (!identity?.startedAt) throw new Error("Could not establish dev-stack supervisor process identity."); From e513137e6f0c4d6a663845d6ac6850c6f09a5221 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:46:03 +0900 Subject: [PATCH 43/71] test(dev): reject stale supervisor takeover --- deploy/docker/dev-stack.test.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/deploy/docker/dev-stack.test.mjs b/deploy/docker/dev-stack.test.mjs index 2d74ca5b0..9b730b0d5 100644 --- a/deploy/docker/dev-stack.test.mjs +++ b/deploy/docker/dev-stack.test.mjs @@ -107,6 +107,10 @@ test("supervisor identity rejects PID reuse and atomically claims a checkout", a const reusedIdentity = async () => ({ startedAt: "Sun Oct 18 07:41:00 2026", args: "node unrelated-tool.mjs" }); await assert.rejects(() => ownedSupervisorPid(config, { identityProvider: reusedIdentity }), /Refusing to signal PID/); + + await releaseSupervisor(config, process.pid); + await writeFile(config.pidFile, `${process.pid}\n`, { mode: 0o600 }); + await assert.rejects(() => claimSupervisor(config, ownedIdentity), /stale or unverified/); } finally { await releaseSupervisor(config, process.pid).catch(() => {}); await rm(root, { recursive: true, force: true }); From a55b5459ba382769143d093c8f0b7d7aecbedc5a Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:47:47 +0900 Subject: [PATCH 44/71] fix(dev): recognize legacy numeric supervisor records --- deploy/docker/dev-stack-safety.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/deploy/docker/dev-stack-safety.mjs b/deploy/docker/dev-stack-safety.mjs index 9a5d82997..394bf0f11 100644 --- a/deploy/docker/dev-stack-safety.mjs +++ b/deploy/docker/dev-stack-safety.mjs @@ -41,6 +41,7 @@ export async function readSupervisorRecord(config) { if (!raw) return null; try { const parsed = JSON.parse(raw); + if (Number.isInteger(parsed)) return { pid: parsed, legacy: true }; return Number.isInteger(parsed?.pid) ? parsed : null; } catch { const pid = Number.parseInt(raw, 10); From c0b2b2eec1799882558859a1d3d7096fa24109f8 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 16:50:49 +0900 Subject: [PATCH 45/71] test(dev): require real terminal output frames --- deploy/docker/dev-stack-verify.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/deploy/docker/dev-stack-verify.mjs b/deploy/docker/dev-stack-verify.mjs index dd8b05460..7fa952ece 100644 --- a/deploy/docker/dev-stack-verify.mjs +++ b/deploy/docker/dev-stack-verify.mjs @@ -149,7 +149,14 @@ let socketError = null; let socketClosed = null; const socket = new WebSocket(wsUrl, { headers: { cookie } }); socket.on("message", (data) => { - if (terminalOutput.length < 200_000) terminalOutput += data.toString(); + let message = null; + try { message = JSON.parse(data.toString()); } catch { return; } + if (message?.type === "error") { + socketError = new Error(`OpenCode terminal server error: ${message.message || message.code || "unknown error"}`); + return; + } + if (message?.type !== "output" || typeof message.data !== "string" || message.data.length === 0) return; + if (terminalOutput.length < 200_000) terminalOutput += message.data.slice(0, 200_000 - terminalOutput.length); }); socket.on("error", (error) => { socketError = error; }); socket.on("close", (code, reason) => { From 8958643dbe0e6be1c002540487e4cca01b6b8d8c Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 17:15:07 +0900 Subject: [PATCH 46/71] fix(dev): harden stack ownership and process identity --- deploy/docker/dev-stack-safety.mjs | 165 +++++++++++++++++++++++++++-- 1 file changed, 154 insertions(+), 11 deletions(-) diff --git a/deploy/docker/dev-stack-safety.mjs b/deploy/docker/dev-stack-safety.mjs index 394bf0f11..f46a85db5 100644 --- a/deploy/docker/dev-stack-safety.mjs +++ b/deploy/docker/dev-stack-safety.mjs @@ -1,8 +1,18 @@ import { existsSync } from "node:fs"; -import { readFile, rm, writeFile } from "node:fs/promises"; +import { lstat, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { pidAlive, run } from "./dev-stack-lib.mjs"; const unsafeOverrideKeys = ["SPACE_DEV_STACK_DIR", "SPACE_DEV_COMPOSE_PROJECT"]; +const ownershipFileName = ".ownership.json"; +const processFileName = ".processes.json"; + +const ownershipFile = (config) => join(config.stateRoot, ownershipFileName); +const processFile = (config) => join(config.stateRoot, processFileName); + +function isZombie(identity) { + return typeof identity?.state === "string" && identity.state.startsWith("Z"); +} export function assertSafeDevStackOverrides(env = process.env) { for (const key of unsafeOverrideKeys) { @@ -12,6 +22,65 @@ export function assertSafeDevStackOverrides(env = process.env) { } } +export async function readStateOwnership(config) { + const path = ownershipFile(config); + if (!existsSync(path)) return null; + let parsed; + try { + parsed = JSON.parse((await readFile(path, "utf8")).trim()); + } catch { + throw new Error("Dev-stack ownership record is corrupt; refusing destructive lifecycle operations."); + } + if (parsed?.version !== 1 || parsed.rootHash !== config.rootHash || parsed.project !== config.project || typeof parsed.createdStateRoot !== "boolean") { + throw new Error("Dev-stack ownership record does not match this checkout; refusing destructive lifecycle operations."); + } + return parsed; +} + +export async function assertOwnedState(config, { allowMissing = false } = {}) { + if (!existsSync(config.stateRoot)) { + if (allowMissing) return null; + throw new Error("Dev-stack state directory is missing; ownership cannot be proven."); + } + const info = await lstat(config.stateRoot); + if (info.isSymbolicLink() || !info.isDirectory()) { + throw new Error("Dev-stack state path is not an owned directory; refusing lifecycle operations."); + } + const record = await readStateOwnership(config); + if (!record) throw new Error("Existing .dev-stack directory is not owned by dev:stack; refusing to modify or remove it."); + return record; +} + +export async function claimStateOwnership(config) { + const existed = existsSync(config.stateRoot); + if (existed) { + const info = await lstat(config.stateRoot); + if (info.isSymbolicLink() || !info.isDirectory()) throw new Error("Existing .dev-stack path is not a safe directory."); + const existing = await readStateOwnership(config); + if (existing) return existing; + const entries = await readdir(config.stateRoot); + if (entries.length) { + throw new Error("Existing .dev-stack directory was not initialized by dev:stack and is not empty; refusing to take ownership."); + } + } else { + await mkdir(config.stateRoot, { recursive: false, mode: 0o700 }); + } + + const record = { + version: 1, + rootHash: config.rootHash, + project: config.project, + createdStateRoot: !existed + }; + try { + await writeFile(ownershipFile(config), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: "wx" }); + } catch (error) { + if (error?.code !== "EEXIST") throw error; + return assertOwnedState(config); + } + return record; +} + async function psField(pid, field) { const result = await run("ps", ["-p", String(pid), "-o", `${field}=`], { capture: true, allowFailure: true }); if (result.code !== 0 || !result.stdout) return null; @@ -20,16 +89,19 @@ async function psField(pid, field) { export async function processIdentity(pid) { if (!pidAlive(pid)) return null; - const [startedAt, args] = await Promise.all([ + const [startedAt, args, state, pgidRaw] = await Promise.all([ psField(pid, "lstart"), - psField(pid, "args") + psField(pid, "args"), + psField(pid, "stat"), + psField(pid, "pgid") ]); - if (!startedAt || !args) return null; - return { startedAt, args }; + const pgid = Number.parseInt(pgidRaw || "", 10); + if (!startedAt || !args || !state || !Number.isInteger(pgid)) return null; + return { startedAt, args, state, pgid }; } export function supervisorRecordMatches(config, record, identity) { - if (!record || !identity || record.legacy === true) return false; + if (!record || !identity || record.legacy === true || isZombie(identity)) return false; if (!Number.isInteger(record.pid) || record.pid <= 1) return false; if (record.rootHash !== config.rootHash || record.startedAt !== identity.startedAt) return false; return /(?:^|[\\/\s])dev-stack\.mjs(?:\s|$)/.test(identity.args); @@ -63,7 +135,12 @@ export async function ownedSupervisorPid(config, { strict = true, identityProvid await rm(config.pidFile, { force: true }); return null; } - if (await isOwnedSupervisor(config, record, identityProvider)) return record.pid; + const identity = await identityProvider(record.pid); + if (isZombie(identity)) { + await rm(config.pidFile, { force: true }); + return null; + } + if (supervisorRecordMatches(config, record, identity)) return record.pid; if (strict) { throw new Error(`Refusing to signal PID ${record.pid}: the pidfile does not identify this checkout's dev-stack supervisor.`); } @@ -73,14 +150,22 @@ export async function ownedSupervisorPid(config, { strict = true, identityProvid export async function claimSupervisor(config, identityProvider = processIdentity) { const existing = await readSupervisorRecord(config); if (existing) { - if (await isOwnedSupervisor(config, existing, identityProvider)) { - throw new Error(`Dev stack is already running under supervisor ${existing.pid}.`); + if (!pidAlive(existing.pid)) { + await rm(config.pidFile, { force: true }); + } else { + const identity = await identityProvider(existing.pid); + if (isZombie(identity)) { + await rm(config.pidFile, { force: true }); + } else if (supervisorRecordMatches(config, existing, identity)) { + throw new Error(`Dev stack is already running under supervisor ${existing.pid}.`); + } else { + throw new Error("A stale or unverified dev-stack supervisor record exists. Run `npm run dev:stack -- status` (or `down`) to clear a dead record before starting again; live unowned PIDs are never signalled or replaced."); + } } - throw new Error("A stale or unverified dev-stack supervisor record exists. Run `npm run dev:stack -- status` (or `down`) to clear a dead record before starting again; live unowned PIDs are never signalled or replaced."); } const identity = await identityProvider(process.pid); - if (!identity?.startedAt) throw new Error("Could not establish dev-stack supervisor process identity."); + if (!identity?.startedAt || isZombie(identity)) throw new Error("Could not establish dev-stack supervisor process identity."); const record = { pid: process.pid, rootHash: config.rootHash, startedAt: identity.startedAt }; try { await writeFile(config.pidFile, `${JSON.stringify(record)}\n`, { mode: 0o600, flag: "wx" }); @@ -95,3 +180,61 @@ export async function releaseSupervisor(config, pid = process.pid) { const record = await readSupervisorRecord(config); if (record?.pid === pid) await rm(config.pidFile, { force: true }); } + +export function managedProcessRecordMatches(config, record, identity) { + if (!record || !identity) return false; + if (!Number.isInteger(record.pid) || record.pid <= 1 || !Number.isInteger(record.pgid) || record.pgid <= 1) return false; + return record.rootHash === config.rootHash + && record.pid === record.pgid + && identity.pgid === record.pgid + && record.startedAt === identity.startedAt + && record.args === identity.args; +} + +export async function readManagedProcessRecords(config) { + const path = processFile(config); + if (!existsSync(path)) return []; + let parsed; + try { + parsed = JSON.parse((await readFile(path, "utf8")).trim()); + } catch { + throw new Error("Dev-stack managed-process record is corrupt; refusing to signal recorded PIDs."); + } + if (parsed?.version !== 1 || parsed.rootHash !== config.rootHash || !Array.isArray(parsed.processes)) { + throw new Error("Dev-stack managed-process record does not belong to this checkout."); + } + return parsed.processes; +} + +export async function writeManagedProcessRecords(config, processes) { + const path = processFile(config); + if (!processes.length) { + await rm(path, { force: true }); + return; + } + await writeFile(path, `${JSON.stringify({ version: 1, rootHash: config.rootHash, processes }, null, 2)}\n`, { mode: 0o600 }); +} + +export async function ownedManagedProcessRecords(config, { strict = true, identityProvider = processIdentity } = {}) { + const records = await readManagedProcessRecords(config); + const owned = []; + let changed = false; + for (const record of records) { + if (!pidAlive(record.pid)) { + changed = true; + continue; + } + const identity = await identityProvider(record.pid); + if (!identity) { + if (strict) throw new Error(`Refusing to signal PID ${record.pid}: process identity could not be established.`); + continue; + } + if (!managedProcessRecordMatches(config, record, identity)) { + if (strict) throw new Error(`Refusing to signal PID ${record.pid}: managed-process identity no longer matches the recorded dev-stack service.`); + continue; + } + owned.push(record); + } + if (changed) await writeManagedProcessRecords(config, owned); + return owned; +} From 95e88f0a6b624ad04f761982988ab153be4e5f38 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 17:15:42 +0900 Subject: [PATCH 47/71] fix(dev): fail closed on Docker ownership --- deploy/docker/dev-stack-lib.mjs | 37 ++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/deploy/docker/dev-stack-lib.mjs b/deploy/docker/dev-stack-lib.mjs index 1e5088148..ce5e3b2c9 100644 --- a/deploy/docker/dev-stack-lib.mjs +++ b/deploy/docker/dev-stack-lib.mjs @@ -88,7 +88,7 @@ export async function resolveConfig(env = process.env, root = repoRoot) { } export function renderCompose(config, images) { - return `services:\n postgres:\n image: ${images.postgres}\n restart: unless-stopped\n environment:\n POSTGRES_DB: spaceapp_dev\n POSTGRES_USER: spaceapp\n POSTGRES_PASSWORD: \${SPACEAPP_DEV_POSTGRES_PASSWORD:-dev-stack-password-not-supplied}\n ports:\n - \"127.0.0.1:${config.ports.postgres}:5432\"\n volumes:\n - postgres-data:/var/lib/postgresql/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U spaceapp -d spaceapp_dev\"]\n interval: 2s\n timeout: 3s\n retries: 30\n temporal:\n image: ${images.temporal}\n restart: unless-stopped\n depends_on:\n postgres:\n condition: service_healthy\n environment:\n DB: postgres12\n DB_PORT: 5432\n POSTGRES_USER: spaceapp\n POSTGRES_SEEDS: postgres\n POSTGRES_PWD: \${SPACEAPP_DEV_POSTGRES_PASSWORD:-dev-stack-password-not-supplied}\n DYNAMIC_CONFIG_FILE_PATH: config/dynamicconfig/docker.yaml\n ports:\n - \"127.0.0.1:${config.ports.temporal}:7233\"\n healthcheck:\n test: [\"CMD-SHELL\", \"temporal operator cluster health --address temporal:7233 >/dev/null && temporal operator namespace describe --namespace default --address temporal:7233 >/dev/null\"]\n interval: 3s\n timeout: 5s\n retries: 40\nvolumes:\n postgres-data:\n`; + return `services:\n postgres:\n image: ${images.postgres}\n restart: unless-stopped\n labels:\n com.spaceapp.dev-stack.owner: "${config.rootHash}"\n environment:\n POSTGRES_DB: spaceapp_dev\n POSTGRES_USER: spaceapp\n POSTGRES_PASSWORD: \${SPACEAPP_DEV_POSTGRES_PASSWORD:-dev-stack-password-not-supplied}\n ports:\n - \"127.0.0.1:${config.ports.postgres}:5432\"\n volumes:\n - postgres-data:/var/lib/postgresql/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U spaceapp -d spaceapp_dev\"]\n interval: 2s\n timeout: 3s\n retries: 30\n temporal:\n image: ${images.temporal}\n restart: unless-stopped\n labels:\n com.spaceapp.dev-stack.owner: "${config.rootHash}"\n depends_on:\n postgres:\n condition: service_healthy\n environment:\n DB: postgres12\n DB_PORT: 5432\n POSTGRES_USER: spaceapp\n POSTGRES_SEEDS: postgres\n POSTGRES_PWD: \${SPACEAPP_DEV_POSTGRES_PASSWORD:-dev-stack-password-not-supplied}\n DYNAMIC_CONFIG_FILE_PATH: config/dynamicconfig/docker.yaml\n ports:\n - \"127.0.0.1:${config.ports.temporal}:7233\"\n healthcheck:\n test: [\"CMD-SHELL\", \"temporal operator cluster health --address temporal:7233 >/dev/null && temporal operator namespace describe --namespace default --address temporal:7233 >/dev/null\"]\n interval: 3s\n timeout: 5s\n retries: 40\nvolumes:\n postgres-data:\n labels:\n com.spaceapp.dev-stack.owner: "${config.rootHash}"\nnetworks:\n default:\n labels:\n com.spaceapp.dev-stack.owner: "${config.rootHash}"\n`; } export async function run(command, args, { capture = false, allowFailure = false, env = process.env, cwd = repoRoot } = {}) { @@ -270,8 +270,35 @@ export async function readSupervisorPid(config) { return Number.isInteger(value) ? value : null; } -export async function dockerResources(config) { - const containers = await run("docker", ["ps", "-aq", "--filter", `label=com.docker.compose.project=${config.project}`], { capture: true, allowFailure: true }); - const volumes = await run("docker", ["volume", "ls", "-q", "--filter", `label=com.docker.compose.project=${config.project}`], { capture: true, allowFailure: true }); - return { containers: containers.stdout.split(/\s+/).filter(Boolean), volumes: volumes.stdout.split(/\s+/).filter(Boolean) }; +function splitResourceIds(value) { + return value.split(/\s+/).filter(Boolean); +} + +function sameResources(all, owned) { + if (all.length !== owned.length) return false; + const set = new Set(owned); + return all.every((value) => set.has(value)); +} + +export async function dockerResources(config, runner = run) { + const projectFilter = `label=com.docker.compose.project=${config.project}`; + const ownerFilter = `label=com.spaceapp.dev-stack.owner=${config.rootHash}`; + const [containersResult, ownedContainersResult, volumesResult, ownedVolumesResult, networksResult, ownedNetworksResult] = await Promise.all([ + runner("docker", ["ps", "-aq", "--filter", projectFilter], { capture: true }), + runner("docker", ["ps", "-aq", "--filter", projectFilter, "--filter", ownerFilter], { capture: true }), + runner("docker", ["volume", "ls", "-q", "--filter", projectFilter], { capture: true }), + runner("docker", ["volume", "ls", "-q", "--filter", projectFilter, "--filter", ownerFilter], { capture: true }), + runner("docker", ["network", "ls", "-q", "--filter", projectFilter], { capture: true }), + runner("docker", ["network", "ls", "-q", "--filter", projectFilter, "--filter", ownerFilter], { capture: true }) + ]); + const containers = splitResourceIds(containersResult.stdout); + const ownedContainers = splitResourceIds(ownedContainersResult.stdout); + const volumes = splitResourceIds(volumesResult.stdout); + const ownedVolumes = splitResourceIds(ownedVolumesResult.stdout); + const networks = splitResourceIds(networksResult.stdout); + const ownedNetworks = splitResourceIds(ownedNetworksResult.stdout); + if (!sameResources(containers, ownedContainers) || !sameResources(volumes, ownedVolumes) || !sameResources(networks, ownedNetworks)) { + throw new Error(`Docker project ${config.project} contains resources without this checkout's ownership label; refusing destructive cleanup.`); + } + return { containers: ownedContainers, volumes: ownedVolumes, networks: ownedNetworks }; } From 809937322d571de90fa226b72f77aeb6387956cc Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 17:16:37 +0900 Subject: [PATCH 48/71] fix(dev): make reload and teardown ownership-safe --- deploy/docker/dev-stack.mjs | 253 ++++++++++++++++++++++++++++++------ 1 file changed, 212 insertions(+), 41 deletions(-) diff --git a/deploy/docker/dev-stack.mjs b/deploy/docker/dev-stack.mjs index f13c309fb..38a20697b 100644 --- a/deploy/docker/dev-stack.mjs +++ b/deploy/docker/dev-stack.mjs @@ -24,13 +24,20 @@ import { waitReady } from "./dev-stack-lib.mjs"; import { + assertOwnedState, assertSafeDevStackOverrides, + claimStateOwnership, claimSupervisor, + ownedManagedProcessRecords, ownedSupervisorPid, - releaseSupervisor + processIdentity, + releaseSupervisor, + writeManagedProcessRecords } from "./dev-stack-safety.mjs"; const scriptPath = fileURLToPath(import.meta.url); +const ownershipFileName = ".ownership.json"; +const processFileName = ".processes.json"; function prefixStream(stream, label, target) { let pending = ""; @@ -43,21 +50,52 @@ function prefixStream(stream, label, target) { stream.on("end", () => { if (pending) target.write(`[${label}] ${pending}\n`); }); } -function terminateTree(child, signal = "SIGTERM") { - if (!child || child.exitCode != null) return; +function processGroupAlive(pgid) { + if (!Number.isInteger(pgid) || pgid <= 1) return false; + try { + process.kill(process.platform === "win32" ? pgid : -pgid, 0); + return true; + } catch (error) { + if (error?.code === "ESRCH") return false; + throw error; + } +} + +export function terminateTree(child, signal = "SIGTERM") { + if (!child) return; try { if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal); - else child.kill(signal); + else if (child.exitCode == null) child.kill(signal); } catch (error) { if (error?.code !== "ESRCH") throw error; } } -async function waitDead(child, timeoutMs = 7000) { - if (!child || child.exitCode != null) return; - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline && child.exitCode == null) await sleep(100); - if (child.exitCode == null) terminateTree(child, "SIGKILL"); +export async function waitDead(child, timeoutMs = 7000) { + if (!child?.pid) return; + const alive = () => process.platform === "win32" ? child.exitCode == null : processGroupAlive(child.pid); + let deadline = Date.now() + timeoutMs; + while (Date.now() < deadline && alive()) await sleep(100); + if (!alive()) return; + terminateTree(child, "SIGKILL"); + deadline = Date.now() + 2000; + while (Date.now() < deadline && alive()) await sleep(100); + if (alive()) throw new Error(`Managed process group ${child.pid} did not stop cleanly.`); +} + +async function waitRecordedDead(record, timeoutMs = 7000) { + const alive = () => processGroupAlive(record.pgid); + let deadline = Date.now() + timeoutMs; + while (Date.now() < deadline && alive()) await sleep(100); + if (!alive()) return; + try { + process.kill(process.platform === "win32" ? record.pid : -record.pgid, "SIGKILL"); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } + deadline = Date.now() + 2000; + while (Date.now() < deadline && alive()) await sleep(100); + if (alive()) throw new Error(`Recorded dev-stack process group ${record.pgid} did not stop cleanly.`); } function spawnService(config, name, command, args, env, onUnexpectedExit) { @@ -73,6 +111,40 @@ function spawnService(config, name, command, args, env, onUnexpectedExit) { return child; } +async function identifyManagedChild(config, name, child) { + const deadline = Date.now() + 3000; + while (Date.now() < deadline) { + const identity = await processIdentity(child.pid); + if (identity && identity.pgid === child.pid) { + return { + name, + pid: child.pid, + pgid: child.pid, + rootHash: config.rootHash, + startedAt: identity.startedAt, + args: identity.args + }; + } + if (child.exitCode != null) break; + await sleep(25); + } + throw new Error(`Could not establish managed process identity for ${name}.`); +} + +export async function stopRecordedProcesses(config) { + const records = await ownedManagedProcessRecords(config); + if (!records.length) return; + for (const record of records) { + try { + process.kill(process.platform === "win32" ? record.pid : -record.pgid, "SIGTERM"); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } + } + await Promise.all(records.map((record) => waitRecordedDead(record))); + await writeManagedProcessRecords(config, []); +} + export async function ensureDarwinNodePtySpawnHelpers(root, platform = process.platform) { if (platform !== "darwin") return []; const prebuilds = join(root, "node_modules", "node-pty", "prebuilds"); @@ -122,32 +194,103 @@ async function waitServiceHealthyWithDiagnostics(config, service, timeoutMs) { throw new Error(`${service} did not become healthy within ${Math.round(timeoutMs / 1000)} seconds; last status=${lastStatus}.`); } +async function cleanupDocker(config, { volumes = false } = {}) { + const resources = await dockerResources(config); + const hasRuntimeResources = resources.containers.length || resources.networks.length; + const hasVolumeResources = volumes && resources.volumes.length; + if (!hasRuntimeResources && !hasVolumeResources) return resources; + + if (existsSync(config.composeFile)) { + const args = ["down"]; + if (volumes) args.push("-v"); + args.push("--remove-orphans"); + await compose(config, args); + } else { + if (resources.containers.length) await run("docker", ["rm", "-f", ...resources.containers]); + if (resources.networks.length) await run("docker", ["network", "rm", ...resources.networks]); + if (volumes && resources.volumes.length) await run("docker", ["volume", "rm", ...resources.volumes]); + } + + const remaining = await dockerResources(config); + if (remaining.containers.length || remaining.networks.length || (volumes && remaining.volumes.length)) { + throw new Error(`Docker cleanup left owned resources behind; state preserved for retry: ${JSON.stringify(remaining)}`); + } + return remaining; +} + +async function removeOwnedState(config, ownership) { + if (!existsSync(config.stateRoot)) return; + const knownEntries = new Set([ + ownershipFileName, + processFileName, + "compose.yml", + "config.json", + "supervisor.pid", + "build-marker.json", + "secrets", + "bin", + "vendor", + "providers", + "workspaces", + "memory", + "memory-graph", + "artifacts", + "projects", + "diagnostics", + "opencode-chat", + "run" + ]); + const entries = await readdir(config.stateRoot); + const unknownEntries = entries.filter((entry) => !knownEntries.has(entry)); + for (const entry of entries) { + if (entry === ownershipFileName || unknownEntries.includes(entry)) continue; + await rm(join(config.stateRoot, entry), { recursive: true, force: true }); + } + await rm(join(config.stateRoot, ownershipFileName), { force: false }); + if (ownership.createdStateRoot && unknownEntries.length === 0) { + await rm(config.stateRoot, { recursive: false, force: false }); + } + if (unknownEntries.length) { + console.warn(`[dev:stack] Preserved unrecognized entries in ${config.stateRoot}: ${unknownEntries.join(", ")}`); + } +} + export async function down(config) { - const pid = await ownedSupervisorPid(config); - if (pid && pid !== process.pid) { - process.kill(pid, "SIGTERM"); - await waitSupervisorExit(pid); + const ownership = await assertOwnedState(config, { allowMissing: true }); + if (ownership) { + const pid = await ownedSupervisorPid(config); + if (pid && pid !== process.pid) { + process.kill(pid, "SIGTERM"); + await waitSupervisorExit(pid); + } + await stopRecordedProcesses(config); } - if (existsSync(config.composeFile)) await compose(config, ["down", "--remove-orphans"]); + await cleanupDocker(config, { volumes: false }); } export async function reset(config) { + const ownership = await assertOwnedState(config, { allowMissing: true }); await down(config); - if (existsSync(config.composeFile)) await compose(config, ["down", "-v", "--remove-orphans"]); + await cleanupDocker(config, { volumes: true }); const remaining = await dockerResources(config); - if (remaining.containers.length || remaining.volumes.length) { + if (remaining.containers.length || remaining.volumes.length || remaining.networks.length) { throw new Error(`Reset left owned Docker resources behind; state preserved for retry: ${JSON.stringify(remaining)}`); } - await rm(config.stateRoot, { recursive: true, force: true }); - const tmpSocketRoot = dirname(config.socketPath); - if (!tmpSocketRoot.startsWith(config.stateRoot) && tmpSocketRoot.includes(`spaceapp-dev-${config.rootHash}`)) { - await rm(tmpSocketRoot, { recursive: true, force: true }); + + if (ownership) { + const tmpSocketRoot = dirname(config.socketPath); + if (!tmpSocketRoot.startsWith(config.stateRoot) && tmpSocketRoot.includes(`spaceapp-dev-${config.rootHash}`)) { + await rm(tmpSocketRoot, { recursive: true, force: true }); + } + await assertOwnedState(config); + await removeOwnedState(config, ownership); } console.log("[dev:stack] Reset complete; owned containers, volumes and state were removed."); } export async function status(config) { - const pid = await ownedSupervisorPid(config, { strict: false }); + const ownership = await assertOwnedState(config, { allowMissing: true }); + const pid = ownership ? await ownedSupervisorPid(config, { strict: false }) : null; const resources = await dockerResources(config); const payload = { running: Boolean(pid), @@ -162,6 +305,7 @@ export async function status(config) { } export async function rebuild(config) { + await assertOwnedState(config); const pid = await ownedSupervisorPid(config); if (!pid) { await ensureBuild(config, true); @@ -177,28 +321,26 @@ export async function start(config) { const dockerfile = await readFile(resolve(config.root, "Dockerfile"), "utf8"); const images = parsePinnedImages(composeTemplate); const openCodeVersion = parseOpenCodeVersion(dockerfile); - const { databaseUrl, sessionSecret, postgresPassword } = await prepareState(config); - await mkdir(dirname(config.composeFile), { recursive: true, mode: 0o700 }); - await writeFile(config.composeFile, renderCompose(config, images), { mode: 0o600 }); - await writeFile(config.configFile, `${JSON.stringify({ - root: config.root, - stateRoot: config.stateRoot, - project: config.project, - ports: config.ports, - socketPath: config.socketPath, - openCodeVersion - }, null, 2)}\n`, { mode: 0o600 }); await run("docker", ["compose", "version"]); + await claimStateOwnership(config); await claimSupervisor(config); + let children = new Map(); + let processRecords = new Map(); let stopping = false; let reloadChain = Promise.resolve(); + let databaseUrl; + let sessionSecret; + let postgresPassword; const stopChildren = async () => { - for (const child of children.values()) terminateTree(child); - await Promise.all([...children.values()].map((child) => waitDead(child))); + const current = [...children.values()]; children = new Map(); + for (const child of current) terminateTree(child); + await Promise.all(current.map((child) => waitDead(child))); + processRecords = new Map(); + await writeManagedProcessRecords(config, []); try { await rm(config.socketPath, { force: true }); } catch {} }; @@ -206,9 +348,12 @@ export async function start(config) { if (stopping) return; stopping = true; console.log(`[dev:stack] Stopping (${reason})...`); - await stopChildren(); - if (existsSync(config.composeFile)) await compose(config, ["down", "--remove-orphans"], { allowFailure: true }); - await releaseSupervisor(config); + try { + await stopChildren(); + if (existsSync(config.composeFile)) await compose(config, ["down", "--remove-orphans"], { allowFailure: true }); + } finally { + await releaseSupervisor(config); + } }; const unexpectedExit = (name, child, code, signal) => { @@ -218,6 +363,14 @@ export async function start(config) { void shutdown("child-failure").finally(() => process.exit(1)); }; + const spawnManaged = async (name, command, args, env) => { + const child = spawnService(config, name, command, args, env, unexpectedExit); + children.set(name, child); + const record = await identifyManagedChild(config, name, child); + processRecords.set(name, record); + await writeManagedProcessRecords(config, [...processRecords.values()]); + }; + const spawnChildren = async (forceBuild = false) => { await ensureBuild(config, forceBuild); const repairedHelpers = await ensureDarwinNodePtySpawnHelpers(config.root); @@ -226,10 +379,10 @@ export async function start(config) { } const openCodeWrapper = await installOpenCode(config, openCodeVersion); const env = sourceEnv(config, databaseUrl, sessionSecret, openCodeWrapper); - children.set("cli-host", spawnService(config, "cli-host", process.execPath, ["packages/cli-host/dist/main.js"], env, unexpectedExit)); - children.set("worker", spawnService(config, "worker", process.execPath, ["apps/worker/dist/worker.js"], env, unexpectedExit)); - children.set("api", spawnService(config, "api", "npm", ["exec", "--", "tsx", "watch", "apps/api/src/server.ts"], env, unexpectedExit)); - children.set("web", spawnService(config, "web", "npm", ["run", "dev", "-w", "@space/web", "--", "--host", "127.0.0.1", "--port", String(config.ports.web)], env, unexpectedExit)); + await spawnManaged("cli-host", process.execPath, ["packages/cli-host/dist/main.js"], env); + await spawnManaged("worker", process.execPath, ["apps/worker/dist/worker.js"], env); + await spawnManaged("api", "npm", ["exec", "--", "tsx", "watch", "apps/api/src/server.ts"], env); + await spawnManaged("web", "npm", ["run", "dev", "-w", "@space/web", "--", "--host", "127.0.0.1", "--port", String(config.ports.web)], env); }; const reload = () => { @@ -237,7 +390,12 @@ export async function start(config) { if (stopping) return; console.log("[dev:stack] Rebuilding workspaces and restarting source services..."); await stopChildren(); + if (stopping) return; await spawnChildren(true); + if (stopping) { + await stopChildren(); + return; + } await waitReady(config); console.log("[dev:stack] Rebuild complete; source services are ready."); }).catch((error) => { @@ -252,6 +410,19 @@ export async function start(config) { process.on("SIGHUP", reload); try { + await stopRecordedProcesses(config); + ({ databaseUrl, sessionSecret, postgresPassword } = await prepareState(config)); + await mkdir(dirname(config.composeFile), { recursive: true, mode: 0o700 }); + await writeFile(config.composeFile, renderCompose(config, images), { mode: 0o600 }); + await writeFile(config.configFile, `${JSON.stringify({ + root: config.root, + stateRoot: config.stateRoot, + project: config.project, + ports: config.ports, + socketPath: config.socketPath, + openCodeVersion + }, null, 2)}\n`, { mode: 0o600 }); + console.log(`[dev:stack] Starting digest-pinned PostgreSQL and Temporal (${config.project})...`); await compose(config, ["up", "-d", "postgres", "temporal"], { env: { ...process.env, SPACEAPP_DEV_POSTGRES_PASSWORD: postgresPassword } From dea616faf27ad4c6e40309f5d0e0038d5a85b842 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 17:17:25 +0900 Subject: [PATCH 49/71] test(dev): cover ownership and orphan cleanup --- deploy/docker/dev-stack.test.mjs | 129 ++++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 4 deletions(-) diff --git a/deploy/docker/dev-stack.test.mjs b/deploy/docker/dev-stack.test.mjs index 9b730b0d5..6ec3b322e 100644 --- a/deploy/docker/dev-stack.test.mjs +++ b/deploy/docker/dev-stack.test.mjs @@ -1,23 +1,33 @@ import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { existsSync } from "node:fs"; import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { assertSafeStateRoot, + dockerResources, parseOpenCodeVersion, parsePinnedImages, + pidAlive, renderCompose, resolveConfig, sourceEnv } from "./dev-stack-lib.mjs"; -import { ensureDarwinNodePtySpawnHelpers } from "./dev-stack.mjs"; +import { ensureDarwinNodePtySpawnHelpers, stopRecordedProcesses, terminateTree, waitDead } from "./dev-stack.mjs"; import { + assertOwnedState, assertSafeDevStackOverrides, + claimStateOwnership, claimSupervisor, + managedProcessRecordMatches, ownedSupervisorPid, + processIdentity, releaseSupervisor, - supervisorRecordMatches + supervisorRecordMatches, + writeManagedProcessRecords } from "./dev-stack-safety.mjs"; test("source stack derives third-party pins from canonical files", () => { @@ -52,6 +62,7 @@ test("defaults isolate installed SpaceApp and bind Docker ports to loopback", as assert.doesNotMatch(compose, /\/run\/secrets\/postgres-password|secrets:\s*\[postgres-password\]/); assert.match(compose, /condition: service_healthy/); assert.match(compose, /temporal operator cluster health --address temporal:7233/); + assert.equal((compose.match(/com\.spaceapp\.dev-stack\.owner:/g) || []).length, 4); const wrapper = join(config.binDir, "opencode-vscode-parity"); const oldComposePassword = process.env.SPACEAPP_DEV_POSTGRES_PASSWORD; @@ -85,6 +96,43 @@ test("unsafe state roots and installed-instance ports fail closed", async () => } }); +test("state ownership never adopts a non-empty pre-existing directory", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-state-owner-")); + try { + const config = await resolveConfig({}, root); + await mkdir(config.stateRoot, { mode: 0o700 }); + const keep = join(config.stateRoot, "keep.txt"); + await writeFile(keep, "user data\n"); + await assert.rejects(() => claimStateOwnership(config), /not initialized by dev:stack/); + assert.equal(await readFile(keep, "utf8"), "user data\n"); + + await rm(keep); + const record = await claimStateOwnership(config); + assert.equal(record.createdStateRoot, false); + assert.equal((await assertOwnedState(config)).rootHash, config.rootHash); + + await writeFile(join(config.stateRoot, ".ownership.json"), "{broken\n", { mode: 0o600 }); + await assert.rejects(() => assertOwnedState(config), /ownership record is corrupt/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("Docker inventory fails closed on query failure or same-project unowned resources", async () => { + const config = { project: "spaceapp-dev-test", rootHash: "0123456789" }; + const ownerFilter = `label=com.spaceapp.dev-stack.owner=${config.rootHash}`; + const cleanRunner = async (_command, args) => ({ code: 0, signal: null, stdout: args.includes(ownerFilter) ? "owned" : "owned", stderr: "" }); + assert.deepEqual(await dockerResources(config, cleanRunner), { + containers: ["owned"], + volumes: ["owned"], + networks: ["owned"] + }); + + const collisionRunner = async (_command, args) => ({ code: 0, signal: null, stdout: args.includes(ownerFilter) ? "owned" : "owned foreign", stderr: "" }); + await assert.rejects(() => dockerResources(config, collisionRunner), /without this checkout's ownership label/); + await assert.rejects(() => dockerResources(config, async () => { throw new Error("daemon unavailable"); }), /daemon unavailable/); +}); + test("destructive ownership selectors cannot be overridden", () => { assert.doesNotThrow(() => assertSafeDevStackOverrides({})); assert.throws(() => assertSafeDevStackOverrides({ SPACE_DEV_STACK_DIR: "/tmp/shared" }), /SPACE_DEV_STACK_DIR/); @@ -97,7 +145,7 @@ test("supervisor identity rejects PID reuse and atomically claims a checkout", a rootHash: "0123456789", pidFile: join(root, "supervisor.pid") }; - const ownedIdentity = async () => ({ startedAt: "Sun Oct 18 07:40:00 2026", args: "node deploy/docker/dev-stack.mjs" }); + const ownedIdentity = async () => ({ startedAt: "Sun Oct 18 07:40:00 2026", args: "node deploy/docker/dev-stack.mjs", state: "S", pgid: process.pid }); try { const record = await claimSupervisor(config, ownedIdentity); assert.equal(record.pid, process.pid); @@ -105,7 +153,7 @@ test("supervisor identity rejects PID reuse and atomically claims a checkout", a assert.equal(supervisorRecordMatches(config, record, await ownedIdentity()), true); await assert.rejects(() => claimSupervisor(config, ownedIdentity), /already running/); - const reusedIdentity = async () => ({ startedAt: "Sun Oct 18 07:41:00 2026", args: "node unrelated-tool.mjs" }); + const reusedIdentity = async () => ({ startedAt: "Sun Oct 18 07:41:00 2026", args: "node unrelated-tool.mjs", state: "S", pgid: process.pid }); await assert.rejects(() => ownedSupervisorPid(config, { identityProvider: reusedIdentity }), /Refusing to signal PID/); await releaseSupervisor(config, process.pid); @@ -117,6 +165,79 @@ test("supervisor identity rejects PID reuse and atomically claims a checkout", a } }); +test("managed process identity rejects PID reuse", () => { + const config = { rootHash: "0123456789" }; + const record = { + name: "api", + pid: 4321, + pgid: 4321, + rootHash: config.rootHash, + startedAt: "Sun Oct 18 08:00:00 2026", + args: "npm exec -- tsx watch apps/api/src/server.ts" + }; + assert.equal(managedProcessRecordMatches(config, record, { + startedAt: record.startedAt, + args: record.args, + state: "S", + pgid: record.pgid + }), true); + assert.equal(managedProcessRecordMatches(config, record, { + startedAt: "Sun Oct 18 08:01:00 2026", + args: record.args, + state: "S", + pgid: record.pgid + }), false); +}); + +test("persisted managed process records allow cleanup after supervisor loss", { skip: process.platform === "win32" }, async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-orphan-test-")); + const config = { rootHash: "0123456789", stateRoot: root }; + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + detached: true, + stdio: "ignore" + }); + try { + let identity = null; + const deadline = Date.now() + 3000; + while (Date.now() < deadline && !identity) { + identity = await processIdentity(child.pid); + if (!identity) await new Promise((resolvePromise) => setTimeout(resolvePromise, 25)); + } + assert.ok(identity); + assert.equal(identity.pgid, child.pid); + await writeManagedProcessRecords(config, [{ + name: "synthetic", + pid: child.pid, + pgid: child.pid, + rootHash: config.rootHash, + startedAt: identity.startedAt, + args: identity.args + }]); + await stopRecordedProcesses(config); + assert.equal(existsSync(join(root, ".processes.json")), false); + assert.equal(pidAlive(child.pid), false); + } finally { + try { process.kill(-child.pid, "SIGKILL"); } catch {} + await rm(root, { recursive: true, force: true }); + } +}); + +test("terminateTree still kills descendants after the detached leader exits", { skip: process.platform === "win32" }, async () => { + const leader = spawn(process.execPath, ["-e", ` + const { spawn } = require("node:child_process"); + spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }); + setTimeout(() => process.exit(0), 100); + `], { detached: true, stdio: "ignore" }); + try { + await once(leader, "exit"); + assert.notEqual(leader.exitCode, null); + terminateTree(leader, "SIGTERM"); + await waitDead(leader, 500); + } finally { + try { process.kill(-leader.pid, "SIGKILL"); } catch {} + } +}); + test("macOS source stack repairs node-pty spawn-helper execute permissions", async () => { const root = await mkdtemp(join(tmpdir(), "spaceapp-pty-helper-")); const helper = join(root, "node_modules", "node-pty", "prebuilds", "darwin-x64", "spawn-helper"); From a6087b59614f34dce4eff180fa04b37ecde776f2 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 17:19:39 +0900 Subject: [PATCH 50/71] fix(dev): retain verifiable orphan process groups --- deploy/docker/dev-stack-safety.mjs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/deploy/docker/dev-stack-safety.mjs b/deploy/docker/dev-stack-safety.mjs index f46a85db5..4f5d2120a 100644 --- a/deploy/docker/dev-stack-safety.mjs +++ b/deploy/docker/dev-stack-safety.mjs @@ -14,6 +14,17 @@ function isZombie(identity) { return typeof identity?.state === "string" && identity.state.startsWith("Z"); } +function processGroupExists(pgid) { + if (process.platform === "win32" || !Number.isInteger(pgid) || pgid <= 1) return false; + try { + process.kill(-pgid, 0); + return true; + } catch (error) { + if (error?.code === "ESRCH") return false; + throw error; + } +} + export function assertSafeDevStackOverrides(env = process.env) { for (const key of unsafeOverrideKeys) { if (env[key] != null && env[key] !== "") { @@ -220,10 +231,23 @@ export async function ownedManagedProcessRecords(config, { strict = true, identi const owned = []; let changed = false; for (const record of records) { + if (!Number.isInteger(record.pid) || record.pid <= 1 || record.pid !== record.pgid || record.rootHash !== config.rootHash) { + if (strict) throw new Error("Refusing to use an invalid managed-process ownership record."); + continue; + } + if (!pidAlive(record.pid)) { - changed = true; + if (processGroupExists(record.pgid)) { + // A POSIX process group cannot be replaced by a new group with the same PGID while + // descendants of the recorded group still exist. With the original leader PID absent, + // this is the only safe case where we may recover the recorded descendants by PGID. + owned.push({ ...record, leaderExited: true }); + } else { + changed = true; + } continue; } + const identity = await identityProvider(record.pid); if (!identity) { if (strict) throw new Error(`Refusing to signal PID ${record.pid}: process identity could not be established.`); From 35d5a704f5e712420051dfb0aecb0a540a301d08 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 17:20:52 +0900 Subject: [PATCH 51/71] ci(dev): run one-shot builder verification --- .../workflows/dev-stack-builder-verify.yml | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 .github/workflows/dev-stack-builder-verify.yml diff --git a/.github/workflows/dev-stack-builder-verify.yml b/.github/workflows/dev-stack-builder-verify.yml new file mode 100644 index 000000000..012ae4f8d --- /dev/null +++ b/.github/workflows/dev-stack-builder-verify.yml @@ -0,0 +1,89 @@ +name: Dev stack builder verification + +on: + push: + branches: + - feat/dev-stack-65 + +permissions: + contents: read + +jobs: + verify-linux: + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install dependencies + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + run: npm ci + + - name: Unit and lifecycle regression tests + run: npm run test:dev-stack + + - name: Start full source stack + shell: bash + run: | + set -euo pipefail + npm run dev:stack >"$RUNNER_TEMP/dev-stack.log" 2>&1 & + echo $! >"$RUNNER_TEMP/dev-stack-shell.pid" + + - name: Verify owner flow and OpenCode pane + run: npm run verify:dev-stack + + - name: Verify shared-package rebuild keeps supervisor alive + shell: bash + run: | + set -euo pipefail + json_pid() { + node -e 'let d=""; process.stdin.on("data", c => d += c); process.stdin.on("end", () => { const x = JSON.parse(d); if (!x.running || !x.supervisorPid) process.exit(2); process.stdout.write(String(x.supervisorPid)); });' + } + before_pid="$(npm run --silent dev:stack -- status | json_pid)" + before_mtime="$(stat -c %Y packages/contracts/dist/index.js)" + sleep 1 + touch packages/contracts/src/index.ts + npm run dev:stack -- rebuild + timeout 300 bash -c 'until grep -q "Rebuild complete; source services are ready." "$RUNNER_TEMP/dev-stack.log"; do sleep 1; done' + after_pid="$(npm run --silent dev:stack -- status | json_pid)" + after_mtime="$(stat -c %Y packages/contracts/dist/index.js)" + test "$before_pid" = "$after_pid" + test "$after_mtime" -gt "$before_mtime" + curl --fail --silent --show-error "http://127.0.0.1:5910/readyz" \ + | node -e 'let d=""; process.stdin.on("data", c => d += c); process.stdin.on("end", () => { const x = JSON.parse(d); if (x.ok !== true || x.dependencies?.worker !== "RUNNING" || x.dependencies?.cliHost !== "RUNNING") process.exit(3); });' + + - name: Reset and prove cleanup + if: always() + shell: bash + run: | + set +e + npm run dev:stack -- down + down_status=$? + npm run dev:stack -- reset + reset_status=$? + shell_pid="$(cat "$RUNNER_TEMP/dev-stack-shell.pid" 2>/dev/null || true)" + if [ -n "$shell_pid" ] && kill -0 "$shell_pid" 2>/dev/null; then + echo "dev-stack launcher $shell_pid still alive" >&2 + kill "$shell_pid" 2>/dev/null || true + exit 1 + fi + status_json="$(npm run --silent dev:stack -- status 2>/dev/null)" + echo "$status_json" + if ! echo "$status_json" | grep -q '"containers": \[\]' \ + || ! echo "$status_json" | grep -q '"volumes": \[\]' \ + || ! echo "$status_json" | grep -q '"networks": \[\]'; then + echo "owned Docker resources remain" >&2 + exit 1 + fi + echo "--- dev-stack log ---" + cat "$RUNNER_TEMP/dev-stack.log" 2>/dev/null || true + test "$down_status" -eq 0 + test "$reset_status" -eq 0 From a5c9d2d7657d3ade60f586ff7cf1b0817c875e6d Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 17:23:01 +0900 Subject: [PATCH 52/71] fix(dev): isolate OpenCode runtime paths and complete reset Override installed-runtime XDG state and temporary paths in the source OpenCode wrapper, create private directories, and shell-quote literal paths. Clear inherited OpenCode credentials and use rmdir for the empty state root. Add regressions for wrapper isolation and successful state removal. --- deploy/docker/dev-stack-lib.mjs | 22 +++++++++++-- deploy/docker/dev-stack.mjs | 4 +-- deploy/docker/dev-stack.test.mjs | 54 ++++++++++++++++++++++++++++++-- 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/deploy/docker/dev-stack-lib.mjs b/deploy/docker/dev-stack-lib.mjs index ce5e3b2c9..af6ba30da 100644 --- a/deploy/docker/dev-stack-lib.mjs +++ b/deploy/docker/dev-stack-lib.mjs @@ -172,7 +172,25 @@ export async function installOpenCode(config, version) { } const wrapper = join(config.binDir, "opencode-vscode-parity"); const binary = join(config.vendorDir, "node_modules", ".bin", "opencode"); - await writeFile(wrapper, `#!/bin/sh\nset -eu\nexport HOME=${JSON.stringify(join(config.providerRoot, "home"))}\nexport XDG_CONFIG_HOME=${JSON.stringify(join(config.providerRoot, "config"))}\nexport XDG_DATA_HOME=${JSON.stringify(join(config.providerRoot, "data"))}\nexport XDG_CACHE_HOME=${JSON.stringify(join(config.providerRoot, "cache"))}\nexec ${JSON.stringify(binary)} \"$@\"\n`, { mode: 0o700 }); + // The API supplies installed-runtime paths to CLI panes; override them at + // the executable boundary so source runs never write to an installed instance. + const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`; + const paths = { + HOME: join(config.providerRoot, "home"), + XDG_CONFIG_HOME: join(config.providerRoot, "config"), + XDG_DATA_HOME: join(config.providerRoot, "data"), + XDG_CACHE_HOME: join(config.providerRoot, "cache"), + XDG_STATE_HOME: join(config.providerRoot, "state"), + TMPDIR: join(config.providerRoot, "tmp") + }; + const exports = Object.entries(paths).map(([key, value]) => `export ${key}=${shellQuote(value)}`); + await writeFile(wrapper, [ + "#!/bin/sh", "set -eu", "umask 077", ...exports, + 'export TMP="$TMPDIR" TEMP="$TMPDIR"', + "unset OPENCODE_API_KEY", + 'mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME" "$XDG_STATE_HOME" "$TMPDIR"', + `exec ${shellQuote(binary)} "$@"`, "" + ].join("\n"), { mode: 0o700 }); await chmod(wrapper, 0o700); return wrapper; } @@ -199,7 +217,7 @@ export async function ensureTemporalNamespace(config) { export function sourceEnv(config, databaseUrl, sessionSecret, openCodeWrapper) { const env = { ...process.env }; - for (const key of ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "GROK_API_KEY", "DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GITHUB_TOKEN", "GH_TOKEN", "SPACEAPP_DEV_POSTGRES_PASSWORD"]) delete env[key]; + for (const key of ["OPENCODE_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "GROK_API_KEY", "DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GITHUB_TOKEN", "GH_TOKEN", "SPACEAPP_DEV_POSTGRES_PASSWORD"]) delete env[key]; if (openCodeWrapper !== join(config.binDir, "opencode-vscode-parity")) { throw new Error("OpenCode wrapper must live inside the controlled dev-stack command directory."); } diff --git a/deploy/docker/dev-stack.mjs b/deploy/docker/dev-stack.mjs index 38a20697b..9c572b516 100644 --- a/deploy/docker/dev-stack.mjs +++ b/deploy/docker/dev-stack.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { existsSync } from "node:fs"; -import { chmod, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, readdir, rm, rmdir, stat, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; @@ -248,7 +248,7 @@ async function removeOwnedState(config, ownership) { } await rm(join(config.stateRoot, ownershipFileName), { force: false }); if (ownership.createdStateRoot && unknownEntries.length === 0) { - await rm(config.stateRoot, { recursive: false, force: false }); + await rmdir(config.stateRoot); } if (unknownEntries.length) { console.warn(`[dev:stack] Preserved unrecognized entries in ${config.stateRoot}: ${unknownEntries.join(", ")}`); diff --git a/deploy/docker/dev-stack.test.mjs b/deploy/docker/dev-stack.test.mjs index 6ec3b322e..19d82d1dd 100644 --- a/deploy/docker/dev-stack.test.mjs +++ b/deploy/docker/dev-stack.test.mjs @@ -1,14 +1,16 @@ import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import { once } from "node:events"; import { existsSync } from "node:fs"; import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import { promisify } from "node:util"; import { assertSafeStateRoot, dockerResources, + installOpenCode, parseOpenCodeVersion, parsePinnedImages, pidAlive, @@ -16,7 +18,7 @@ import { resolveConfig, sourceEnv } from "./dev-stack-lib.mjs"; -import { ensureDarwinNodePtySpawnHelpers, stopRecordedProcesses, terminateTree, waitDead } from "./dev-stack.mjs"; +import { ensureDarwinNodePtySpawnHelpers, reset, stopRecordedProcesses, terminateTree, waitDead } from "./dev-stack.mjs"; import { assertOwnedState, assertSafeDevStackOverrides, @@ -45,6 +47,54 @@ services: assert.throws(() => parseOpenCodeVersion("RUN npm install opencode-ai@latest"), /pinned/); }); +test("OpenCode wrapper isolates every writable path and preserves literal checkout paths", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-opencode-$literal-'quote-")); + try { + const config = await resolveConfig({}, root); + await mkdir(config.binDir, { recursive: true }); + await mkdir(join(config.vendorDir, "node_modules", "opencode-ai"), { recursive: true }); + await mkdir(join(config.vendorDir, "node_modules", ".bin"), { recursive: true }); + await writeFile(join(config.vendorDir, "node_modules", "opencode-ai", "package.json"), JSON.stringify({ version: "1.18.4" })); + const keys = ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME", "TMPDIR", "TMP", "TEMP"]; + await writeFile(join(config.vendorDir, "node_modules", ".bin", "opencode"), + `#!${process.execPath}\nconsole.log(JSON.stringify({paths:Object.fromEntries(${JSON.stringify(keys)}.map(k=>[k,process.env[k]])),key:process.env.OPENCODE_API_KEY,args:process.argv.slice(2)}));\n`, { mode: 0o700 }); + const wrapper = await installOpenCode(config, "1.18.4"); + const { stdout } = await promisify(execFile)(wrapper, ["argument with spaces", "$literal"], { + env: { ...process.env, ...Object.fromEntries(keys.map(k => [k, "/proc/spaceapp-unwritable"])), OPENCODE_API_KEY: "parent-key" } + }); + const result = JSON.parse(stdout); + const dirs = ["home", "config", "data", "cache", "state", "tmp", "tmp", "tmp"]; + for (const [index, key] of keys.entries()) { + assert.equal(result.paths[key], join(config.providerRoot, dirs[index])); + assert.equal((await stat(result.paths[key])).isDirectory(), true); + } + assert.equal(result.key, undefined); + assert.deepEqual(result.args, ["argument with spaces", "$literal"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("reset removes the owned state root after an empty Docker inventory", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-reset-empty-")); + const oldPath = process.env.PATH; + try { + const bin = join(root, "fake-bin"); + await mkdir(bin); + // Docker behavior is deliberately stubbed: this regression covers directory removal only. + await writeFile(join(bin, "docker"), "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + process.env.PATH = `${bin}:${oldPath}`; + const config = await resolveConfig({}, root); + await claimStateOwnership(config); + await reset(config); + assert.equal(existsSync(config.stateRoot), false); + } finally { + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + await rm(root, { recursive: true, force: true }); + } +}); + test("defaults isolate installed SpaceApp and bind Docker ports to loopback", async () => { const root = await mkdtemp(join(tmpdir(), "spaceapp-dev-test-")); try { From de0455cb8b721d2a83b98e178e84a84cb28ba7ef Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 17:28:22 +0900 Subject: [PATCH 53/71] ci(dev): remove one-shot builder verification --- .../workflows/dev-stack-builder-verify.yml | 89 ------------------- 1 file changed, 89 deletions(-) delete mode 100644 .github/workflows/dev-stack-builder-verify.yml diff --git a/.github/workflows/dev-stack-builder-verify.yml b/.github/workflows/dev-stack-builder-verify.yml deleted file mode 100644 index 012ae4f8d..000000000 --- a/.github/workflows/dev-stack-builder-verify.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Dev stack builder verification - -on: - push: - branches: - - feat/dev-stack-65 - -permissions: - contents: read - -jobs: - verify-linux: - runs-on: ubuntu-latest - timeout-minutes: 35 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version-file: .nvmrc - cache: npm - - - name: Install dependencies - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" - run: npm ci - - - name: Unit and lifecycle regression tests - run: npm run test:dev-stack - - - name: Start full source stack - shell: bash - run: | - set -euo pipefail - npm run dev:stack >"$RUNNER_TEMP/dev-stack.log" 2>&1 & - echo $! >"$RUNNER_TEMP/dev-stack-shell.pid" - - - name: Verify owner flow and OpenCode pane - run: npm run verify:dev-stack - - - name: Verify shared-package rebuild keeps supervisor alive - shell: bash - run: | - set -euo pipefail - json_pid() { - node -e 'let d=""; process.stdin.on("data", c => d += c); process.stdin.on("end", () => { const x = JSON.parse(d); if (!x.running || !x.supervisorPid) process.exit(2); process.stdout.write(String(x.supervisorPid)); });' - } - before_pid="$(npm run --silent dev:stack -- status | json_pid)" - before_mtime="$(stat -c %Y packages/contracts/dist/index.js)" - sleep 1 - touch packages/contracts/src/index.ts - npm run dev:stack -- rebuild - timeout 300 bash -c 'until grep -q "Rebuild complete; source services are ready." "$RUNNER_TEMP/dev-stack.log"; do sleep 1; done' - after_pid="$(npm run --silent dev:stack -- status | json_pid)" - after_mtime="$(stat -c %Y packages/contracts/dist/index.js)" - test "$before_pid" = "$after_pid" - test "$after_mtime" -gt "$before_mtime" - curl --fail --silent --show-error "http://127.0.0.1:5910/readyz" \ - | node -e 'let d=""; process.stdin.on("data", c => d += c); process.stdin.on("end", () => { const x = JSON.parse(d); if (x.ok !== true || x.dependencies?.worker !== "RUNNING" || x.dependencies?.cliHost !== "RUNNING") process.exit(3); });' - - - name: Reset and prove cleanup - if: always() - shell: bash - run: | - set +e - npm run dev:stack -- down - down_status=$? - npm run dev:stack -- reset - reset_status=$? - shell_pid="$(cat "$RUNNER_TEMP/dev-stack-shell.pid" 2>/dev/null || true)" - if [ -n "$shell_pid" ] && kill -0 "$shell_pid" 2>/dev/null; then - echo "dev-stack launcher $shell_pid still alive" >&2 - kill "$shell_pid" 2>/dev/null || true - exit 1 - fi - status_json="$(npm run --silent dev:stack -- status 2>/dev/null)" - echo "$status_json" - if ! echo "$status_json" | grep -q '"containers": \[\]' \ - || ! echo "$status_json" | grep -q '"volumes": \[\]' \ - || ! echo "$status_json" | grep -q '"networks": \[\]'; then - echo "owned Docker resources remain" >&2 - exit 1 - fi - echo "--- dev-stack log ---" - cat "$RUNNER_TEMP/dev-stack.log" 2>/dev/null || true - test "$down_status" -eq 0 - test "$reset_status" -eq 0 From 293847a435123bdedc768831b0bf71911f916025 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 17:36:47 +0900 Subject: [PATCH 54/71] fix(dev): close lifecycle ownership gaps --- .github/workflows/dev-stack.yml | 42 ++++ deploy/docker/dev-stack-lib.mjs | 62 +++-- deploy/docker/dev-stack-safety.mjs | 259 ++++++++++--------- deploy/docker/dev-stack.mjs | 385 ++++++++++++----------------- deploy/docker/dev-stack.test.mjs | 219 ++++++++-------- 5 files changed, 486 insertions(+), 481 deletions(-) mode change 100644 => 100755 deploy/docker/dev-stack.mjs diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml index 706cd1ef8..224790c24 100644 --- a/.github/workflows/dev-stack.yml +++ b/.github/workflows/dev-stack.yml @@ -57,6 +57,48 @@ jobs: - name: Verify readiness, owner setup, and OpenCode pane run: npm run verify:dev-stack + - name: Verify a running shared-package rebuild + shell: bash + timeout-minutes: 10 + run: | + set -euo pipefail + probe="dev_stack_rebuild_${GITHUB_RUN_ID}_${GITHUB_RUN_ATTEMPT}" + before_pid="$(node -e 'const fs=require("node:fs"); process.stdout.write(String(JSON.parse(fs.readFileSync(".dev-stack/supervisor.pid","utf8")).pid))')" + printf '\nexport const __devStackRebuildProbe = "%s";\n' "$probe" >> packages/contracts/src/index.ts + + npm run dev:stack -- rebuild + + rebuilt=0 + for attempt in $(seq 1 240); do + ready=0 + if curl -fsS "http://127.0.0.1:5910/readyz" | node -e ' + let input=""; + process.stdin.on("data", (chunk) => { input += chunk; }); + process.stdin.on("end", () => { + try { + const body = JSON.parse(input); + process.exit(body.ok === true && body.dependencies?.database === "RUNNING" && body.dependencies?.worker === "RUNNING" && body.dependencies?.cliHost === "RUNNING" ? 0 : 1); + } catch { process.exit(1); } + }); + '; then + ready=1 + fi + if grep -Fq "$probe" packages/contracts/dist/index.js && [ "$ready" -eq 1 ] && grep -Fq "Rebuild complete; source services are ready." "$RUNNER_TEMP/dev-stack.log"; then + rebuilt=1 + break + fi + sleep 1 + done + + if [ "$rebuilt" -ne 1 ]; then + echo "shared-package rebuild did not become observable in the running stack" >&2 + tail -200 "$RUNNER_TEMP/dev-stack.log" >&2 || true + exit 1 + fi + after_pid="$(node -e 'const fs=require("node:fs"); process.stdout.write(String(JSON.parse(fs.readFileSync(".dev-stack/supervisor.pid","utf8")).pid))')" + test "$before_pid" = "$after_pid" + echo "shared-package rebuild was picked up by supervisor $after_pid" + - name: Stop and reset owned resources if: always() shell: bash diff --git a/deploy/docker/dev-stack-lib.mjs b/deploy/docker/dev-stack-lib.mjs index af6ba30da..b01bc2140 100644 --- a/deploy/docker/dev-stack-lib.mjs +++ b/deploy/docker/dev-stack-lib.mjs @@ -71,7 +71,9 @@ export async function resolveConfig(env = process.env, root = repoRoot) { root, rootHash, stateRoot, project, ports, socketPath, composeFile: join(stateRoot, "compose.yml"), configFile: join(stateRoot, "config.json"), + ownerFile: join(stateRoot, "ownership.json"), pidFile: join(stateRoot, "supervisor.pid"), + processFile: join(stateRoot, "process-groups.json"), buildMarker: join(stateRoot, "build-marker.json"), secretsDir: join(stateRoot, "secrets"), binDir: join(stateRoot, "bin"), @@ -87,8 +89,10 @@ export async function resolveConfig(env = process.env, root = repoRoot) { }; } -export function renderCompose(config, images) { - return `services:\n postgres:\n image: ${images.postgres}\n restart: unless-stopped\n labels:\n com.spaceapp.dev-stack.owner: "${config.rootHash}"\n environment:\n POSTGRES_DB: spaceapp_dev\n POSTGRES_USER: spaceapp\n POSTGRES_PASSWORD: \${SPACEAPP_DEV_POSTGRES_PASSWORD:-dev-stack-password-not-supplied}\n ports:\n - \"127.0.0.1:${config.ports.postgres}:5432\"\n volumes:\n - postgres-data:/var/lib/postgresql/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U spaceapp -d spaceapp_dev\"]\n interval: 2s\n timeout: 3s\n retries: 30\n temporal:\n image: ${images.temporal}\n restart: unless-stopped\n labels:\n com.spaceapp.dev-stack.owner: "${config.rootHash}"\n depends_on:\n postgres:\n condition: service_healthy\n environment:\n DB: postgres12\n DB_PORT: 5432\n POSTGRES_USER: spaceapp\n POSTGRES_SEEDS: postgres\n POSTGRES_PWD: \${SPACEAPP_DEV_POSTGRES_PASSWORD:-dev-stack-password-not-supplied}\n DYNAMIC_CONFIG_FILE_PATH: config/dynamicconfig/docker.yaml\n ports:\n - \"127.0.0.1:${config.ports.temporal}:7233\"\n healthcheck:\n test: [\"CMD-SHELL\", \"temporal operator cluster health --address temporal:7233 >/dev/null && temporal operator namespace describe --namespace default --address temporal:7233 >/dev/null\"]\n interval: 3s\n timeout: 5s\n retries: 40\nvolumes:\n postgres-data:\n labels:\n com.spaceapp.dev-stack.owner: "${config.rootHash}"\nnetworks:\n default:\n labels:\n com.spaceapp.dev-stack.owner: "${config.rootHash}"\n`; +export function renderCompose(config, images, ownership) { + if (!ownership?.token) throw new Error("Dev-stack ownership token is required to render Compose resources."); + const ownerLabel = `com.spaceapp.dev-stack.owner: ${ownership.token}`; + return `services:\n postgres:\n image: ${images.postgres}\n restart: unless-stopped\n labels:\n ${ownerLabel}\n environment:\n POSTGRES_DB: spaceapp_dev\n POSTGRES_USER: spaceapp\n POSTGRES_PASSWORD: \${SPACEAPP_DEV_POSTGRES_PASSWORD:-dev-stack-password-not-supplied}\n ports:\n - \"127.0.0.1:${config.ports.postgres}:5432\"\n volumes:\n - postgres-data:/var/lib/postgresql/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U spaceapp -d spaceapp_dev\"]\n interval: 2s\n timeout: 3s\n retries: 30\n temporal:\n image: ${images.temporal}\n restart: unless-stopped\n labels:\n ${ownerLabel}\n depends_on:\n postgres:\n condition: service_healthy\n environment:\n DB: postgres12\n DB_PORT: 5432\n POSTGRES_USER: spaceapp\n POSTGRES_SEEDS: postgres\n POSTGRES_PWD: \${SPACEAPP_DEV_POSTGRES_PASSWORD:-dev-stack-password-not-supplied}\n DYNAMIC_CONFIG_FILE_PATH: config/dynamicconfig/docker.yaml\n ports:\n - \"127.0.0.1:${config.ports.temporal}:7233\"\n healthcheck:\n test: [\"CMD-SHELL\", \"temporal operator cluster health --address temporal:7233 >/dev/null && temporal operator namespace describe --namespace default --address temporal:7233 >/dev/null\"]\n interval: 3s\n timeout: 5s\n retries: 40\nvolumes:\n postgres-data:\n labels:\n ${ownerLabel}\nnetworks:\n default:\n labels:\n ${ownerLabel}\n`; } export async function run(command, args, { capture = false, allowFailure = false, env = process.env, cwd = repoRoot } = {}) { @@ -217,7 +221,7 @@ export async function ensureTemporalNamespace(config) { export function sourceEnv(config, databaseUrl, sessionSecret, openCodeWrapper) { const env = { ...process.env }; - for (const key of ["OPENCODE_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "GROK_API_KEY", "DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GITHUB_TOKEN", "GH_TOKEN", "SPACEAPP_DEV_POSTGRES_PASSWORD"]) delete env[key]; + for (const key of ["OPENCODE_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "GROK_API_KEY", "DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GITHUB_TOKEN", "GH_TOKEN", "SPACEAPP_DEV_POSTGRES_PASSWORD", "SPACE_DEV_PROCESS_TOKEN"]) delete env[key]; if (openCodeWrapper !== join(config.binDir, "opencode-vscode-parity")) { throw new Error("OpenCode wrapper must live inside the controlled dev-stack command directory."); } @@ -288,35 +292,39 @@ export async function readSupervisorPid(config) { return Number.isInteger(value) ? value : null; } -function splitResourceIds(value) { +function splitLines(value) { return value.split(/\s+/).filter(Boolean); } -function sameResources(all, owned) { - if (all.length !== owned.length) return false; - const set = new Set(owned); - return all.every((value) => set.has(value)); +async function dockerList(runner, args, kind) { + const result = await runner("docker", args, { capture: true, allowFailure: true }); + if (result.code !== 0) throw new Error(`Docker inventory failed while listing dev-stack ${kind}; state was preserved for recovery.`); + return splitLines(result.stdout); } -export async function dockerResources(config, runner = run) { +function sameSet(left, right) { + return left.length === right.length && [...left].sort().every((value, index) => value === [...right].sort()[index]); +} + +export async function dockerResources(config, ownership = null, runner = run) { const projectFilter = `label=com.docker.compose.project=${config.project}`; - const ownerFilter = `label=com.spaceapp.dev-stack.owner=${config.rootHash}`; - const [containersResult, ownedContainersResult, volumesResult, ownedVolumesResult, networksResult, ownedNetworksResult] = await Promise.all([ - runner("docker", ["ps", "-aq", "--filter", projectFilter], { capture: true }), - runner("docker", ["ps", "-aq", "--filter", projectFilter, "--filter", ownerFilter], { capture: true }), - runner("docker", ["volume", "ls", "-q", "--filter", projectFilter], { capture: true }), - runner("docker", ["volume", "ls", "-q", "--filter", projectFilter, "--filter", ownerFilter], { capture: true }), - runner("docker", ["network", "ls", "-q", "--filter", projectFilter], { capture: true }), - runner("docker", ["network", "ls", "-q", "--filter", projectFilter, "--filter", ownerFilter], { capture: true }) - ]); - const containers = splitResourceIds(containersResult.stdout); - const ownedContainers = splitResourceIds(ownedContainersResult.stdout); - const volumes = splitResourceIds(volumesResult.stdout); - const ownedVolumes = splitResourceIds(ownedVolumesResult.stdout); - const networks = splitResourceIds(networksResult.stdout); - const ownedNetworks = splitResourceIds(ownedNetworksResult.stdout); - if (!sameResources(containers, ownedContainers) || !sameResources(volumes, ownedVolumes) || !sameResources(networks, ownedNetworks)) { - throw new Error(`Docker project ${config.project} contains resources without this checkout's ownership label; refusing destructive cleanup.`); + const all = { + containers: await dockerList(runner, ["ps", "-aq", "--filter", projectFilter], "containers"), + volumes: await dockerList(runner, ["volume", "ls", "-q", "--filter", projectFilter], "volumes"), + networks: await dockerList(runner, ["network", "ls", "-q", "--filter", projectFilter], "networks") + }; + if (!ownership) return all; + + const ownerFilter = `label=com.spaceapp.dev-stack.owner=${ownership.token}`; + const owned = { + containers: await dockerList(runner, ["ps", "-aq", "--filter", projectFilter, "--filter", ownerFilter], "owned containers"), + volumes: await dockerList(runner, ["volume", "ls", "-q", "--filter", projectFilter, "--filter", ownerFilter], "owned volumes"), + networks: await dockerList(runner, ["network", "ls", "-q", "--filter", projectFilter, "--filter", ownerFilter], "owned networks") + }; + for (const kind of ["containers", "volumes", "networks"]) { + if (!sameSet(all[kind], owned[kind])) { + throw new Error(`Refusing cleanup: Compose project ${config.project} contains ${kind} that are not proven to belong to this dev-stack state.`); + } } - return { containers: ownedContainers, volumes: ownedVolumes, networks: ownedNetworks }; + return owned; } diff --git a/deploy/docker/dev-stack-safety.mjs b/deploy/docker/dev-stack-safety.mjs index 4f5d2120a..9c03f9f42 100644 --- a/deploy/docker/dev-stack-safety.mjs +++ b/deploy/docker/dev-stack-safety.mjs @@ -1,29 +1,11 @@ +import { randomBytes } from "node:crypto"; import { existsSync } from "node:fs"; -import { lstat, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { pidAlive, run } from "./dev-stack-lib.mjs"; +import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { pidAlive, run, sleep } from "./dev-stack-lib.mjs"; const unsafeOverrideKeys = ["SPACE_DEV_STACK_DIR", "SPACE_DEV_COMPOSE_PROJECT"]; -const ownershipFileName = ".ownership.json"; -const processFileName = ".processes.json"; - -const ownershipFile = (config) => join(config.stateRoot, ownershipFileName); -const processFile = (config) => join(config.stateRoot, processFileName); - -function isZombie(identity) { - return typeof identity?.state === "string" && identity.state.startsWith("Z"); -} - -function processGroupExists(pgid) { - if (process.platform === "win32" || !Number.isInteger(pgid) || pgid <= 1) return false; - try { - process.kill(-pgid, 0); - return true; - } catch (error) { - if (error?.code === "ESRCH") return false; - throw error; - } -} +const ownershipVersion = 1; +const processRecordVersion = 1; export function assertSafeDevStackOverrides(env = process.env) { for (const key of unsafeOverrideKeys) { @@ -33,63 +15,52 @@ export function assertSafeDevStackOverrides(env = process.env) { } } -export async function readStateOwnership(config) { - const path = ownershipFile(config); - if (!existsSync(path)) return null; - let parsed; - try { - parsed = JSON.parse((await readFile(path, "utf8")).trim()); - } catch { - throw new Error("Dev-stack ownership record is corrupt; refusing destructive lifecycle operations."); - } - if (parsed?.version !== 1 || parsed.rootHash !== config.rootHash || parsed.project !== config.project || typeof parsed.createdStateRoot !== "boolean") { - throw new Error("Dev-stack ownership record does not match this checkout; refusing destructive lifecycle operations."); +function validateOwnership(config, record) { + if (!record || record.version !== ownershipVersion || record.rootHash !== config.rootHash || !/^[a-f0-9]{48}$/.test(record.token || "")) { + throw new Error("The dev-stack ownership record is missing, damaged, or belongs to another checkout; refusing destructive lifecycle operations."); } - return parsed; + return record; } -export async function assertOwnedState(config, { allowMissing = false } = {}) { - if (!existsSync(config.stateRoot)) { +export async function readStateOwnership(config, { allowMissing = false } = {}) { + if (!existsSync(config.stateRoot)) return null; + if (!existsSync(config.ownerFile)) { if (allowMissing) return null; - throw new Error("Dev-stack state directory is missing; ownership cannot be proven."); + throw new Error(`Refusing to adopt pre-existing state directory ${config.stateRoot}: it was not initialized by this dev-stack.`); } - const info = await lstat(config.stateRoot); - if (info.isSymbolicLink() || !info.isDirectory()) { - throw new Error("Dev-stack state path is not an owned directory; refusing lifecycle operations."); + let parsed; + try { + parsed = JSON.parse(await readFile(config.ownerFile, "utf8")); + } catch { + throw new Error("The dev-stack ownership record is unreadable; refusing cleanup so recovery material is preserved."); } - const record = await readStateOwnership(config); - if (!record) throw new Error("Existing .dev-stack directory is not owned by dev:stack; refusing to modify or remove it."); - return record; + return validateOwnership(config, parsed); } export async function claimStateOwnership(config) { - const existed = existsSync(config.stateRoot); - if (existed) { - const info = await lstat(config.stateRoot); - if (info.isSymbolicLink() || !info.isDirectory()) throw new Error("Existing .dev-stack path is not a safe directory."); - const existing = await readStateOwnership(config); - if (existing) return existing; - const entries = await readdir(config.stateRoot); - if (entries.length) { - throw new Error("Existing .dev-stack directory was not initialized by dev:stack and is not empty; refusing to take ownership."); - } - } else { - await mkdir(config.stateRoot, { recursive: false, mode: 0o700 }); - } + if (existsSync(config.stateRoot)) return readStateOwnership(config); - const record = { - version: 1, - rootHash: config.rootHash, - project: config.project, - createdStateRoot: !existed - }; + let created = false; try { - await writeFile(ownershipFile(config), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: "wx" }); + await mkdir(config.stateRoot, { mode: 0o700 }); + created = true; + const record = { + version: ownershipVersion, + rootHash: config.rootHash, + token: randomBytes(24).toString("hex") + }; + await writeFile(config.ownerFile, `${JSON.stringify(record)}\n`, { mode: 0o600, flag: "wx" }); + await chmod(config.ownerFile, 0o600); + return record; } catch (error) { - if (error?.code !== "EEXIST") throw error; - return assertOwnedState(config); + if (created) await rm(config.stateRoot, { recursive: true, force: true }).catch(() => {}); + if (error?.code === "EEXIST") return readStateOwnership(config); + throw error; } - return record; +} + +function isZombie(identity) { + return typeof identity?.state === "string" && identity.state.startsWith("Z"); } async function psField(pid, field) { @@ -100,15 +71,13 @@ async function psField(pid, field) { export async function processIdentity(pid) { if (!pidAlive(pid)) return null; - const [startedAt, args, state, pgidRaw] = await Promise.all([ + const [startedAt, args, state] = await Promise.all([ psField(pid, "lstart"), psField(pid, "args"), - psField(pid, "stat"), - psField(pid, "pgid") + psField(pid, "stat") ]); - const pgid = Number.parseInt(pgidRaw || "", 10); - if (!startedAt || !args || !state || !Number.isInteger(pgid)) return null; - return { startedAt, args, state, pgid }; + if (!startedAt || !args || !state) return null; + return { startedAt, args, state }; } export function supervisorRecordMatches(config, record, identity) { @@ -192,73 +161,117 @@ export async function releaseSupervisor(config, pid = process.pid) { if (record?.pid === pid) await rm(config.pidFile, { force: true }); } -export function managedProcessRecordMatches(config, record, identity) { - if (!record || !identity) return false; - if (!Number.isInteger(record.pid) || record.pid <= 1 || !Number.isInteger(record.pgid) || record.pgid <= 1) return false; - return record.rootHash === config.rootHash - && record.pid === record.pgid - && identity.pgid === record.pgid - && record.startedAt === identity.startedAt - && record.args === identity.args; +function validateProcessRecord(record) { + if (!record || typeof record.name !== "string" || !Number.isInteger(record.pid) || record.pid <= 1 || record.pgid !== record.pid || record.sid !== record.pid) { + throw new Error("Invalid persisted dev-stack process-group record; refusing to signal it."); + } + return record; } -export async function readManagedProcessRecords(config) { - const path = processFile(config); - if (!existsSync(path)) return []; +export async function writeProcessRecords(config, ownership, records) { + validateOwnership(config, ownership); + const normalized = records.map(validateProcessRecord); + const payload = { + version: processRecordVersion, + rootHash: config.rootHash, + token: ownership.token, + groups: normalized + }; + const temporary = `${config.processFile}.${process.pid}.tmp`; + await writeFile(temporary, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 }); + await chmod(temporary, 0o600); + await rename(temporary, config.processFile); +} + +export async function readProcessRecords(config, ownership) { + validateOwnership(config, ownership); + if (!existsSync(config.processFile)) return []; let parsed; try { - parsed = JSON.parse((await readFile(path, "utf8")).trim()); + parsed = JSON.parse(await readFile(config.processFile, "utf8")); } catch { - throw new Error("Dev-stack managed-process record is corrupt; refusing to signal recorded PIDs."); + throw new Error("The dev-stack process ownership record is unreadable; refusing to signal any process."); } - if (parsed?.version !== 1 || parsed.rootHash !== config.rootHash || !Array.isArray(parsed.processes)) { - throw new Error("Dev-stack managed-process record does not belong to this checkout."); + if (parsed?.version !== processRecordVersion || parsed.rootHash !== config.rootHash || parsed.token !== ownership.token || !Array.isArray(parsed.groups)) { + throw new Error("The dev-stack process ownership record does not match this state; refusing to signal any process."); } - return parsed.processes; + return parsed.groups.map(validateProcessRecord); +} + +export async function clearProcessRecords(config) { + await rm(config.processFile, { force: true }); } -export async function writeManagedProcessRecords(config, processes) { - const path = processFile(config); - if (!processes.length) { - await rm(path, { force: true }); - return; +async function listGroupMembers(record, runner = run) { + const result = await runner("ps", ["-axo", "pid=,pgid=,sid=,stat="], { capture: true, allowFailure: true }); + if (result.code !== 0) throw new Error("Could not inventory local processes; refusing to signal a dev-stack process group."); + const members = []; + for (const line of result.stdout.split(/\r?\n/)) { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)/); + if (!match) continue; + const pid = Number(match[1]); + const pgid = Number(match[2]); + const sid = Number(match[3]); + const stat = match[4]; + if (pgid === record.pgid && sid === record.sid && !stat.startsWith("Z")) members.push({ pid, stat }); } - await writeFile(path, `${JSON.stringify({ version: 1, rootHash: config.rootHash, processes }, null, 2)}\n`, { mode: 0o600 }); + return members; } -export async function ownedManagedProcessRecords(config, { strict = true, identityProvider = processIdentity } = {}) { - const records = await readManagedProcessRecords(config); - const owned = []; - let changed = false; - for (const record of records) { - if (!Number.isInteger(record.pid) || record.pid <= 1 || record.pid !== record.pgid || record.rootHash !== config.rootHash) { - if (strict) throw new Error("Refusing to use an invalid managed-process ownership record."); - continue; +async function processHasToken(pid, token, runner = run) { + const result = await runner("ps", ["eww", "-p", String(pid), "-o", "command="], { capture: true, allowFailure: true }); + if (result.code !== 0 || !result.stdout) return false; + return result.stdout.includes(`SPACE_DEV_PROCESS_TOKEN=${token}`); +} + +async function verifiedGroupMembers(record, ownership, runner = run) { + const members = await listGroupMembers(record, runner); + for (const member of members) { + if (!await processHasToken(member.pid, ownership.token, runner)) { + throw new Error(`Refusing to signal process group ${record.pgid}: PID ${member.pid} does not carry this dev-stack ownership token.`); } + } + return members; +} - if (!pidAlive(record.pid)) { - if (processGroupExists(record.pgid)) { - // A POSIX process group cannot be replaced by a new group with the same PGID while - // descendants of the recorded group still exist. With the original leader PID absent, - // this is the only safe case where we may recover the recorded descendants by PGID. - owned.push({ ...record, leaderExited: true }); - } else { - changed = true; - } - continue; +async function signalVerifiedGroup(record, ownership, signal, runner, signaler) { + const members = await verifiedGroupMembers(record, ownership, runner); + if (!members.length) return false; + try { + if (process.platform === "win32") { + for (const member of members) signaler(member.pid, signal); + } else { + signaler(-record.pgid, signal); } + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } + return true; +} - const identity = await identityProvider(record.pid); - if (!identity) { - if (strict) throw new Error(`Refusing to signal PID ${record.pid}: process identity could not be established.`); - continue; +export async function terminateOwnedProcessGroups(config, ownership, { + records = null, + timeoutMs = 7000, + runner = run, + signaler = process.kill +} = {}) { + validateOwnership(config, ownership); + const groups = (records ?? await readProcessRecords(config, ownership)).map(validateProcessRecord); + for (const record of groups) { + if (!await signalVerifiedGroup(record, ownership, "SIGTERM", runner, signaler)) continue; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!(await verifiedGroupMembers(record, ownership, runner)).length) break; + await sleep(100); } - if (!managedProcessRecordMatches(config, record, identity)) { - if (strict) throw new Error(`Refusing to signal PID ${record.pid}: managed-process identity no longer matches the recorded dev-stack service.`); - continue; + if ((await verifiedGroupMembers(record, ownership, runner)).length) { + await signalVerifiedGroup(record, ownership, "SIGKILL", runner, signaler); + const killDeadline = Date.now() + 2000; + while (Date.now() < killDeadline && (await verifiedGroupMembers(record, ownership, runner)).length) await sleep(50); + if ((await verifiedGroupMembers(record, ownership, runner)).length) { + throw new Error(`Owned process group ${record.pgid} did not terminate; process ownership state was preserved for retry.`); + } } - owned.push(record); } - if (changed) await writeManagedProcessRecords(config, owned); - return owned; + if (records == null) await clearProcessRecords(config); } diff --git a/deploy/docker/dev-stack.mjs b/deploy/docker/dev-stack.mjs old mode 100644 new mode 100755 index 9c572b516..17e662381 --- a/deploy/docker/dev-stack.mjs +++ b/deploy/docker/dev-stack.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { existsSync } from "node:fs"; -import { chmod, mkdir, readFile, readdir, rm, rmdir, stat, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, readdir, rename, rm, rmdir, stat, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; @@ -12,10 +12,8 @@ import { installOpenCode, parseOpenCodeVersion, parsePinnedImages, - pidAlive, prepareState, renderCompose, - repoRoot, resolveConfig, run, sleep, @@ -24,20 +22,18 @@ import { waitReady } from "./dev-stack-lib.mjs"; import { - assertOwnedState, assertSafeDevStackOverrides, claimStateOwnership, claimSupervisor, - ownedManagedProcessRecords, + clearProcessRecords, ownedSupervisorPid, - processIdentity, + readStateOwnership, releaseSupervisor, - writeManagedProcessRecords + terminateOwnedProcessGroups, + writeProcessRecords } from "./dev-stack-safety.mjs"; const scriptPath = fileURLToPath(import.meta.url); -const ownershipFileName = ".ownership.json"; -const processFileName = ".processes.json"; function prefixStream(stream, label, target) { let pending = ""; @@ -50,99 +46,21 @@ function prefixStream(stream, label, target) { stream.on("end", () => { if (pending) target.write(`[${label}] ${pending}\n`); }); } -function processGroupAlive(pgid) { - if (!Number.isInteger(pgid) || pgid <= 1) return false; - try { - process.kill(process.platform === "win32" ? pgid : -pgid, 0); - return true; - } catch (error) { - if (error?.code === "ESRCH") return false; - throw error; - } -} - -export function terminateTree(child, signal = "SIGTERM") { - if (!child) return; - try { - if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal); - else if (child.exitCode == null) child.kill(signal); - } catch (error) { - if (error?.code !== "ESRCH") throw error; - } -} - -export async function waitDead(child, timeoutMs = 7000) { - if (!child?.pid) return; - const alive = () => process.platform === "win32" ? child.exitCode == null : processGroupAlive(child.pid); - let deadline = Date.now() + timeoutMs; - while (Date.now() < deadline && alive()) await sleep(100); - if (!alive()) return; - terminateTree(child, "SIGKILL"); - deadline = Date.now() + 2000; - while (Date.now() < deadline && alive()) await sleep(100); - if (alive()) throw new Error(`Managed process group ${child.pid} did not stop cleanly.`); -} - -async function waitRecordedDead(record, timeoutMs = 7000) { - const alive = () => processGroupAlive(record.pgid); - let deadline = Date.now() + timeoutMs; - while (Date.now() < deadline && alive()) await sleep(100); - if (!alive()) return; - try { - process.kill(process.platform === "win32" ? record.pid : -record.pgid, "SIGKILL"); - } catch (error) { - if (error?.code !== "ESRCH") throw error; - } - deadline = Date.now() + 2000; - while (Date.now() < deadline && alive()) await sleep(100); - if (alive()) throw new Error(`Recorded dev-stack process group ${record.pgid} did not stop cleanly.`); -} - -function spawnService(config, name, command, args, env, onUnexpectedExit) { +function spawnService(config, name, command, args, env, ownership, onUnexpectedExit) { const child = spawn(command, args, { cwd: config.root, - env, + env: { ...env, SPACE_DEV_PROCESS_TOKEN: ownership.token }, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] }); + if (!Number.isInteger(child.pid) || child.pid <= 1) throw new Error(`Could not establish a process-group leader for ${name}.`); prefixStream(child.stdout, name, process.stdout); prefixStream(child.stderr, name, process.stderr); child.once("exit", (code, signal) => onUnexpectedExit(name, child, code, signal)); - return child; -} - -async function identifyManagedChild(config, name, child) { - const deadline = Date.now() + 3000; - while (Date.now() < deadline) { - const identity = await processIdentity(child.pid); - if (identity && identity.pgid === child.pid) { - return { - name, - pid: child.pid, - pgid: child.pid, - rootHash: config.rootHash, - startedAt: identity.startedAt, - args: identity.args - }; - } - if (child.exitCode != null) break; - await sleep(25); - } - throw new Error(`Could not establish managed process identity for ${name}.`); -} - -export async function stopRecordedProcesses(config) { - const records = await ownedManagedProcessRecords(config); - if (!records.length) return; - for (const record of records) { - try { - process.kill(process.platform === "win32" ? record.pid : -record.pgid, "SIGTERM"); - } catch (error) { - if (error?.code !== "ESRCH") throw error; - } - } - await Promise.all(records.map((record) => waitRecordedDead(record))); - await writeManagedProcessRecords(config, []); + return { + child, + record: { name, pid: child.pid, pgid: child.pid, sid: child.pid } + }; } export async function ensureDarwinNodePtySpawnHelpers(root, platform = process.platform) { @@ -165,10 +83,13 @@ export async function ensureDarwinNodePtySpawnHelpers(root, platform = process.p return repaired; } -async function waitSupervisorExit(pid, timeoutMs = 15_000) { +async function waitSupervisorExit(config, pid, timeoutMs = 15_000) { const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline && pidAlive(pid)) await sleep(100); - if (pidAlive(pid)) throw new Error(`Dev-stack supervisor ${pid} did not stop cleanly.`); + while (Date.now() < deadline) { + if (await ownedSupervisorPid(config, { strict: false }) !== pid) return; + await sleep(100); + } + throw new Error(`Dev-stack supervisor ${pid} did not stop cleanly.`); } async function waitServiceHealthyWithDiagnostics(config, service, timeoutMs) { @@ -194,109 +115,100 @@ async function waitServiceHealthyWithDiagnostics(config, service, timeoutMs) { throw new Error(`${service} did not become healthy within ${Math.round(timeoutMs / 1000)} seconds; last status=${lastStatus}.`); } -async function cleanupDocker(config, { volumes = false } = {}) { +async function ownershipForCleanup(config) { + if (existsSync(config.stateRoot)) return readStateOwnership(config); const resources = await dockerResources(config); - const hasRuntimeResources = resources.containers.length || resources.networks.length; - const hasVolumeResources = volumes && resources.volumes.length; - if (!hasRuntimeResources && !hasVolumeResources) return resources; - - if (existsSync(config.composeFile)) { - const args = ["down"]; - if (volumes) args.push("-v"); - args.push("--remove-orphans"); - await compose(config, args); - } else { - if (resources.containers.length) await run("docker", ["rm", "-f", ...resources.containers]); - if (resources.networks.length) await run("docker", ["network", "rm", ...resources.networks]); - if (volumes && resources.volumes.length) await run("docker", ["volume", "rm", ...resources.volumes]); + if (resources.containers.length || resources.volumes.length || resources.networks.length) { + throw new Error(`Dev-stack Docker resources exist for ${config.project}, but the state ownership record is missing; refusing cleanup because ownership cannot be proven.`); } + return null; +} - const remaining = await dockerResources(config); - if (remaining.containers.length || remaining.networks.length || (volumes && remaining.volumes.length)) { - throw new Error(`Docker cleanup left owned resources behind; state preserved for retry: ${JSON.stringify(remaining)}`); +async function stopDockerResources(config, ownership, { removeVolumes = false } = {}) { + const before = await dockerResources(config, ownership); + const hasRuntimeResources = before.containers.length || before.networks.length || (removeVolumes && before.volumes.length); + if (hasRuntimeResources) { + if (existsSync(config.composeFile)) { + const args = ["down"]; + if (removeVolumes) args.push("-v"); + args.push("--remove-orphans"); + await compose(config, args); + } else { + if (before.containers.length) await run("docker", ["rm", "-f", ...before.containers]); + if (before.networks.length) await run("docker", ["network", "rm", ...before.networks]); + if (removeVolumes && before.volumes.length) await run("docker", ["volume", "rm", ...before.volumes]); + } + } + + const after = await dockerResources(config, ownership); + if (after.containers.length || after.networks.length || (removeVolumes && after.volumes.length)) { + throw new Error(`Cleanup left owned Docker resources behind; state preserved for retry: ${JSON.stringify(after)}`); } - return remaining; + return after; } async function removeOwnedState(config, ownership) { if (!existsSync(config.stateRoot)) return; - const knownEntries = new Set([ - ownershipFileName, - processFileName, - "compose.yml", - "config.json", - "supervisor.pid", - "build-marker.json", - "secrets", - "bin", - "vendor", - "providers", - "workspaces", - "memory", - "memory-graph", - "artifacts", - "projects", - "diagnostics", - "opencode-chat", - "run" - ]); - const entries = await readdir(config.stateRoot); - const unknownEntries = entries.filter((entry) => !knownEntries.has(entry)); - for (const entry of entries) { - if (entry === ownershipFileName || unknownEntries.includes(entry)) continue; - await rm(join(config.stateRoot, entry), { recursive: true, force: true }); - } - await rm(join(config.stateRoot, ownershipFileName), { force: false }); - if (ownership.createdStateRoot && unknownEntries.length === 0) { - await rmdir(config.stateRoot); - } - if (unknownEntries.length) { - console.warn(`[dev:stack] Preserved unrecognized entries in ${config.stateRoot}: ${unknownEntries.join(", ")}`); + const current = await readStateOwnership(config); + if (current.token !== ownership.token) throw new Error("Dev-stack state ownership changed during reset; refusing deletion."); + const quarantine = `${config.stateRoot}.reset-${ownership.token.slice(0, 12)}`; + if (existsSync(quarantine)) throw new Error(`Cannot reset while recovery directory already exists: ${quarantine}`); + await rename(config.stateRoot, quarantine); + try { + await rm(quarantine, { recursive: true, force: true }); + } catch (error) { + try { await rename(quarantine, config.stateRoot); } + catch { + throw new Error(`State deletion failed and recovery data remains at ${quarantine}: ${error instanceof Error ? error.message : String(error)}`); + } + throw new Error(`State deletion failed; ownership data was restored for retry: ${error instanceof Error ? error.message : String(error)}`); } } export async function down(config) { - const ownership = await assertOwnedState(config, { allowMissing: true }); - if (ownership) { - const pid = await ownedSupervisorPid(config); - if (pid && pid !== process.pid) { - process.kill(pid, "SIGTERM"); - await waitSupervisorExit(pid); - } - await stopRecordedProcesses(config); + const ownership = await ownershipForCleanup(config); + if (!ownership) return; + const pid = await ownedSupervisorPid(config); + if (pid && pid !== process.pid) { + process.kill(pid, "SIGTERM"); + await waitSupervisorExit(config, pid); } - await cleanupDocker(config, { volumes: false }); + await terminateOwnedProcessGroups(config, ownership); + await stopDockerResources(config, ownership); } export async function reset(config) { - const ownership = await assertOwnedState(config, { allowMissing: true }); - await down(config); - await cleanupDocker(config, { volumes: true }); - const remaining = await dockerResources(config); - if (remaining.containers.length || remaining.volumes.length || remaining.networks.length) { - throw new Error(`Reset left owned Docker resources behind; state preserved for retry: ${JSON.stringify(remaining)}`); + const ownership = await ownershipForCleanup(config); + if (!ownership) { + console.log("[dev:stack] Reset complete; no owned state or Docker resources exist."); + return; } - - if (ownership) { - const tmpSocketRoot = dirname(config.socketPath); - if (!tmpSocketRoot.startsWith(config.stateRoot) && tmpSocketRoot.includes(`spaceapp-dev-${config.rootHash}`)) { - await rm(tmpSocketRoot, { recursive: true, force: true }); - } - await assertOwnedState(config); - await removeOwnedState(config, ownership); + await down(config); + await terminateOwnedProcessGroups(config, ownership); + await stopDockerResources(config, ownership, { removeVolumes: true }); + await removeOwnedState(config, ownership); + const tmpSocketRoot = dirname(config.socketPath); + if (!tmpSocketRoot.startsWith(config.stateRoot) && tmpSocketRoot.includes(`spaceapp-dev-${config.rootHash}`)) { + await rm(config.socketPath, { force: true }).catch(() => {}); + await rmdir(tmpSocketRoot).catch((error) => { + if (error?.code !== "ENOENT" && error?.code !== "ENOTEMPTY") throw error; + }); } - console.log("[dev:stack] Reset complete; owned containers, volumes and state were removed."); + console.log("[dev:stack] Reset complete; owned containers, volumes, processes and state were removed."); } export async function status(config) { - const ownership = await assertOwnedState(config, { allowMissing: true }); + const stateExists = existsSync(config.stateRoot); + const ownership = stateExists ? await readStateOwnership(config, { allowMissing: true }) : null; const pid = ownership ? await ownedSupervisorPid(config, { strict: false }) : null; - const resources = await dockerResources(config); + const resources = await dockerResources(config, ownership); + const noDockerResources = !resources.containers.length && !resources.volumes.length && !resources.networks.length; const payload = { running: Boolean(pid), supervisorPid: pid, project: config.project, stateRoot: config.stateRoot, + ownershipVerified: Boolean(ownership) || (!stateExists && noDockerResources), ports: config.ports, docker: resources }; @@ -305,8 +217,8 @@ export async function status(config) { } export async function rebuild(config) { - await assertOwnedState(config); - const pid = await ownedSupervisorPid(config); + const ownership = existsSync(config.stateRoot) ? await readStateOwnership(config) : null; + const pid = ownership ? await ownedSupervisorPid(config) : null; if (!pid) { await ensureBuild(config, true); console.log("[dev:stack] Rebuilt source workspaces; no running supervisor to restart."); @@ -317,81 +229,97 @@ export async function rebuild(config) { } export async function start(config) { - const composeTemplate = await readFile(resolve(config.root, "packages/run-spaceapp/templates/compose.yml"), "utf8"); - const dockerfile = await readFile(resolve(config.root, "Dockerfile"), "utf8"); - const images = parsePinnedImages(composeTemplate); - const openCodeVersion = parseOpenCodeVersion(dockerfile); - await run("docker", ["compose", "version"]); - await claimStateOwnership(config); + const ownership = await claimStateOwnership(config); await claimSupervisor(config); let children = new Map(); - let processRecords = new Map(); let stopping = false; + let shutdownPromise = null; let reloadChain = Promise.resolve(); - let databaseUrl; - let sessionSecret; - let postgresPassword; + let processRecordWrite = Promise.resolve(); + + const persistChildren = async () => { + const records = [...children.values()].map((entry) => entry.record); + processRecordWrite = processRecordWrite.then(() => writeProcessRecords(config, ownership, records)); + await processRecordWrite; + }; const stopChildren = async () => { - const current = [...children.values()]; + const snapshot = [...children.values()]; children = new Map(); - for (const child of current) terminateTree(child); - await Promise.all(current.map((child) => waitDead(child))); - processRecords = new Map(); - await writeManagedProcessRecords(config, []); + const records = snapshot.map((entry) => entry.record); + await processRecordWrite; + if (records.length) { + await terminateOwnedProcessGroups(config, ownership, { records }); + await clearProcessRecords(config); + } try { await rm(config.socketPath, { force: true }); } catch {} }; - const shutdown = async (reason) => { - if (stopping) return; + const shutdown = (reason) => { + if (shutdownPromise) return shutdownPromise; stopping = true; - console.log(`[dev:stack] Stopping (${reason})...`); - try { - await stopChildren(); - if (existsSync(config.composeFile)) await compose(config, ["down", "--remove-orphans"], { allowFailure: true }); - } finally { - await releaseSupervisor(config); - } + shutdownPromise = (async () => { + console.log(`[dev:stack] Stopping (${reason})...`); + let failure = null; + try { await stopChildren(); } + catch (error) { failure = error; } + try { await stopDockerResources(config, ownership); } + catch (error) { failure ??= error; } + try { await releaseSupervisor(config); } + catch (error) { failure ??= error; } + if (failure) throw failure; + })(); + return shutdownPromise; + }; + + const finishShutdown = (reason, preferredCode = 0) => { + void shutdown(reason).then( + () => process.exit(process.exitCode || preferredCode), + (error) => { + process.stderr.write(`[dev:stack] cleanup failed: ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + ); }; const unexpectedExit = (name, child, code, signal) => { - if (stopping || child !== children.get(name)) return; + if (stopping || child !== children.get(name)?.child) return; console.error(`[dev:stack] ${name} exited unexpectedly (${code ?? signal}); stopping the stack.`); process.exitCode = 1; - void shutdown("child-failure").finally(() => process.exit(1)); + finishShutdown("child-failure", 1); }; - const spawnManaged = async (name, command, args, env) => { - const child = spawnService(config, name, command, args, env, unexpectedExit); - children.set(name, child); - const record = await identifyManagedChild(config, name, child); - processRecords.set(name, record); - await writeManagedProcessRecords(config, [...processRecords.values()]); + const addChild = async (name, command, args, env) => { + if (stopping) throw new Error(`Refusing to start ${name} while the dev stack is stopping.`); + const entry = spawnService(config, name, command, args, env, ownership, unexpectedExit); + children.set(name, entry); + await persistChildren(); }; - const spawnChildren = async (forceBuild = false) => { + const spawnChildren = async (forceBuild = false, state) => { await ensureBuild(config, forceBuild); const repairedHelpers = await ensureDarwinNodePtySpawnHelpers(config.root); if (repairedHelpers.length) { console.log(`[dev:stack] Repaired execute permission on ${repairedHelpers.length} node-pty macOS spawn helper(s).`); } - const openCodeWrapper = await installOpenCode(config, openCodeVersion); - const env = sourceEnv(config, databaseUrl, sessionSecret, openCodeWrapper); - await spawnManaged("cli-host", process.execPath, ["packages/cli-host/dist/main.js"], env); - await spawnManaged("worker", process.execPath, ["apps/worker/dist/worker.js"], env); - await spawnManaged("api", "npm", ["exec", "--", "tsx", "watch", "apps/api/src/server.ts"], env); - await spawnManaged("web", "npm", ["run", "dev", "-w", "@space/web", "--", "--host", "127.0.0.1", "--port", String(config.ports.web)], env); + const openCodeWrapper = await installOpenCode(config, state.openCodeVersion); + const env = sourceEnv(config, state.databaseUrl, state.sessionSecret, openCodeWrapper); + await addChild("cli-host", process.execPath, ["packages/cli-host/dist/main.js"], env); + await addChild("worker", process.execPath, ["apps/worker/dist/worker.js"], env); + await addChild("api", "npm", ["exec", "--", "tsx", "watch", "apps/api/src/server.ts"], env); + await addChild("web", "npm", ["run", "dev", "-w", "@space/web", "--", "--host", "127.0.0.1", "--port", String(config.ports.web)], env); }; + let runtimeState = null; const reload = () => { reloadChain = reloadChain.then(async () => { if (stopping) return; console.log("[dev:stack] Rebuilding workspaces and restarting source services..."); await stopChildren(); if (stopping) return; - await spawnChildren(true); + await spawnChildren(true, runtimeState); if (stopping) { await stopChildren(); return; @@ -401,19 +329,27 @@ export async function start(config) { }).catch((error) => { console.error(`[dev:stack] rebuild failed: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; - void shutdown("rebuild-failure").finally(() => process.exit(1)); + finishShutdown("rebuild-failure", 1); }); }; - process.once("SIGINT", () => { void shutdown("SIGINT").finally(() => process.exit(process.exitCode || 0)); }); - process.once("SIGTERM", () => { void shutdown("SIGTERM").finally(() => process.exit(process.exitCode || 0)); }); + process.once("SIGINT", () => finishShutdown("SIGINT")); + process.once("SIGTERM", () => finishShutdown("SIGTERM")); process.on("SIGHUP", reload); try { - await stopRecordedProcesses(config); - ({ databaseUrl, sessionSecret, postgresPassword } = await prepareState(config)); + await terminateOwnedProcessGroups(config, ownership); + await dockerResources(config, ownership); + + const composeTemplate = await readFile(resolve(config.root, "packages/run-spaceapp/templates/compose.yml"), "utf8"); + const dockerfile = await readFile(resolve(config.root, "Dockerfile"), "utf8"); + const images = parsePinnedImages(composeTemplate); + const openCodeVersion = parseOpenCodeVersion(dockerfile); + const { databaseUrl, sessionSecret, postgresPassword } = await prepareState(config); + runtimeState = { databaseUrl, sessionSecret, openCodeVersion }; + await mkdir(dirname(config.composeFile), { recursive: true, mode: 0o700 }); - await writeFile(config.composeFile, renderCompose(config, images), { mode: 0o600 }); + await writeFile(config.composeFile, renderCompose(config, images, ownership), { mode: 0o600 }); await writeFile(config.configFile, `${JSON.stringify({ root: config.root, stateRoot: config.stateRoot, @@ -435,7 +371,7 @@ export async function start(config) { await ensureBuild(config); console.log("[dev:stack] Applying database migrations..."); await run("npm", ["run", "db:migrate"], { cwd: config.root, env: { ...process.env, SPACE_DATABASE_URL: databaseUrl } }); - await spawnChildren(false); + await spawnChildren(false, runtimeState); await waitReady(config); const setupToken = (await readFile(resolve(config.secretsDir, "setup-token"), "utf8")).trim(); console.log("\n============================================================"); @@ -449,7 +385,10 @@ export async function start(config) { await new Promise(() => {}); } catch (error) { process.exitCode = 1; - await shutdown("startup-failure"); + try { await shutdown("startup-failure"); } + catch (cleanupError) { + process.stderr.write(`[dev:stack] cleanup failed after startup error: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`); + } throw error; } } diff --git a/deploy/docker/dev-stack.test.mjs b/deploy/docker/dev-stack.test.mjs index 19d82d1dd..6894d67e8 100644 --- a/deploy/docker/dev-stack.test.mjs +++ b/deploy/docker/dev-stack.test.mjs @@ -1,8 +1,7 @@ import assert from "node:assert/strict"; import { execFile, spawn } from "node:child_process"; -import { once } from "node:events"; import { existsSync } from "node:fs"; -import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -13,25 +12,27 @@ import { installOpenCode, parseOpenCodeVersion, parsePinnedImages, - pidAlive, renderCompose, resolveConfig, + run, + sleep, sourceEnv } from "./dev-stack-lib.mjs"; -import { ensureDarwinNodePtySpawnHelpers, reset, stopRecordedProcesses, terminateTree, waitDead } from "./dev-stack.mjs"; +import { ensureDarwinNodePtySpawnHelpers, reset } from "./dev-stack.mjs"; import { - assertOwnedState, assertSafeDevStackOverrides, claimStateOwnership, claimSupervisor, - managedProcessRecordMatches, ownedSupervisorPid, - processIdentity, + readStateOwnership, releaseSupervisor, supervisorRecordMatches, - writeManagedProcessRecords + terminateOwnedProcessGroups, + writeProcessRecords } from "./dev-stack-safety.mjs"; +const ownership = (rootHash) => ({ version: 1, rootHash, token: "a".repeat(48) }); + test("source stack derives third-party pins from canonical files", () => { const images = parsePinnedImages(` services: @@ -47,7 +48,7 @@ services: assert.throws(() => parseOpenCodeVersion("RUN npm install opencode-ai@latest"), /pinned/); }); -test("OpenCode wrapper isolates every writable path and preserves literal checkout paths", async () => { +test("OpenCode wrapper isolates writable state and preserves literal checkout paths", async () => { const root = await mkdtemp(join(tmpdir(), "spaceapp-opencode-$literal-'quote-")); try { const config = await resolveConfig({}, root); @@ -60,7 +61,7 @@ test("OpenCode wrapper isolates every writable path and preserves literal checko `#!${process.execPath}\nconsole.log(JSON.stringify({paths:Object.fromEntries(${JSON.stringify(keys)}.map(k=>[k,process.env[k]])),key:process.env.OPENCODE_API_KEY,args:process.argv.slice(2)}));\n`, { mode: 0o700 }); const wrapper = await installOpenCode(config, "1.18.4"); const { stdout } = await promisify(execFile)(wrapper, ["argument with spaces", "$literal"], { - env: { ...process.env, ...Object.fromEntries(keys.map(k => [k, "/proc/spaceapp-unwritable"])), OPENCODE_API_KEY: "parent-key" } + env: { ...process.env, ...Object.fromEntries(keys.map((key) => [key, "/proc/spaceapp-unwritable"])), OPENCODE_API_KEY: "parent-key" } }); const result = JSON.parse(stdout); const dirs = ["home", "config", "data", "cache", "state", "tmp", "tmp", "tmp"]; @@ -75,15 +76,14 @@ test("OpenCode wrapper isolates every writable path and preserves literal checko } }); -test("reset removes the owned state root after an empty Docker inventory", async () => { +test("reset removes only the state directory it initialized when Docker inventory is empty", async () => { const root = await mkdtemp(join(tmpdir(), "spaceapp-reset-empty-")); const oldPath = process.env.PATH; try { const bin = join(root, "fake-bin"); await mkdir(bin); - // Docker behavior is deliberately stubbed: this regression covers directory removal only. await writeFile(join(bin, "docker"), "#!/bin/sh\nexit 0\n", { mode: 0o700 }); - process.env.PATH = `${bin}:${oldPath}`; + process.env.PATH = `${bin}:${oldPath || ""}`; const config = await resolveConfig({}, root); await claimStateOwnership(config); await reset(config); @@ -105,7 +105,7 @@ test("defaults isolate installed SpaceApp and bind Docker ports to loopback", as const compose = renderCompose(config, { postgres: `pgvector/pgvector:0.8.3@sha256:${"c".repeat(64)}`, temporal: `temporalio/auto-setup:1.29.1@sha256:${"d".repeat(64)}` - }); + }, ownership(config.rootHash)); for (const port of [config.ports.postgres, config.ports.temporal]) assert.match(compose, new RegExp(`127\\.0\\.0\\.1:${port}:`)); assert.match(compose, /POSTGRES_PASSWORD: \$\{SPACEAPP_DEV_POSTGRES_PASSWORD/); assert.match(compose, /POSTGRES_PWD: \$\{SPACEAPP_DEV_POSTGRES_PASSWORD/); @@ -116,7 +116,9 @@ test("defaults isolate installed SpaceApp and bind Docker ports to loopback", as const wrapper = join(config.binDir, "opencode-vscode-parity"); const oldComposePassword = process.env.SPACEAPP_DEV_POSTGRES_PASSWORD; + const oldProcessToken = process.env.SPACE_DEV_PROCESS_TOKEN; process.env.SPACEAPP_DEV_POSTGRES_PASSWORD = "compose-only-secret"; + process.env.SPACE_DEV_PROCESS_TOKEN = "caller-supplied-token"; try { const env = sourceEnv(config, "postgresql://spaceapp:test@127.0.0.1:55432/spaceapp_dev", "session-secret-with-enough-length", wrapper); assert.equal(env.SPACE_CLI_COMMAND_PATH, config.binDir); @@ -125,10 +127,13 @@ test("defaults isolate installed SpaceApp and bind Docker ports to loopback", as assert.equal(env.SPACE_API_HOST, "127.0.0.1"); assert.equal(env.SPACE_WEB_HOST, "127.0.0.1"); assert.equal(env.SPACEAPP_DEV_POSTGRES_PASSWORD, undefined); + assert.equal(env.SPACE_DEV_PROCESS_TOKEN, undefined); assert.throws(() => sourceEnv(config, "postgresql://x", "session-secret-with-enough-length", "/tmp/opencode-vscode-parity"), /controlled dev-stack command directory/); } finally { if (oldComposePassword === undefined) delete process.env.SPACEAPP_DEV_POSTGRES_PASSWORD; else process.env.SPACEAPP_DEV_POSTGRES_PASSWORD = oldComposePassword; + if (oldProcessToken === undefined) delete process.env.SPACE_DEV_PROCESS_TOKEN; + else process.env.SPACE_DEV_PROCESS_TOKEN = oldProcessToken; } } finally { await rm(root, { recursive: true, force: true }); @@ -139,6 +144,11 @@ test("unsafe state roots and installed-instance ports fail closed", async () => const root = await mkdtemp(join(tmpdir(), "spaceapp-dev-root-")); try { await assert.rejects(() => assertSafeStateRoot(root, root), /Unsafe dev-stack state directory/); + const target = join(root, "real-state"); + const link = join(root, ".dev-stack-link"); + await mkdir(target); + await symlink(target, link); + await assert.rejects(() => assertSafeStateRoot(link, join(root, "checkout")), /may not be a symlink/); await assert.rejects(() => resolveConfig({ SPACE_DEV_API_PORT: "4910" }, root), /reserved for an installed SpaceApp/); await assert.rejects(() => resolveConfig({ SPACE_DEV_API_PORT: "6000", SPACE_DEV_WEB_PORT: "6000" }, root), /conflicts/); } finally { @@ -146,47 +156,62 @@ test("unsafe state roots and installed-instance ports fail closed", async () => } }); -test("state ownership never adopts a non-empty pre-existing directory", async () => { +test("destructive ownership selectors cannot be overridden", () => { + assert.doesNotThrow(() => assertSafeDevStackOverrides({})); + assert.throws(() => assertSafeDevStackOverrides({ SPACE_DEV_STACK_DIR: "/tmp/shared" }), /SPACE_DEV_STACK_DIR/); + assert.throws(() => assertSafeDevStackOverrides({ SPACE_DEV_COMPOSE_PROJECT: "someone-elses-project" }), /SPACE_DEV_COMPOSE_PROJECT/); +}); + +test("state ownership never adopts a pre-existing or corrupt directory", async () => { const root = await mkdtemp(join(tmpdir(), "spaceapp-state-owner-")); + const config = await resolveConfig({}, root); try { - const config = await resolveConfig({}, root); - await mkdir(config.stateRoot, { mode: 0o700 }); - const keep = join(config.stateRoot, "keep.txt"); - await writeFile(keep, "user data\n"); - await assert.rejects(() => claimStateOwnership(config), /not initialized by dev:stack/); - assert.equal(await readFile(keep, "utf8"), "user data\n"); + const owner = await claimStateOwnership(config); + assert.equal(owner.rootHash, config.rootHash); + assert.match(owner.token, /^[a-f0-9]{48}$/); + assert.deepEqual(await readStateOwnership(config), owner); - await rm(keep); - const record = await claimStateOwnership(config); - assert.equal(record.createdStateRoot, false); - assert.equal((await assertOwnedState(config)).rootHash, config.rootHash); - - await writeFile(join(config.stateRoot, ".ownership.json"), "{broken\n", { mode: 0o600 }); - await assert.rejects(() => assertOwnedState(config), /ownership record is corrupt/); + await writeFile(config.ownerFile, "not-json\n", { mode: 0o600 }); + await assert.rejects(() => readStateOwnership(config), /unreadable/); } finally { await rm(root, { recursive: true, force: true }); } + + const preexistingRoot = await mkdtemp(join(tmpdir(), "spaceapp-state-preexisting-")); + const preexistingConfig = await resolveConfig({}, preexistingRoot); + try { + await mkdir(preexistingConfig.stateRoot, { mode: 0o700 }); + await assert.rejects(() => claimStateOwnership(preexistingConfig), /Refusing to adopt pre-existing state directory/); + await writeFile(join(preexistingConfig.stateRoot, "keep-me"), "foreign\n"); + await assert.rejects(() => claimStateOwnership(preexistingConfig), /Refusing to adopt pre-existing state directory/); + assert.equal(await readFile(join(preexistingConfig.stateRoot, "keep-me"), "utf8"), "foreign\n"); + } finally { + await rm(preexistingRoot, { recursive: true, force: true }); + } }); -test("Docker inventory fails closed on query failure or same-project unowned resources", async () => { - const config = { project: "spaceapp-dev-test", rootHash: "0123456789" }; - const ownerFilter = `label=com.spaceapp.dev-stack.owner=${config.rootHash}`; - const cleanRunner = async (_command, args) => ({ code: 0, signal: null, stdout: args.includes(ownerFilter) ? "owned" : "owned", stderr: "" }); - assert.deepEqual(await dockerResources(config, cleanRunner), { - containers: ["owned"], - volumes: ["owned"], - networks: ["owned"] - }); +test("docker inventory fails closed and rejects same-project resources without the ownership label", async () => { + const config = { project: "spaceapp-dev-test" }; + const owner = { token: "b".repeat(48) }; + const failingRunner = async () => ({ code: 1, stdout: "", stderr: "daemon unavailable" }); + await assert.rejects(() => dockerResources(config, owner, failingRunner), /Docker inventory failed/); - const collisionRunner = async (_command, args) => ({ code: 0, signal: null, stdout: args.includes(ownerFilter) ? "owned" : "owned foreign", stderr: "" }); - await assert.rejects(() => dockerResources(config, collisionRunner), /without this checkout's ownership label/); - await assert.rejects(() => dockerResources(config, async () => { throw new Error("daemon unavailable"); }), /daemon unavailable/); -}); + const collisionRunner = async (_command, args) => { + const owned = args.some((arg) => String(arg).includes("com.spaceapp.dev-stack.owner=")); + const kind = args[0] === "ps" ? "container" : args[0] === "volume" ? "volume" : "network"; + return { code: 0, stdout: owned ? "" : `${kind}-1`, stderr: "" }; + }; + await assert.rejects(() => dockerResources(config, owner, collisionRunner), /not proven to belong/); -test("destructive ownership selectors cannot be overridden", () => { - assert.doesNotThrow(() => assertSafeDevStackOverrides({})); - assert.throws(() => assertSafeDevStackOverrides({ SPACE_DEV_STACK_DIR: "/tmp/shared" }), /SPACE_DEV_STACK_DIR/); - assert.throws(() => assertSafeDevStackOverrides({ SPACE_DEV_COMPOSE_PROJECT: "someone-elses-project" }), /SPACE_DEV_COMPOSE_PROJECT/); + const cleanRunner = async (_command, args) => { + const kind = args[0] === "ps" ? "container" : args[0] === "volume" ? "volume" : "network"; + return { code: 0, stdout: `${kind}-1`, stderr: "" }; + }; + assert.deepEqual(await dockerResources(config, owner, cleanRunner), { + containers: ["container-1"], + volumes: ["volume-1"], + networks: ["network-1"] + }); }); test("supervisor identity rejects PID reuse and atomically claims a checkout", async () => { @@ -195,7 +220,7 @@ test("supervisor identity rejects PID reuse and atomically claims a checkout", a rootHash: "0123456789", pidFile: join(root, "supervisor.pid") }; - const ownedIdentity = async () => ({ startedAt: "Sun Oct 18 07:40:00 2026", args: "node deploy/docker/dev-stack.mjs", state: "S", pgid: process.pid }); + const ownedIdentity = async () => ({ startedAt: "Sun Oct 18 07:40:00 2026", args: "node deploy/docker/dev-stack.mjs" }); try { const record = await claimSupervisor(config, ownedIdentity); assert.equal(record.pid, process.pid); @@ -203,7 +228,7 @@ test("supervisor identity rejects PID reuse and atomically claims a checkout", a assert.equal(supervisorRecordMatches(config, record, await ownedIdentity()), true); await assert.rejects(() => claimSupervisor(config, ownedIdentity), /already running/); - const reusedIdentity = async () => ({ startedAt: "Sun Oct 18 07:41:00 2026", args: "node unrelated-tool.mjs", state: "S", pgid: process.pid }); + const reusedIdentity = async () => ({ startedAt: "Sun Oct 18 07:41:00 2026", args: "node unrelated-tool.mjs" }); await assert.rejects(() => ownedSupervisorPid(config, { identityProvider: reusedIdentity }), /Refusing to signal PID/); await releaseSupervisor(config, process.pid); @@ -215,76 +240,54 @@ test("supervisor identity rejects PID reuse and atomically claims a checkout", a } }); -test("managed process identity rejects PID reuse", () => { - const config = { rootHash: "0123456789" }; - const record = { - name: "api", - pid: 4321, - pgid: 4321, - rootHash: config.rootHash, - startedAt: "Sun Oct 18 08:00:00 2026", - args: "npm exec -- tsx watch apps/api/src/server.ts" +test("persisted process-group ownership cleans descendants after the leader exits", { skip: process.platform === "win32" }, async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-process-owner-")); + const config = { + rootHash: "abcdef0123", + processFile: join(root, "process-groups.json") }; - assert.equal(managedProcessRecordMatches(config, record, { - startedAt: record.startedAt, - args: record.args, - state: "S", - pgid: record.pgid - }), true); - assert.equal(managedProcessRecordMatches(config, record, { - startedAt: "Sun Oct 18 08:01:00 2026", - args: record.args, - state: "S", - pgid: record.pgid - }), false); -}); - -test("persisted managed process records allow cleanup after supervisor loss", { skip: process.platform === "win32" }, async () => { - const root = await mkdtemp(join(tmpdir(), "spaceapp-orphan-test-")); - const config = { rootHash: "0123456789", stateRoot: root }; - const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + const owner = ownership(config.rootHash); + const childPidFile = join(root, "child.pid"); + const childScript = "setInterval(() => {}, 1000)"; + const leaderScript = ` + const { spawn } = require("node:child_process"); + const fs = require("node:fs"); + const child = spawn(process.execPath, ["-e", ${JSON.stringify(childScript)}], { stdio: "ignore", env: process.env }); + fs.writeFileSync(${JSON.stringify(childPidFile)}, String(child.pid)); + setTimeout(() => process.exit(0), 50); + `; + const leader = spawn(process.execPath, ["-e", leaderScript], { detached: true, - stdio: "ignore" + stdio: "ignore", + env: { ...process.env, SPACE_DEV_PROCESS_TOKEN: owner.token } }); + leader.unref(); + const record = { name: "orphan-probe", pid: leader.pid, pgid: leader.pid, sid: leader.pid }; try { - let identity = null; + await writeProcessRecords(config, owner, [record]); const deadline = Date.now() + 3000; - while (Date.now() < deadline && !identity) { - identity = await processIdentity(child.pid); - if (!identity) await new Promise((resolvePromise) => setTimeout(resolvePromise, 25)); + while (Date.now() < deadline) { + if (existsSync(childPidFile)) { + const leaderStatus = await run("ps", ["-p", String(leader.pid), "-o", "stat="], { capture: true, allowFailure: true }); + if (leaderStatus.code !== 0 || leaderStatus.stdout.startsWith("Z")) break; + } + await sleep(50); } - assert.ok(identity); - assert.equal(identity.pgid, child.pid); - await writeManagedProcessRecords(config, [{ - name: "synthetic", - pid: child.pid, - pgid: child.pid, - rootHash: config.rootHash, - startedAt: identity.startedAt, - args: identity.args - }]); - await stopRecordedProcesses(config); - assert.equal(existsSync(join(root, ".processes.json")), false); - assert.equal(pidAlive(child.pid), false); - } finally { - try { process.kill(-child.pid, "SIGKILL"); } catch {} - await rm(root, { recursive: true, force: true }); - } -}); + assert.equal(existsSync(childPidFile), true); + const childPid = Number(await readFile(childPidFile, "utf8")); + assert.ok(Number.isInteger(childPid) && childPid > 1); -test("terminateTree still kills descendants after the detached leader exits", { skip: process.platform === "win32" }, async () => { - const leader = spawn(process.execPath, ["-e", ` - const { spawn } = require("node:child_process"); - spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }); - setTimeout(() => process.exit(0), 100); - `], { detached: true, stdio: "ignore" }); - try { - await once(leader, "exit"); - assert.notEqual(leader.exitCode, null); - terminateTree(leader, "SIGTERM"); - await waitDead(leader, 500); + await terminateOwnedProcessGroups(config, owner, { timeoutMs: 1500 }); + const groupStatus = await run("ps", ["-axo", "pid=,pgid=,sid=,stat="], { capture: true, allowFailure: true }); + const liveMembers = groupStatus.stdout.split(/\r?\n/).filter((line) => { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)/); + return match && Number(match[2]) === record.pgid && Number(match[3]) === record.sid && !match[4].startsWith("Z"); + }); + assert.deepEqual(liveMembers, []); + assert.equal(existsSync(config.processFile), false); } finally { try { process.kill(-leader.pid, "SIGKILL"); } catch {} + await rm(root, { recursive: true, force: true }); } }); From c6e5f19c8adea51420b5fb98743a425fc4d014d3 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 17:46:25 +0900 Subject: [PATCH 55/71] fix(dev): use portable ps session field on macOS --- deploy/docker/dev-stack-safety.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/docker/dev-stack-safety.mjs b/deploy/docker/dev-stack-safety.mjs index 9c03f9f42..9a2722b20 100644 --- a/deploy/docker/dev-stack-safety.mjs +++ b/deploy/docker/dev-stack-safety.mjs @@ -203,7 +203,7 @@ export async function clearProcessRecords(config) { } async function listGroupMembers(record, runner = run) { - const result = await runner("ps", ["-axo", "pid=,pgid=,sid=,stat="], { capture: true, allowFailure: true }); + const result = await runner("ps", ["-axo", "pid=,pgid=,sess=,stat="], { capture: true, allowFailure: true }); if (result.code !== 0) throw new Error("Could not inventory local processes; refusing to signal a dev-stack process group."); const members = []; for (const line of result.stdout.split(/\r?\n/)) { From 31cc260abb38f3fdd79ef4215dc8460efb31990f Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 18:15:51 +0900 Subject: [PATCH 56/71] fix(dev): safely clean live descendants and portable process groups --- deploy/docker/dev-stack-safety.mjs | 57 ++++++++---- deploy/docker/dev-stack.mjs | 2 +- deploy/docker/dev-stack.test.mjs | 134 ++++++++++++++++++++++++++++- 3 files changed, 174 insertions(+), 19 deletions(-) diff --git a/deploy/docker/dev-stack-safety.mjs b/deploy/docker/dev-stack-safety.mjs index 9a2722b20..ce3b2174d 100644 --- a/deploy/docker/dev-stack-safety.mjs +++ b/deploy/docker/dev-stack-safety.mjs @@ -203,17 +203,26 @@ export async function clearProcessRecords(config) { } async function listGroupMembers(record, runner = run) { - const result = await runner("ps", ["-axo", "pid=,pgid=,sess=,stat="], { capture: true, allowFailure: true }); + // Match the entire group that kill(-pgid) targets. Darwin's `sess` is not a + // session-leader PID; filtering on it can silently hide owned live members. + const result = await runner("ps", ["-axo", "pid=,pgid=,stat=,lstart="], { + capture: true, allowFailure: true, env: { ...process.env, LC_ALL: "C" } + }); if (result.code !== 0) throw new Error("Could not inventory local processes; refusing to signal a dev-stack process group."); const members = []; for (const line of result.stdout.split(/\r?\n/)) { - const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)/); - if (!match) continue; + if (!line.trim()) continue; + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)(?:\s+(.+))?$/); + if (!match) throw new Error("Could not parse local process inventory; refusing to signal a dev-stack process group."); const pid = Number(match[1]); const pgid = Number(match[2]); - const sid = Number(match[3]); - const stat = match[4]; - if (pgid === record.pgid && sid === record.sid && !stat.startsWith("Z")) members.push({ pid, stat }); + const stat = match[3]; + if (pgid !== record.pgid || stat.startsWith("Z")) continue; + const startedAt = match[4]?.trim(); + if (!/^[A-Za-z?]/.test(stat) || !/^[A-Za-z]{3}\s+[A-Za-z]{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4}$/.test(startedAt || "")) { + throw new Error(`Could not establish start identity for PID ${pid}; refusing to signal its process group.`); + } + members.push({ pid, stat, startedAt }); } return members; } @@ -224,9 +233,23 @@ async function processHasToken(pid, token, runner = run) { return result.stdout.includes(`SPACE_DEV_PROCESS_TOKEN=${token}`); } -async function verifiedGroupMembers(record, ownership, runner = run) { +async function snapshotLiveGroup(record, ownership, child, runner) { + const isLiveChild = () => child?.pid === record.pid && child.exitCode === null && child.signalCode === null; + if (!isLiveChild()) return new Map(); + const members = await listGroupMembers(record, runner); + if (!members.some((member) => member.pid === child.pid) || !await processHasToken(child.pid, ownership.token, runner) || !isLiveChild()) { + return new Map(); + } + // The current supervisor's live child anchors this session. Descendants may + // intentionally drop the token; retain their start identities before signalling. + // Neither persisted records nor an already-exited child grant this trust. + return new Map(members.map((member) => [member.pid, member.startedAt])); +} + +async function verifiedGroupMembers(record, ownership, runner = run, trustedMembers = new Map()) { const members = await listGroupMembers(record, runner); for (const member of members) { + if (trustedMembers.get(member.pid) === member.startedAt) continue; if (!await processHasToken(member.pid, ownership.token, runner)) { throw new Error(`Refusing to signal process group ${record.pgid}: PID ${member.pid} does not carry this dev-stack ownership token.`); } @@ -234,8 +257,8 @@ async function verifiedGroupMembers(record, ownership, runner = run) { return members; } -async function signalVerifiedGroup(record, ownership, signal, runner, signaler) { - const members = await verifiedGroupMembers(record, ownership, runner); +async function signalVerifiedGroup(record, ownership, signal, runner, signaler, trustedMembers) { + const members = await verifiedGroupMembers(record, ownership, runner, trustedMembers); if (!members.length) return false; try { if (process.platform === "win32") { @@ -251,6 +274,7 @@ async function signalVerifiedGroup(record, ownership, signal, runner, signaler) export async function terminateOwnedProcessGroups(config, ownership, { records = null, + liveChildren = [], timeoutMs = 7000, runner = run, signaler = process.kill @@ -258,17 +282,20 @@ export async function terminateOwnedProcessGroups(config, ownership, { validateOwnership(config, ownership); const groups = (records ?? await readProcessRecords(config, ownership)).map(validateProcessRecord); for (const record of groups) { - if (!await signalVerifiedGroup(record, ownership, "SIGTERM", runner, signaler)) continue; + const child = records == null ? null : liveChildren.find((candidate) => candidate.pid === record.pid); + const trustedMembers = await snapshotLiveGroup(record, ownership, child, runner); + const members = () => verifiedGroupMembers(record, ownership, runner, trustedMembers); + if (!await signalVerifiedGroup(record, ownership, "SIGTERM", runner, signaler, trustedMembers)) continue; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - if (!(await verifiedGroupMembers(record, ownership, runner)).length) break; + if (!(await members()).length) break; await sleep(100); } - if ((await verifiedGroupMembers(record, ownership, runner)).length) { - await signalVerifiedGroup(record, ownership, "SIGKILL", runner, signaler); + if ((await members()).length) { + await signalVerifiedGroup(record, ownership, "SIGKILL", runner, signaler, trustedMembers); const killDeadline = Date.now() + 2000; - while (Date.now() < killDeadline && (await verifiedGroupMembers(record, ownership, runner)).length) await sleep(50); - if ((await verifiedGroupMembers(record, ownership, runner)).length) { + while (Date.now() < killDeadline && (await members()).length) await sleep(50); + if ((await members()).length) { throw new Error(`Owned process group ${record.pgid} did not terminate; process ownership state was preserved for retry.`); } } diff --git a/deploy/docker/dev-stack.mjs b/deploy/docker/dev-stack.mjs index 17e662381..a204edbd0 100755 --- a/deploy/docker/dev-stack.mjs +++ b/deploy/docker/dev-stack.mjs @@ -251,7 +251,7 @@ export async function start(config) { const records = snapshot.map((entry) => entry.record); await processRecordWrite; if (records.length) { - await terminateOwnedProcessGroups(config, ownership, { records }); + await terminateOwnedProcessGroups(config, ownership, { records, liveChildren: snapshot.map((entry) => entry.child) }); await clearProcessRecords(config); } try { await rm(config.socketPath, { force: true }); } catch {} diff --git a/deploy/docker/dev-stack.test.mjs b/deploy/docker/dev-stack.test.mjs index 6894d67e8..0f5f928d0 100644 --- a/deploy/docker/dev-stack.test.mjs +++ b/deploy/docker/dev-stack.test.mjs @@ -278,10 +278,11 @@ test("persisted process-group ownership cleans descendants after the leader exit assert.ok(Number.isInteger(childPid) && childPid > 1); await terminateOwnedProcessGroups(config, owner, { timeoutMs: 1500 }); - const groupStatus = await run("ps", ["-axo", "pid=,pgid=,sid=,stat="], { capture: true, allowFailure: true }); + const groupStatus = await run("ps", ["-axo", "pid=,pgid=,stat="], { capture: true }); + assert.equal(groupStatus.code, 0); const liveMembers = groupStatus.stdout.split(/\r?\n/).filter((line) => { - const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)/); - return match && Number(match[2]) === record.pgid && Number(match[3]) === record.sid && !match[4].startsWith("Z"); + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)/); + return match && Number(match[2]) === record.pgid && !match[3].startsWith("Z"); }); assert.deepEqual(liveMembers, []); assert.equal(existsSync(config.processFile), false); @@ -291,6 +292,133 @@ test("persisted process-group ownership cleans descendants after the leader exit } }); +test("live child ownership cleans environment-sanitized descendants without relaxing persisted recovery", { skip: process.platform === "win32" }, async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-sanitized-descendant-")); + const config = { rootHash: "abcdef0123", processFile: join(root, "process-groups.json") }; + const owner = ownership(config.rootHash); + const readyFile = join(root, "descendant.json"); + const descendantScript = ` + process.on("SIGTERM", () => {}); + require("node:fs").writeFileSync(${JSON.stringify(readyFile)}, JSON.stringify({ pid: process.pid, token: process.env.SPACE_DEV_PROCESS_TOKEN })); + setInterval(() => {}, 1000); + `; + const leader = spawn(process.execPath, ["-e", ` + require("node:child_process").spawn(process.execPath, ["-e", ${JSON.stringify(descendantScript)}], { stdio: "ignore", env: {} }); + setInterval(() => {}, 1000); + `], { detached: true, stdio: "ignore", env: { ...process.env, SPACE_DEV_PROCESS_TOKEN: owner.token } }); + const record = { name: "sanitized-probe", pid: leader.pid, pgid: leader.pid, sid: leader.pid }; + try { + await writeProcessRecords(config, owner, [record]); + const deadline = Date.now() + 3000; + while (!existsSync(readyFile) && Date.now() < deadline) await sleep(20); + const descendant = JSON.parse(await readFile(readyFile, "utf8")); + assert.equal(descendant.token, undefined); + assert.equal(leader.exitCode, null); + + await assert.rejects(() => terminateOwnedProcessGroups(config, owner), /does not carry this dev-stack ownership token/); + await assert.rejects(() => terminateOwnedProcessGroups(config, owner, { liveChildren: [leader] }), /does not carry this dev-stack ownership token/); + await assert.rejects(() => terminateOwnedProcessGroups(config, owner, { records: [record] }), /does not carry this dev-stack ownership token/); + assert.equal(existsSync(config.processFile), true); + + await terminateOwnedProcessGroups(config, owner, { records: [record], liveChildren: [leader], timeoutMs: 100 }); + const status = await run("ps", ["-p", String(descendant.pid), "-o", "stat="], { capture: true, allowFailure: true }); + assert.ok(status.code !== 0 || status.stdout.startsWith("Z")); + assert.notEqual(leader.signalCode, null); + } finally { + try { process.kill(-leader.pid, "SIGKILL"); } catch {} + await rm(root, { recursive: true, force: true }); + } +}); + +test("live group snapshots fail closed on stale leaders and changed process identities", { skip: process.platform === "win32" }, async (t) => { + const config = { rootHash: "abcdef0123" }; + const owner = ownership(config.rootHash); + const record = { name: "identity-probe", pid: 4100, pgid: 4100, sid: 4100 }; + const startedAt = "Sun Oct 18 07:40:00 2026"; + + for (const scenario of ["exited leader", "different child", "leader exits during snapshot", "missing leader", "wrong leader token", "reused descendant", "new unverified descendant", "missing start identity"]) { + await t.test(scenario, async () => { + const child = { pid: scenario === "different child" ? 4200 : record.pid, exitCode: scenario === "exited leader" ? 0 : null, signalCode: null }; + const signals = []; + let terminated = false; + const runner = async (_command, args) => { + if (args[0] === "-axo") { + const leader = scenario === "missing leader" || terminated ? "" : `4100 4100 S ${startedAt}\n`; + const childStart = terminated && scenario === "reused descendant" ? "Sun Oct 18 07:41:00 2026" : startedAt; + const descendant = `4101 4100 S${scenario === "missing start identity" ? "" : ` ${childStart}`}\n`; + const newcomer = terminated && scenario === "new unverified descendant" ? `4102 4100 S ${startedAt}\n` : ""; + return { code: 0, stdout: leader + descendant + newcomer, stderr: "" }; + } + const pid = Number(args[args.indexOf("-p") + 1]); + if (pid === record.pid) { + if (scenario === "leader exits during snapshot") child.signalCode = "SIGTERM"; + return { code: 0, stdout: scenario === "wrong leader token" ? "node unrelated" : `node SPACE_DEV_PROCESS_TOKEN=${owner.token}`, stderr: "" }; + } + return { code: 0, stdout: "node sanitized-child", stderr: "" }; + }; + await assert.rejects(() => terminateOwnedProcessGroups(config, owner, { + records: [record], liveChildren: [child], runner, timeoutMs: 0, + signaler: (pid, signal) => { signals.push([pid, signal]); terminated = true; child.signalCode = signal; } + }), /does not carry this dev-stack ownership token|Could not establish start identity/); + const expected = ["reused descendant", "new unverified descendant"].includes(scenario) ? [[-record.pgid, "SIGTERM"]] : []; + assert.deepEqual(signals, expected); + }); + } +}); + +test("process-group cleanup does not depend on Darwin sess values", async (t) => { + for (const sess of ["0", "ffffffff8100abcd"]) { + await t.test(`sess=${sess}`, async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-darwin-group-")); + const config = { rootHash: "abcdef0123", processFile: join(root, "process-groups.json") }; + const owner = ownership(config.rootHash); + const record = { name: "darwin-probe", pid: 4100, pgid: 4100, sid: 4100 }; + const signals = []; + let stopped = false; + try { + await writeProcessRecords(config, owner, [record]); + const runner = async (_command, args) => { + if (args[0] !== "-axo") return { code: 0, stdout: `node SPACE_DEV_PROCESS_TOKEN=${owner.token}`, stderr: "" }; + const fields = args[1].split(",").map((field) => field.replace(/=$/, "")); + const rows = stopped ? [] : [4100, 4101].map((pid) => ({ pid, pgid: 4100, sess, stat: "S", lstart: "Sun Oct 18 07:40:00 2026" })); + return { code: 0, stdout: rows.map((row) => fields.map((field) => row[field]).join(" ")).join("\n"), stderr: "" }; + }; + await terminateOwnedProcessGroups(config, owner, { + runner, timeoutMs: 0, + signaler: (pid, signal) => { signals.push([pid, signal]); stopped = true; } + }); + assert.deepEqual(signals, process.platform === "win32" ? [[4100, "SIGTERM"], [4101, "SIGTERM"]] : [[-4100, "SIGTERM"]]); + assert.equal(existsSync(config.processFile), false); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + } +}); + +test("malformed process inventory preserves recovery records without signalling", async (t) => { + for (const stdout of ["unparseable process", "4100 4100 0 S Sun Oct 18 07:40:00 2026", "4100 4100 broken-sess S Sun Oct 18 07:40:00 2026", "4100 4100 S"]) { + await t.test(stdout, async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-malformed-group-")); + const config = { rootHash: "abcdef0123", processFile: join(root, "process-groups.json") }; + const owner = ownership(config.rootHash); + const record = { name: "malformed-probe", pid: 4100, pgid: 4100, sid: 4100 }; + const signals = []; + try { + await writeProcessRecords(config, owner, [record]); + await assert.rejects(() => terminateOwnedProcessGroups(config, owner, { + runner: async () => ({ code: 0, stdout, stderr: "" }), + signaler: (...args) => signals.push(args) + }), /Could not parse local process inventory|Could not establish start identity/); + assert.deepEqual(signals, []); + assert.equal(existsSync(config.processFile), true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + } +}); + test("macOS source stack repairs node-pty spawn-helper execute permissions", async () => { const root = await mkdtemp(join(tmpdir(), "spaceapp-pty-helper-")); const helper = join(root, "node_modules", "node-pty", "prebuilds", "darwin-x64", "spawn-helper"); From 18109ba721e31d5653b20d023706186f4f68d31c Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 18:52:24 +0900 Subject: [PATCH 57/71] fix(dev): avoid redundant builds and await captured output --- deploy/docker/dev-stack-lib.mjs | 41 ++++++++++++++---------- deploy/docker/dev-stack.test.mjs | 54 +++++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 17 deletions(-) diff --git a/deploy/docker/dev-stack-lib.mjs b/deploy/docker/dev-stack-lib.mjs index b01bc2140..0d9fca424 100644 --- a/deploy/docker/dev-stack-lib.mjs +++ b/deploy/docker/dev-stack-lib.mjs @@ -105,7 +105,7 @@ export async function run(command, args, { capture = false, allowFailure = false child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); } child.once("error", rejectPromise); - child.once("exit", (code, signal) => { + child.once("close", (code, signal) => { const result = { code: code ?? 1, signal, stdout: stdout.trim(), stderr: stderr.trim() }; if (!allowFailure && code !== 0) rejectPromise(new Error(`${command} ${args.join(" ")} failed (${code ?? signal}).${stderr ? `\n${stderr}` : ""}`)); else resolvePromise(result); @@ -139,27 +139,36 @@ export async function prepareState(config) { return { databaseUrl, sessionSecret, postgresPassword }; } -async function newestMtime(path) { - if (!existsSync(path)) return 0; - const info = await stat(path); - if (!info.isDirectory()) return info.mtimeMs; - let newest = info.mtimeMs; - for (const entry of await readdir(path, { withFileTypes: true })) { - if (["node_modules", "dist", "coverage"].includes(entry.name)) continue; - newest = Math.max(newest, await newestMtime(join(path, entry.name))); - } - return newest; +async function buildSourceFingerprint(root) { + const inputs = []; + const visit = async (path) => { + if (!existsSync(path)) return; + const info = await stat(path); + if (!info.isDirectory()) { + inputs.push([relative(root, path), info.mtimeMs, info.size]); + return; + } + for (const name of (await readdir(path)).sort()) { + if (["node_modules", "dist", "coverage"].includes(name)) continue; + await visit(join(path, name)); + } + }; + await visit(join(root, "packages")); + await visit(join(root, "apps", "worker", "src")); + // Building creates excluded output directories, which changes parent directory + // mtimes. Hash source file identities instead, including paths to detect deletes. + return createHash("sha256").update(JSON.stringify(inputs)).digest("hex"); } -export async function ensureBuild(config, force = false) { +export async function ensureBuild(config, force = false, runner = run) { if (process.env.SPACE_DEV_SKIP_BUILD === "true" && !force) return; const artifacts = ["packages/contracts/dist/index.js", "packages/runtime/dist/index.js", "packages/db/dist/migrate.js", "packages/cli-host/dist/main.js", "apps/worker/dist/worker.js"].map((p) => join(config.root, p)); const marker = existsSync(config.buildMarker) ? JSON.parse(await readFile(config.buildMarker, "utf8")) : null; - const sourceMtime = Math.max(await newestMtime(join(config.root, "packages")), await newestMtime(join(config.root, "apps", "worker", "src"))); - if (!force && process.env.SPACE_DEV_FORCE_BUILD !== "true" && marker && artifacts.every(existsSync) && sourceMtime <= Number(marker.sourceMtime || 0)) return; + const sourceFingerprint = await buildSourceFingerprint(config.root); + if (!force && process.env.SPACE_DEV_FORCE_BUILD !== "true" && marker && artifacts.every(existsSync) && sourceFingerprint === marker.sourceFingerprint) return; console.log("[dev:stack] Building source workspaces before migrations..."); - await run("npm", ["run", "build"], { cwd: config.root }); - await writeFile(config.buildMarker, `${JSON.stringify({ sourceMtime, builtAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 }); + await runner("npm", ["run", "build"], { cwd: config.root }); + await writeFile(config.buildMarker, `${JSON.stringify({ sourceFingerprint, builtAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 }); } export async function installOpenCode(config, version) { diff --git a/deploy/docker/dev-stack.test.mjs b/deploy/docker/dev-stack.test.mjs index 0f5f928d0..ca51beeb3 100644 --- a/deploy/docker/dev-stack.test.mjs +++ b/deploy/docker/dev-stack.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFile, spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -9,6 +9,7 @@ import { promisify } from "node:util"; import { assertSafeStateRoot, dockerResources, + ensureBuild, installOpenCode, parseOpenCodeVersion, parsePinnedImages, @@ -33,6 +34,57 @@ import { const ownership = (rootHash) => ({ version: 1, rootHash, token: "a".repeat(48) }); +test("captured commands wait for inherited output streams to close", async () => { + const delayedOutput = 'setTimeout(() => { process.stdout.write("complete output"); process.stderr.write("complete error"); }, 100)'; + const script = ` + require("node:child_process").spawn(process.execPath, ["-e", ${JSON.stringify(delayedOutput)}], { stdio: ["ignore", process.stdout, process.stderr] }); + process.exit(0); + `; + const result = await run(process.execPath, ["-e", script], { capture: true }); + assert.equal(result.stdout, "complete output"); + assert.match(result.stderr, /complete error$/); +}); + +test("build markers ignore generated-directory timestamps but detect source changes and deletions", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-build-marker-")); + const config = { root, buildMarker: join(root, "build.json") }; + const source = join(root, "packages", "contracts", "src", "index.ts"); + const artifacts = ["packages/contracts/dist/index.js", "packages/runtime/dist/index.js", "packages/db/dist/migrate.js", "packages/cli-host/dist/main.js", "apps/worker/dist/worker.js"]; + let builds = 0; + try { + await mkdir(join(root, "packages", "contracts", "src"), { recursive: true }); + await mkdir(join(root, "apps", "worker", "src"), { recursive: true }); + await writeFile(source, "export const value = 1;\n"); + const runner = async () => { + builds += 1; + for (const artifact of artifacts) { + const target = join(root, artifact); + await mkdir(join(target, ".."), { recursive: true }); + await writeFile(target, "// built\n"); + } + const future = new Date(Date.now() + 2000); + await utimes(join(root, "packages"), future, future); + }; + await ensureBuild(config, false, runner); + await ensureBuild(config, false, runner); + assert.equal(builds, 1, "startup must not rebuild solely because dist directories were created"); + + await writeFile(source, "export const value = 22;\n"); + await ensureBuild(config, false, runner); + assert.equal(builds, 2); + await rm(source); + await ensureBuild(config, false, runner); + assert.equal(builds, 3, "source deletion must invalidate the marker"); + await rm(join(root, artifacts[0])); + await ensureBuild(config, false, runner); + assert.equal(builds, 4, "missing artifacts must trigger a build"); + await ensureBuild(config, true, runner); + assert.equal(builds, 5, "explicit rebuild must remain unconditional"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("source stack derives third-party pins from canonical files", () => { const images = parsePinnedImages(` services: From 9d5ee146548a04bcc65bc7bdac21a58757ec8822 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 19:19:16 +0900 Subject: [PATCH 58/71] fix(dev): isolate streaming credentials in stack state --- deploy/docker/dev-stack-lib.mjs | 1 + deploy/docker/dev-stack.test.mjs | 50 +++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/deploy/docker/dev-stack-lib.mjs b/deploy/docker/dev-stack-lib.mjs index 0d9fca424..5aec195d7 100644 --- a/deploy/docker/dev-stack-lib.mjs +++ b/deploy/docker/dev-stack-lib.mjs @@ -242,6 +242,7 @@ export function sourceEnv(config, databaseUrl, sessionSecret, openCodeWrapper) { SPACE_DATABASE_URL: databaseUrl, SPACE_SESSION_SECRET: sessionSecret, SPACE_SETUP_TOKEN_FILE: join(config.secretsDir, "setup-token"), + SPACE_STREAMING_SECRET_ROOT: join(config.secretsDir, "streaming"), SPACE_DEV_LOGIN: "false", SPACE_GOOGLE_AUTH_DISABLED: "true", SPACE_PUBLIC_DISTRIBUTION: "true", diff --git a/deploy/docker/dev-stack.test.mjs b/deploy/docker/dev-stack.test.mjs index ca51beeb3..703fee87d 100644 --- a/deploy/docker/dev-stack.test.mjs +++ b/deploy/docker/dev-stack.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFile, spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, utimes, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -13,6 +13,7 @@ import { installOpenCode, parseOpenCodeVersion, parsePinnedImages, + prepareState, renderCompose, resolveConfig, run, @@ -147,6 +148,53 @@ test("reset removes only the state directory it initialized when Docker inventor } }); +test("streaming credentials stay private inside owned state and reset preserves an installed instance", async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-streaming-isolation-")); + const installedRoot = join(root, "installed-streaming"); + const oldPath = process.env.PATH; + const oldStreamingRoot = process.env.SPACE_STREAMING_SECRET_ROOT; + try { + await mkdir(installedRoot, { mode: 0o755 }); + const sentinel = join(installedRoot, "keep.json"); + await writeFile(sentinel, "installed instance\n", { mode: 0o600 }); + const installedMode = (await stat(installedRoot)).mode & 0o777; + process.env.SPACE_STREAMING_SECRET_ROOT = installedRoot; + const config = await resolveConfig({}, root); + const env = sourceEnv(config, "postgresql://unused", "test-session-secret", join(config.binDir, "opencode-vscode-parity")); + assert.equal(env.SPACE_STREAMING_SECRET_ROOT, join(config.secretsDir, "streaming")); + await claimStateOwnership(config); + await prepareState(config); + await run(process.execPath, ["--import", "tsx", "--input-type=module", "-e", ` + import { StreamingCredentialStore } from "./packages/streaming/src/credential-store.ts"; + const store = new StreamingCredentialStore(process.env.SPACE_STREAMING_SECRET_ROOT); + await store.writeCredential("dev-stack-probe", { probe: "owned test data" }); + `], { env, capture: true }); + const streamingRoot = env.SPACE_STREAMING_SECRET_ROOT; + const credentials = join(streamingRoot, "credentials"); + assert.equal((await stat(streamingRoot)).mode & 0o777, 0o700); + assert.equal((await stat(credentials)).mode & 0o777, 0o700); + const files = await readdir(credentials); + assert.equal(files.length, 1); + assert.equal((await stat(join(credentials, files[0]))).mode & 0o777, 0o600); + + const bin = join(root, "fake-bin"); + await mkdir(bin); + await writeFile(join(bin, "docker"), "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + process.env.PATH = `${bin}:${oldPath || ""}`; + await reset(config); + assert.equal(existsSync(config.stateRoot), false); + assert.equal(await readFile(sentinel, "utf8"), "installed instance\n"); + assert.equal((await stat(installedRoot)).mode & 0o777, installedMode); + assert.deepEqual(await readdir(installedRoot), ["keep.json"]); + } finally { + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldStreamingRoot === undefined) delete process.env.SPACE_STREAMING_SECRET_ROOT; + else process.env.SPACE_STREAMING_SECRET_ROOT = oldStreamingRoot; + await rm(root, { recursive: true, force: true }); + } +}); + test("defaults isolate installed SpaceApp and bind Docker ports to loopback", async () => { const root = await mkdtemp(join(tmpdir(), "spaceapp-dev-test-")); try { From 57beae31f611eddb5451ef2598eb7025af1ec1c7 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 19:55:34 +0900 Subject: [PATCH 59/71] fix(dev): cancel owned startup builds and measure macOS cold start --- .github/workflows/dev-stack.yml | 4 +- deploy/docker/dev-stack-lib.mjs | 4 +- deploy/docker/dev-stack.mjs | 37 +++++++++++++- deploy/docker/dev-stack.test.mjs | 84 ++++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml index 224790c24..770f3e324 100644 --- a/.github/workflows/dev-stack.yml +++ b/.github/workflows/dev-stack.yml @@ -194,7 +194,9 @@ jobs: - name: Verify readiness, owner setup, and OpenCode pane env: - SPACE_DEV_VERIFY_READY_TIMEOUT_MS: "900000" + # Cold Colima/image startup plus one full source build can exceed 15m. + # Keep fresh-checkout startup under test; readiness assertions stay intact. + SPACE_DEV_VERIFY_READY_TIMEOUT_MS: "1800000" SPACE_DEV_VERIFY_CLI_TIMEOUT_MS: "300000" run: npm run verify:dev-stack diff --git a/deploy/docker/dev-stack-lib.mjs b/deploy/docker/dev-stack-lib.mjs index 5aec195d7..27c74b124 100644 --- a/deploy/docker/dev-stack-lib.mjs +++ b/deploy/docker/dev-stack-lib.mjs @@ -166,9 +166,11 @@ export async function ensureBuild(config, force = false, runner = run) { const marker = existsSync(config.buildMarker) ? JSON.parse(await readFile(config.buildMarker, "utf8")) : null; const sourceFingerprint = await buildSourceFingerprint(config.root); if (!force && process.env.SPACE_DEV_FORCE_BUILD !== "true" && marker && artifacts.every(existsSync) && sourceFingerprint === marker.sourceFingerprint) return; - console.log("[dev:stack] Building source workspaces before migrations..."); + const buildStartedAt = Date.now(); + console.log(`[dev:stack] Building source workspaces before migrations... started at ${new Date(buildStartedAt).toISOString()}`); await runner("npm", ["run", "build"], { cwd: config.root }); await writeFile(config.buildMarker, `${JSON.stringify({ sourceFingerprint, builtAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 }); + console.log(`[dev:stack] Source workspace build completed at ${new Date().toISOString()} (${Math.round((Date.now() - buildStartedAt) / 1000)}s).`); } export async function installOpenCode(config, version) { diff --git a/deploy/docker/dev-stack.mjs b/deploy/docker/dev-stack.mjs index a204edbd0..da8d1d6d1 100755 --- a/deploy/docker/dev-stack.mjs +++ b/deploy/docker/dev-stack.mjs @@ -245,6 +245,39 @@ export async function start(config) { await processRecordWrite; }; + const runBuild = async (command, args, options = {}) => { + if (stopping) throw new Error("Refusing to build while the dev stack is stopping."); + // Builds can spawn compilers of their own. Track the transient group just + // like a service so shutdown owns the whole tree, not only the npm leader. + const child = spawn(command, args, { + cwd: config.root, + env: { ...(options.env ?? process.env), SPACE_DEV_PROCESS_TOKEN: ownership.token }, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"] + }); + const completed = new Promise((resolvePromise, rejectPromise) => { + child.once("error", rejectPromise); + child.once("close", (code, signal) => { + if (code === 0) resolvePromise(); + else rejectPromise(new Error(`${command} ${args.join(" ")} failed (${code ?? signal}).`)); + }); + }); + if (!Number.isInteger(child.pid) || child.pid <= 1) { + await completed; + throw new Error("Could not establish a process-group leader for the build."); + } + prefixStream(child.stdout, "build", process.stdout); + prefixStream(child.stderr, "build", process.stderr); + const entry = { child, record: { name: "build", pid: child.pid, pgid: child.pid, sid: child.pid } }; + children.set("build", entry); + await Promise.all([persistChildren(), completed]); + if (children.get("build") === entry) { + children.delete("build"); + await persistChildren(); + } + if (stopping) throw new Error("Build cancelled while the dev stack is stopping."); + }; + const stopChildren = async () => { const snapshot = [...children.values()]; children = new Map(); @@ -299,7 +332,7 @@ export async function start(config) { }; const spawnChildren = async (forceBuild = false, state) => { - await ensureBuild(config, forceBuild); + await ensureBuild(config, forceBuild, runBuild); const repairedHelpers = await ensureDarwinNodePtySpawnHelpers(config.root); if (repairedHelpers.length) { console.log(`[dev:stack] Repaired execute permission on ${repairedHelpers.length} node-pty macOS spawn helper(s).`); @@ -368,7 +401,7 @@ export async function start(config) { waitServiceHealthyWithDiagnostics(config, "temporal", 240_000) ]); await ensureTemporalNamespace(config); - await ensureBuild(config); + await ensureBuild(config, false, runBuild); console.log("[dev:stack] Applying database migrations..."); await run("npm", ["run", "db:migrate"], { cwd: config.root, env: { ...process.env, SPACE_DATABASE_URL: databaseUrl } }); await spawnChildren(false, runtimeState); diff --git a/deploy/docker/dev-stack.test.mjs b/deploy/docker/dev-stack.test.mjs index 703fee87d..eac26ea67 100644 --- a/deploy/docker/dev-stack.test.mjs +++ b/deploy/docker/dev-stack.test.mjs @@ -86,6 +86,90 @@ test("build markers ignore generated-directory timestamps but detect source chan } }); +test("startup build ownership covers cancellation, success, and failure", { skip: process.platform === "win32" }, async (t) => { + for (const mode of ["cancel", "success", "failure", "spawn-failure"]) await t.test(mode, async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-build-cancel-")); + const config = await resolveConfig({}, root); + const bin = join(root, "fake-bin"); + const familyFile = join(root, "build-family.json"); + const migrationFile = join(root, "migration-started"); + let supervisor; + let unrelated; + let family; + let output = ""; + try { + await mkdir(bin); + await mkdir(join(root, "packages", "run-spaceapp", "templates"), { recursive: true }); + await writeFile(join(root, "packages", "run-spaceapp", "templates", "compose.yml"), `services:\n postgres:\n image: pgvector/pgvector:test@sha256:${"a".repeat(64)}\n temporal:\n image: temporalio/auto-setup:test@sha256:${"b".repeat(64)}\n`); + await writeFile(join(root, "Dockerfile"), "RUN npm install opencode-ai@1.18.4\n"); + await writeFile(join(bin, "docker"), `#!${process.execPath} + const args = process.argv.slice(2); + if (args[0] === "inspect") console.log("healthy"); + else if (args[0] === "compose" && args.includes("ps")) console.log("fixture-service"); + `, { mode: 0o700 }); + await writeFile(join(bin, "npm"), `#!${process.execPath} + const fs = require("node:fs"); + if (process.argv[3] === "build") { + if (${JSON.stringify(mode)} !== "cancel") { + fs.writeFileSync(${JSON.stringify(familyFile)}, JSON.stringify({ leader: process.pid })); + process.exit(${mode === "failure" ? 2 : 0}); + } + const descendant = require("node:child_process").spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { env: {}, stdio: ["ignore", "inherit", "inherit"] }); + fs.writeFileSync(${JSON.stringify(familyFile)}, JSON.stringify({ leader: process.pid, descendant: descendant.pid })); + setInterval(() => {}, 1000); + } else if (process.argv[3] === "db:migrate") { + fs.writeFileSync(${JSON.stringify(migrationFile)}, fs.readFileSync(${JSON.stringify(config.processFile)}, "utf8")); + process.exit(2); // Stop the fixture before launching real services. + } + `, { mode: 0o700 }); + if (mode === "spawn-failure") { + await writeFile(join(bin, "npm"), "#!/spaceapp-test-nonexistent-interpreter\n", { mode: 0o700 }); + } + const launcher = join(root, "dev-stack.mjs"); + await writeFile(launcher, ` + import { start } from ${JSON.stringify(new URL("./dev-stack.mjs", import.meta.url).href)}; + import { resolveConfig } from ${JSON.stringify(new URL("./dev-stack-lib.mjs", import.meta.url).href)}; + start(await resolveConfig({}, ${JSON.stringify(root)})).catch(error => { console.error(error.message); process.exitCode = 1; }); + `); + unrelated = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { detached: true, stdio: "ignore" }); + supervisor = spawn(process.execPath, [launcher], { + detached: true, + env: { ...process.env, PATH: `${bin}:${process.env.PATH || ""}` }, + stdio: ["ignore", "pipe", "pipe"] + }); + supervisor.stdout.on("data", chunk => { output += chunk.toString(); }); + supervisor.stderr.on("data", chunk => { output += chunk.toString(); }); + const startedDeadline = Date.now() + 10000; + while (!existsSync(familyFile) && Date.now() < startedDeadline && supervisor.exitCode === null) await sleep(25); + assert.equal(existsSync(familyFile), mode !== "spawn-failure", output); + if (existsSync(familyFile)) family = JSON.parse(await readFile(familyFile, "utf8")); + if (mode === "cancel") supervisor.kill("SIGTERM"); + const stoppedDeadline = Date.now() + 10000; + while (supervisor.exitCode === null && supervisor.signalCode === null && Date.now() < stoppedDeadline) await sleep(25); + assert.notEqual(supervisor.exitCode ?? supervisor.signalCode, null, output); + for (const pid of [family?.leader, family?.descendant].filter(Number.isInteger)) { + const result = await run("ps", ["-p", String(pid), "-o", "stat="], { capture: true, allowFailure: true }); + assert.ok(result.code !== 0 || result.stdout.startsWith("Z"), `build PID ${pid} survived cancellation: ${result.stdout}`); + } + assert.equal(existsSync(migrationFile), mode === "success", "only successful builds may advance startup to migrations"); + assert.equal(existsSync(config.buildMarker), mode === "success", "only successful builds may write a success marker"); + if (mode === "success") { + assert.deepEqual(JSON.parse(await readFile(migrationFile, "utf8")).groups, [], "completed builds must be untracked before migrations"); + } + if (existsSync(config.processFile)) { + assert.deepEqual(JSON.parse(await readFile(config.processFile, "utf8")).groups, [], "terminated builds must leave no tracked groups"); + } + assert.equal(existsSync(config.pidFile), false); + assert.doesNotThrow(() => process.kill(unrelated.pid, 0), "an unrelated process must remain alive"); + } finally { + for (const pid of [supervisor?.pid, family?.leader, unrelated?.pid]) { + if (Number.isInteger(pid)) try { process.kill(-pid, "SIGKILL"); } catch {} + } + await rm(root, { recursive: true, force: true }); + } + }); +}); + test("source stack derives third-party pins from canonical files", () => { const images = parsePinnedImages(` services: From c7578f54fd25f385d079c6817de7453534389265 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 20:26:15 +0900 Subject: [PATCH 60/71] fix(dev): retain spawn ownership when process titles hide environment --- deploy/docker/dev-stack-safety.mjs | 21 +++++++- deploy/docker/dev-stack.mjs | 12 ++--- deploy/docker/dev-stack.test.mjs | 81 ++++++++++++++++++++++++++---- 3 files changed, 96 insertions(+), 18 deletions(-) diff --git a/deploy/docker/dev-stack-safety.mjs b/deploy/docker/dev-stack-safety.mjs index ce3b2174d..f6697295b 100644 --- a/deploy/docker/dev-stack-safety.mjs +++ b/deploy/docker/dev-stack-safety.mjs @@ -1,4 +1,5 @@ import { randomBytes } from "node:crypto"; +import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { pidAlive, run, sleep } from "./dev-stack-lib.mjs"; @@ -6,6 +7,19 @@ import { pidAlive, run, sleep } from "./dev-stack-lib.mjs"; const unsafeOverrideKeys = ["SPACE_DEV_STACK_DIR", "SPACE_DEV_COMPOSE_PROJECT"]; const ownershipVersion = 1; const processRecordVersion = 1; +const spawnedChildren = new WeakMap(); + +export function spawnOwnedProcess(ownership, command, args, options = {}) { + const child = spawn(command, args, { + ...options, + env: { ...(options.env ?? process.env), SPACE_DEV_PROCESS_TOKEN: ownership.token }, + detached: process.platform !== "win32" + }); + if (Number.isInteger(child.pid) && child.pid > 1) { + spawnedChildren.set(child, { pid: child.pid, token: ownership.token }); + } + return child; +} export function assertSafeDevStackOverrides(env = process.env) { for (const key of unsafeOverrideKeys) { @@ -236,8 +250,13 @@ async function processHasToken(pid, token, runner = run) { async function snapshotLiveGroup(record, ownership, child, runner) { const isLiveChild = () => child?.pid === record.pid && child.exitCode === null && child.signalCode === null; if (!isLiveChild()) return new Map(); + const spawned = spawnedChildren.get(child); + const spawnedHere = spawned?.pid === record.pid && spawned.token === ownership.token; const members = await listGroupMembers(record, runner); - if (!members.some((member) => member.pid === child.pid) || !await processHasToken(child.pid, ownership.token, runner) || !isLiveChild()) { + // npm rewrites process.title; Darwin ps can then omit its environment. + // Only our exact, still-live spawned handle supplies alternative provenance. + // A persisted record or another object with the same PID cannot supply it. + if (!members.some((member) => member.pid === child.pid) || (!spawnedHere && !await processHasToken(child.pid, ownership.token, runner)) || !isLiveChild()) { return new Map(); } // The current supervisor's live child anchors this session. Descendants may diff --git a/deploy/docker/dev-stack.mjs b/deploy/docker/dev-stack.mjs index da8d1d6d1..4013881b9 100755 --- a/deploy/docker/dev-stack.mjs +++ b/deploy/docker/dev-stack.mjs @@ -3,7 +3,6 @@ import { existsSync } from "node:fs"; import { chmod, mkdir, readFile, readdir, rename, rm, rmdir, stat, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { spawn } from "node:child_process"; import { compose, dockerResources, @@ -29,6 +28,7 @@ import { ownedSupervisorPid, readStateOwnership, releaseSupervisor, + spawnOwnedProcess, terminateOwnedProcessGroups, writeProcessRecords } from "./dev-stack-safety.mjs"; @@ -47,10 +47,9 @@ function prefixStream(stream, label, target) { } function spawnService(config, name, command, args, env, ownership, onUnexpectedExit) { - const child = spawn(command, args, { + const child = spawnOwnedProcess(ownership, command, args, { cwd: config.root, - env: { ...env, SPACE_DEV_PROCESS_TOKEN: ownership.token }, - detached: process.platform !== "win32", + env, stdio: ["ignore", "pipe", "pipe"] }); if (!Number.isInteger(child.pid) || child.pid <= 1) throw new Error(`Could not establish a process-group leader for ${name}.`); @@ -249,10 +248,9 @@ export async function start(config) { if (stopping) throw new Error("Refusing to build while the dev stack is stopping."); // Builds can spawn compilers of their own. Track the transient group just // like a service so shutdown owns the whole tree, not only the npm leader. - const child = spawn(command, args, { + const child = spawnOwnedProcess(ownership, command, args, { cwd: config.root, - env: { ...(options.env ?? process.env), SPACE_DEV_PROCESS_TOKEN: ownership.token }, - detached: process.platform !== "win32", + env: options.env, stdio: ["ignore", "pipe", "pipe"] }); const completed = new Promise((resolvePromise, rejectPromise) => { diff --git a/deploy/docker/dev-stack.test.mjs b/deploy/docker/dev-stack.test.mjs index eac26ea67..0f0338867 100644 --- a/deploy/docker/dev-stack.test.mjs +++ b/deploy/docker/dev-stack.test.mjs @@ -28,6 +28,7 @@ import { ownedSupervisorPid, readStateOwnership, releaseSupervisor, + spawnOwnedProcess, supervisorRecordMatches, terminateOwnedProcessGroups, writeProcessRecords @@ -87,7 +88,8 @@ test("build markers ignore generated-directory timestamps but detect source chan }); test("startup build ownership covers cancellation, success, and failure", { skip: process.platform === "win32" }, async (t) => { - for (const mode of ["cancel", "success", "failure", "spawn-failure"]) await t.test(mode, async () => { + for (const mode of ["cancel", "renamed-cancel", "success", "failure", "spawn-failure"]) await t.test(mode, async () => { + const cancel = mode.endsWith("cancel"); const root = await mkdtemp(join(tmpdir(), "spaceapp-build-cancel-")); const config = await resolveConfig({}, root); const bin = join(root, "fake-bin"); @@ -110,10 +112,11 @@ test("startup build ownership covers cancellation, success, and failure", { skip await writeFile(join(bin, "npm"), `#!${process.execPath} const fs = require("node:fs"); if (process.argv[3] === "build") { - if (${JSON.stringify(mode)} !== "cancel") { + if (${!cancel}) { fs.writeFileSync(${JSON.stringify(familyFile)}, JSON.stringify({ leader: process.pid })); process.exit(${mode === "failure" ? 2 : 0}); } + process.title = "npm run build"; const descendant = require("node:child_process").spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { env: {}, stdio: ["ignore", "inherit", "inherit"] }); fs.writeFileSync(${JSON.stringify(familyFile)}, JSON.stringify({ leader: process.pid, descendant: descendant.pid })); setInterval(() => {}, 1000); @@ -122,6 +125,23 @@ test("startup build ownership covers cancellation, success, and failure", { skip process.exit(2); // Stop the fixture before launching real services. } `, { mode: 0o700 }); + if (mode === "renamed-cancel") { + // Darwin ps can stop at process.title's NUL padding before reaching + // the environment. Model that output on every test platform. + await writeFile(join(bin, "ps"), `#!${process.execPath} + const fs = require("node:fs"); + const args = process.argv.slice(2); + const family = fs.existsSync(${JSON.stringify(familyFile)}) ? JSON.parse(fs.readFileSync(${JSON.stringify(familyFile)}, "utf8")) : {}; + if (args[0] === "eww" && Number(args[args.indexOf("-p") + 1]) === family.leader) { + console.log("npm run build"); + } else { + const result = require("node:child_process").spawnSync("/bin/ps", args, { encoding: "utf8" }); + process.stdout.write(result.stdout || ""); + process.stderr.write(result.stderr || ""); + process.exit(result.status ?? 1); + } + `, { mode: 0o700 }); + } if (mode === "spawn-failure") { await writeFile(join(bin, "npm"), "#!/spaceapp-test-nonexistent-interpreter\n", { mode: 0o700 }); } @@ -143,7 +163,7 @@ test("startup build ownership covers cancellation, success, and failure", { skip while (!existsSync(familyFile) && Date.now() < startedDeadline && supervisor.exitCode === null) await sleep(25); assert.equal(existsSync(familyFile), mode !== "spawn-failure", output); if (existsSync(familyFile)) family = JSON.parse(await readFile(familyFile, "utf8")); - if (mode === "cancel") supervisor.kill("SIGTERM"); + if (cancel) supervisor.kill("SIGTERM"); const stoppedDeadline = Date.now() + 10000; while (supervisor.exitCode === null && supervisor.signalCode === null && Date.now() < stoppedDeadline) await sleep(25); assert.notEqual(supervisor.exitCode ?? supervisor.signalCode, null, output); @@ -476,7 +496,7 @@ test("persisted process-group ownership cleans descendants after the leader exit } }); -test("live child ownership cleans environment-sanitized descendants without relaxing persisted recovery", { skip: process.platform === "win32" }, async () => { +test("registered renamed children clean sanitized descendants without relaxing persisted recovery", { skip: process.platform === "win32" }, async () => { const root = await mkdtemp(join(tmpdir(), "spaceapp-sanitized-descendant-")); const config = { rootHash: "abcdef0123", processFile: join(root, "process-groups.json") }; const owner = ownership(config.rootHash); @@ -486,11 +506,16 @@ test("live child ownership cleans environment-sanitized descendants without rela require("node:fs").writeFileSync(${JSON.stringify(readyFile)}, JSON.stringify({ pid: process.pid, token: process.env.SPACE_DEV_PROCESS_TOKEN })); setInterval(() => {}, 1000); `; - const leader = spawn(process.execPath, ["-e", ` + const leader = spawnOwnedProcess(owner, process.execPath, ["-e", ` + process.title = "npm exec tsx watch"; require("node:child_process").spawn(process.execPath, ["-e", ${JSON.stringify(descendantScript)}], { stdio: "ignore", env: {} }); setInterval(() => {}, 1000); - `], { detached: true, stdio: "ignore", env: { ...process.env, SPACE_DEV_PROCESS_TOKEN: owner.token } }); + `], { stdio: "ignore" }); + const unrelated = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { detached: true, stdio: "ignore" }); const record = { name: "sanitized-probe", pid: leader.pid, pgid: leader.pid, sid: leader.pid }; + const runner = (command, args, options) => args[0] === "eww" && Number(args[args.indexOf("-p") + 1]) === leader.pid + ? Promise.resolve({ code: 0, stdout: "npm exec tsx watch", stderr: "" }) + : run(command, args, options); try { await writeProcessRecords(config, owner, [record]); const deadline = Date.now() + 3000; @@ -499,21 +524,57 @@ test("live child ownership cleans environment-sanitized descendants without rela assert.equal(descendant.token, undefined); assert.equal(leader.exitCode, null); - await assert.rejects(() => terminateOwnedProcessGroups(config, owner), /does not carry this dev-stack ownership token/); - await assert.rejects(() => terminateOwnedProcessGroups(config, owner, { liveChildren: [leader] }), /does not carry this dev-stack ownership token/); - await assert.rejects(() => terminateOwnedProcessGroups(config, owner, { records: [record] }), /does not carry this dev-stack ownership token/); + await assert.rejects(() => terminateOwnedProcessGroups(config, owner, { runner }), /does not carry this dev-stack ownership token/); + await assert.rejects(() => terminateOwnedProcessGroups(config, owner, { liveChildren: [leader], runner }), /does not carry this dev-stack ownership token/); + await assert.rejects(() => terminateOwnedProcessGroups(config, owner, { records: [record], runner }), /does not carry this dev-stack ownership token/); assert.equal(existsSync(config.processFile), true); - await terminateOwnedProcessGroups(config, owner, { records: [record], liveChildren: [leader], timeoutMs: 100 }); + await terminateOwnedProcessGroups(config, owner, { records: [record], liveChildren: [leader], runner, timeoutMs: 100 }); const status = await run("ps", ["-p", String(descendant.pid), "-o", "stat="], { capture: true, allowFailure: true }); assert.ok(status.code !== 0 || status.stdout.startsWith("Z")); assert.notEqual(leader.signalCode, null); + assert.doesNotThrow(() => process.kill(unrelated.pid, 0), "an unrelated process must remain alive"); } finally { try { process.kill(-leader.pid, "SIGKILL"); } catch {} + try { process.kill(-unrelated.pid, "SIGKILL"); } catch {} await rm(root, { recursive: true, force: true }); } }); +test("spawn registration rejects spoofed handles, other tokens, reused identities, and exit races", { skip: process.platform === "win32" }, async (t) => { + const config = { rootHash: "abcdef0123" }; + const owner = ownership(config.rootHash); + const startedAt = "Sun Oct 18 07:40:00 2026"; + for (const scenario of ["spoofed handle", "other token", "reused identity", "exit during snapshot"]) await t.test(scenario, async () => { + const leader = spawnOwnedProcess(owner, process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }); + const record = { name: "registered-probe", pid: leader.pid, pgid: leader.pid, sid: leader.pid }; + const signals = []; + let inventories = 0; + try { + const runner = async (_command, args) => { + if (args[0] !== "-axo") return { code: 0, stdout: "npm", stderr: "" }; + inventories += 1; + if (scenario === "exit during snapshot" && inventories === 1) { + const exited = new Promise(resolvePromise => leader.once("exit", resolvePromise)); + leader.kill("SIGTERM"); + await exited; + } + const identity = scenario === "reused identity" && inventories > 1 ? "Sun Oct 18 07:41:00 2026" : startedAt; + return { code: 0, stdout: `${leader.pid} ${leader.pid} S ${identity}\n`, stderr: "" }; + }; + const child = scenario === "spoofed handle" ? { pid: leader.pid, exitCode: null, signalCode: null } : leader; + const candidateOwner = scenario === "other token" ? { ...owner, token: "b".repeat(48) } : owner; + await assert.rejects(() => terminateOwnedProcessGroups(config, candidateOwner, { + records: [record], liveChildren: [child], runner, timeoutMs: 0, + signaler: (...args) => signals.push(args) + }), /does not carry this dev-stack ownership token/); + assert.deepEqual(signals, []); + } finally { + try { process.kill(-leader.pid, "SIGKILL"); } catch {} + } + }); +}); + test("live group snapshots fail closed on stale leaders and changed process identities", { skip: process.platform === "win32" }, async (t) => { const config = { rootHash: "abcdef0123" }; const owner = ownership(config.rootHash); From 6b3f1b65d7937062449461d8e63ce4a311a47571 Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Sun, 18 Oct 2026 20:58:26 +0900 Subject: [PATCH 61/71] fix(dev): finish shutdown and verify source acceptance flows Keep shutdown signal handling idempotent through slow Docker cleanup. Add real API watch, browser HMR and worker failure/restart probes with durable source-edit recovery. Reviewed tree: 052e89ba4ec7acb0960aadf294cdeeb4963451a8 Node 22.23.0 dev-stack tests: 56 passed. --- .github/workflows/dev-stack.yml | 157 +++++++--- CONTRIBUTING.md | 11 + deploy/docker/dev-stack-hotreload.mjs | 346 ++++++++++++++++++++++ deploy/docker/dev-stack-probes.test.mjs | 163 ++++++++++ deploy/docker/dev-stack-test-fixtures.mjs | 5 + deploy/docker/dev-stack-verify.mjs | 4 +- deploy/docker/dev-stack-worker-probe.mjs | 224 ++++++++++++++ deploy/docker/dev-stack.mjs | 11 +- deploy/docker/dev-stack.test.mjs | 46 ++- package.json | 2 +- 10 files changed, 925 insertions(+), 44 deletions(-) create mode 100644 deploy/docker/dev-stack-hotreload.mjs create mode 100644 deploy/docker/dev-stack-probes.test.mjs create mode 100644 deploy/docker/dev-stack-test-fixtures.mjs create mode 100644 deploy/docker/dev-stack-worker-probe.mjs diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml index 770f3e324..825b970d1 100644 --- a/.github/workflows/dev-stack.yml +++ b/.github/workflows/dev-stack.yml @@ -50,7 +50,15 @@ jobs: shell: bash run: | set -euo pipefail - npm run dev:stack >"$RUNNER_TEMP/dev-stack.log" 2>&1 & + rm -f "$RUNNER_TEMP/dev-stack-initial.exit" + ( + set +e + npm run dev:stack >"$RUNNER_TEMP/dev-stack.log" 2>&1 + code=$? + printf '%s\n' "$code" >"$RUNNER_TEMP/dev-stack-initial.exit.tmp" + mv "$RUNNER_TEMP/dev-stack-initial.exit.tmp" "$RUNNER_TEMP/dev-stack-initial.exit" + exit "$code" + ) & echo $! >"$RUNNER_TEMP/dev-stack-shell.pid" echo "source stack launched in background" @@ -64,14 +72,27 @@ jobs: set -euo pipefail probe="dev_stack_rebuild_${GITHUB_RUN_ID}_${GITHUB_RUN_ATTEMPT}" before_pid="$(node -e 'const fs=require("node:fs"); process.stdout.write(String(JSON.parse(fs.readFileSync(".dev-stack/supervisor.pid","utf8")).pid))')" - printf '\nexport const __devStackRebuildProbe = "%s";\n' "$probe" >> packages/contracts/src/index.ts + export SPACE_DEV_EXPECTED_PACKAGE_PROBE="$probe" + echo "SPACE_DEV_EXPECTED_PACKAGE_PROBE=$probe" >> "$GITHUB_ENV" + git diff --exit-code -- packages/contracts/src/index.ts + node --input-type=module <<'NODE' + import { mkdtemp } from "node:fs/promises"; + import { join } from "node:path"; + import { editSource } from "./deploy/docker/dev-stack-hotreload.mjs"; + const backupRoot = await mkdtemp(join(process.env.RUNNER_TEMP, "dev-stack-source-backup-")); + const receiptRoot = join(process.env.RUNNER_TEMP, "dev-stack-source-edit-receipts"); + await editSource("packages/contracts/src/index.ts", source => + source + `\nexport const __devStackRebuildProbe = ${JSON.stringify(process.env.SPACE_DEV_EXPECTED_PACKAGE_PROBE)};\n`, + backupRoot, process.cwd(), receiptRoot); + NODE + api_url="$(node -e 'const fs=require("node:fs"); process.stdout.write(`http://127.0.0.1:${JSON.parse(fs.readFileSync(".dev-stack/config.json","utf8")).ports.api}`)')" npm run dev:stack -- rebuild rebuilt=0 for attempt in $(seq 1 240); do ready=0 - if curl -fsS "http://127.0.0.1:5910/readyz" | node -e ' + if curl -fsS "$api_url/readyz" | node -e ' let input=""; process.stdin.on("data", (chunk) => { input += chunk; }); process.stdin.on("end", () => { @@ -99,52 +120,122 @@ jobs: test "$before_pid" = "$after_pid" echo "shared-package rebuild was picked up by supervisor $after_pid" + - name: Verify API watch and real browser hot reload + timeout-minutes: 6 + env: + SPACE_DEV_ACCEPTANCE_MUTATIONS: "1" + run: node deploy/docker/dev-stack-hotreload.mjs --repo "$GITHUB_WORKSPACE" + + - name: Verify worker failure and explicit normal restart + timeout-minutes: 10 + env: + SPACE_DEV_ACCEPTANCE_MUTATIONS: "1" + SPACE_DEV_INITIAL_EXIT_FILE: ${{ runner.temp }}/dev-stack-initial.exit + run: node deploy/docker/dev-stack-worker-probe.mjs --repo "$GITHUB_WORKSPACE" + - name: Stop and reset owned resources if: always() shell: bash run: | set +e + node --input-type=module <<'NODE' + import { existsSync, readFileSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + import { resolveConfig } from "./deploy/docker/dev-stack-lib.mjs"; + const config = await resolveConfig(); + const records = existsSync(config.processFile) ? JSON.parse(readFileSync(config.processFile, "utf8")).groups : []; + writeFileSync(join(process.env.RUNNER_TEMP, "dev-stack-cleanup-inventory.json"), JSON.stringify({ + stateRoot: config.stateRoot, socketPath: config.socketPath, groups: records.map(record => record.pgid) + }), { mode: 0o600 }); + NODE + inventory_status=$? npm run dev:stack -- down down_status=$? npm run dev:stack -- reset reset_status=$? - shell_pid="$(cat "$RUNNER_TEMP/dev-stack-shell.pid" 2>/dev/null || true)" - attempt=0 - while [ "$attempt" -lt 50 ]; do - if [ -z "$shell_pid" ] || ! kill -0 "$shell_pid" 2>/dev/null; then - break + process_status=0 + for pid_file in dev-stack-shell.pid dev-stack-recovery-shell.pid; do + shell_pid="$(cat "$RUNNER_TEMP/$pid_file" 2>/dev/null || true)" + attempt=0 + while [ "$attempt" -lt 50 ] && [ -n "$shell_pid" ]; do + state="$(ps -p "$shell_pid" -o stat= 2>/dev/null | xargs)" + if [ -z "$state" ] || [[ "$state" = Z* ]]; then break; fi + attempt=$((attempt + 1)) + sleep 0.1 + done + if [ -n "$shell_pid" ] && [ -n "$state" ] && [[ "$state" != Z* ]]; then + echo "dev-stack launcher $shell_pid is still alive after reset" >&2 + process_status=1 fi - attempt=$((attempt + 1)) - sleep 0.1 done - process_status=0 - if [ -n "$shell_pid" ] && kill -0 "$shell_pid" 2>/dev/null; then - echo "dev-stack launcher process $shell_pid is still alive after reset" >&2 - process_status=1 - kill "$shell_pid" 2>/dev/null || true - fi - status_json="$(npm run --silent dev:stack -- status 2>/dev/null)" - echo "$status_json" - if echo "$status_json" | grep -q '"running": true'; then - echo "dev-stack supervisor still reports running after reset" >&2 - process_status=1 - fi - if ! echo "$status_json" | grep -q '"containers": \[\]'; then - echo "dev-stack containers remain after reset" >&2 - process_status=1 - fi - if ! echo "$status_json" | grep -q '"volumes": \[\]'; then - echo "dev-stack volumes remain after reset" >&2 - process_status=1 - fi + npm run --silent dev:stack -- status >"$RUNNER_TEMP/dev-stack-final-status.json" + status_status=$? + cat "$RUNNER_TEMP/dev-stack-final-status.json" + node --input-type=module <<'NODE' + import assert from "node:assert/strict"; + import { execFileSync } from "node:child_process"; + import { existsSync, readFileSync } from "node:fs"; + import { join } from "node:path"; + const inventory = JSON.parse(readFileSync(join(process.env.RUNNER_TEMP, "dev-stack-cleanup-inventory.json"), "utf8")); + const status = JSON.parse(readFileSync(join(process.env.RUNNER_TEMP, "dev-stack-final-status.json"), "utf8")); + assert.equal(status.running, false); + for (const type of ["containers", "volumes", "networks"]) assert.deepEqual(status.docker[type], []); + assert(!existsSync(inventory.stateRoot), "Owned state directory remains after reset."); + assert(!existsSync(inventory.socketPath), "Owned Unix socket remains after reset."); + const groups = new Set(inventory.groups); + for (const line of execFileSync("ps", ["-axo", "pid=,pgid=,stat="], { encoding: "utf8" }).trim().split(/\r?\n/)) { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)$/); + assert(match, "Could not parse final process inventory."); + assert(!groups.has(Number(match[2])) || match[3].startsWith("Z"), `Owned group ${match[2]} still contains live PID ${match[1]}.`); + } + console.log("PASS no owned processes, containers, volumes, networks, state or Unix socket remain"); + NODE + resource_status=$? + # Recover all source mutations, including interrupted probe processes, + # after services stop so normal worker recovery need not rebuild again. + node --input-type=module <<'NODE' + import assert from "node:assert/strict"; + import { existsSync, readFileSync, readdirSync } from "node:fs"; + import { basename, dirname, join, resolve } from "node:path"; + import { restoreSourceEdit } from "./deploy/docker/dev-stack-hotreload.mjs"; + const receiptRoot = join(process.env.RUNNER_TEMP, "dev-stack-source-edit-receipts"); + const sources = ["apps/api/src/health-routes.ts", "apps/web/src/features/auth/LoginScreen.tsx", "packages/contracts/src/index.ts"]; + for (const name of existsSync(receiptRoot) ? readdirSync(receiptRoot) : []) { + const source = sources.find(candidate => `${candidate.replaceAll("/", "__")}.json` === name); + assert(source, "Unexpected source recovery receipt; refusing overwrite."); + const file = join(receiptRoot, name); + const receipt = JSON.parse(readFileSync(file, "utf8")); + assert.equal(receipt.receiptFile, file); + assert.equal(receipt.path, resolve(source)); + assert.equal(dirname(dirname(receipt.backup)), process.env.RUNNER_TEMP); + assert(basename(dirname(receipt.backup)).startsWith("dev-stack-source-backup-")); + assert.equal(basename(receipt.backup), source.replaceAll("/", "__")); + await restoreSourceEdit(receipt); + console.log(`PASS interrupted source edit restored sha256=${receipt.originalHash}`); + } + NODE + restore_status=$? + git diff --exit-code -- packages/contracts/src/index.ts apps/api/src/health-routes.ts apps/web/src/features/auth/LoginScreen.tsx + diff_status=$? echo "--- dev-stack log ---" cat "$RUNNER_TEMP/dev-stack.log" 2>/dev/null || true - if [ "$down_status" -ne 0 ] || [ "$reset_status" -ne 0 ] || [ "$process_status" -ne 0 ]; then - exit 1 - fi + for result in "$inventory_status" "$down_status" "$reset_status" "$process_status" "$status_status" "$resource_status" "$restore_status" "$diff_status"; do + if [ "$result" -ne 0 ]; then exit 1; fi + done + + - name: Preserve limited acceptance evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dev-stack-linux-acceptance + if-no-files-found: ignore + retention-days: 14 + path: | + ${{ runner.temp }}/dev-stack-acceptance-evidence/hmr-*.png + ${{ runner.temp }}/dev-stack-acceptance-evidence/*-receipt.json source-stack-macos: name: Real source stack lifecycle (macOS, Colima) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b2f2bab7b..d7c60673b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -115,6 +115,17 @@ Docker engine running inside WSL2; that path follows the Linux host model. These host paths are contributor environments, not installer-release certification. +The Linux lifecycle job also edits and restores source files in its disposable +checkout: it checks API watch restarts, imports a rebuilt package marker through +the running API, and verifies Vite updates a real Chrome page without navigation +or losing form state. It then stops the owned worker, requires a clear failure +and nonzero supervisor exit, and exercises an explicit normal restart with the +same owner data. These mutation probes are CI-only and are not needed for normal +contributor startup. Their screenshots include only the anonymous login heading; +state directories, credentials, browser profiles and source backups are not +uploaded. The final cleanup restores original source bytes and checks owned +processes, containers, volumes, networks, state and the Unix socket are gone. + ### Troubleshooting the source stack - If Docker is unavailable, run `docker compose version` first and start Docker diff --git a/deploy/docker/dev-stack-hotreload.mjs b/deploy/docker/dev-stack-hotreload.mjs new file mode 100644 index 000000000..b9fcbeaf0 --- /dev/null +++ b/deploy/docker/dev-stack-hotreload.mjs @@ -0,0 +1,346 @@ +#!/usr/bin/env node +// Destructive source probes are restricted to an opted-in disposable Linux CI checkout. +import assert from "node:assert/strict"; +import { spawn, execFile } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { access, chmod, lstat, mkdir, mkdtemp, readFile, rename, rm, rmdir, writeFile } from "node:fs/promises"; +import { constants } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative, resolve, sep } from "node:path"; +import { pathToFileURL, fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const exec = promisify(execFile); +const pause = (ms) => new Promise((done) => setTimeout(done, ms)); +const hash = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const arg = process.argv.indexOf("--repo"); +const root = arg >= 0 && process.argv[arg + 1] ? resolve(process.argv[arg + 1]) : null; +const receipts = []; + +function checkRunner() { + assert(root, "Pass --repo with the disposable checkout path."); + assert.equal(process.env.GITHUB_ACTIONS, "true", "This mutation probe is restricted to an ephemeral GitHub Actions runner."); + assert.equal(process.env.RUNNER_OS, "Linux"); + assert.equal(process.env.SPACE_DEV_ACCEPTANCE_MUTATIONS, "1", "Explicit mutation opt-in is required."); + assert(process.env.RUNNER_TEMP, "RUNNER_TEMP is required."); + assert.notEqual(process.getuid?.(), 0, "Do not disable the browser sandbox to run this probe as root."); +} + +async function until(label, action, timeoutMs = 90_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const result = await action(); + if (result) return result; + await pause(200); + } + throw new Error(`${label} did not reach its asserted condition within ${timeoutMs}ms.`); +} + +async function json(url) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(5_000), cache: "no-store" }); + if (!response.ok) return null; + return await response.json(); + } catch { return null; } +} + +function ready(body) { + return body?.ok === true && ["database", "worker", "cliHost"].every((key) => body.dependencies?.[key] === "RUNNING"); +} + +export function replaceOnce(text, anchor, replacement) { + assert.equal(text.split(anchor).length, 2, "Source anchor must occur exactly once; inspect candidate drift."); + return text.replace(anchor, replacement); +} + +async function replaceFile(path, bytes, mode) { + const temporary = `${path}.dev-stack-probe-${randomUUID()}.tmp`; + try { + await writeFile(temporary, bytes, { flag: "wx", mode }); + await chmod(temporary, mode); + await rename(temporary, path); + } finally { await rm(temporary, { force: true }); } +} + +export async function restoreSourceEdit(receipt) { + const { path, backup, originalHash, editedHash, mode } = receipt; + const info = await lstat(path); + assert(info.isFile() && !info.isSymbolicLink(), "Refusing to restore a replaced non-regular source file."); + const backupInfo = await lstat(backup); + assert(backupInfo.isFile() && !backupInfo.isSymbolicLink(), "Source backup must remain a regular file."); + const original = await readFile(backup); + assert.equal(hash(original), originalHash, "Source backup checksum changed; refusing restoration."); + const currentHash = hash(await readFile(path)); + assert(currentHash === editedHash || currentHash === originalHash, `Concurrent modification of ${path}; original backup retained at ${backup}.`); + await replaceFile(path, original, mode); + assert.equal(hash(await readFile(path)), originalHash, "Original source bytes were not restored."); + if (receipt.receiptFile) await rm(receipt.receiptFile); + await rm(backup); +} + +export async function editSource(relativePath, transform, backupRoot, checkoutRoot = root, receiptRoot = null) { + const path = resolve(checkoutRoot, relativePath); + const rel = relative(resolve(checkoutRoot), path); + assert(rel && rel !== ".." && !rel.startsWith(`..${sep}`), "Source probe must stay inside its checkout."); + const info = await lstat(path); + assert(info.isFile() && !info.isSymbolicLink(), "Probe source must be a regular tracked file."); + const original = await readFile(path); + const edited = Buffer.from(transform(original.toString("utf8"))); + assert(!original.equals(edited), "Source probe must actually change bytes."); + const backup = join(backupRoot, relativePath.replaceAll("/", "__")); + await writeFile(backup, original, { flag: "wx", mode: 0o600 }); + assert((await readFile(path)).equals(original), "Source changed while preparing its backup."); + const receipt = { path, backup, originalHash: hash(original), editedHash: hash(edited), mode: info.mode & 0o777 }; + // Persist before changing source so the workflow's always-cleanup can recover + // even if timeout/SIGTERM prevents this process's finally blocks from running. + if (receiptRoot) { + await mkdir(receiptRoot, { recursive: true, mode: 0o700 }); + receipt.receiptFile = join(receiptRoot, `${relativePath.replaceAll("/", "__")}.json`); + await writeFile(receipt.receiptFile, JSON.stringify(receipt), { flag: "wx", mode: 0o600 }); + } + await replaceFile(path, edited, receipt.mode); + let restored = false; + return { + ...receipt, + async restore() { + if (restored) return; + await restoreSourceEdit(receipt); + restored = true; + } + }; +} + +export class PipeCdp { + constructor(child) { + this.child = child; + this.nextId = 1; + this.pending = new Map(); + this.handlers = new Set(); + this.closed = false; + let buffered = Buffer.alloc(0); + child.stdio[4].on("data", (chunk) => { + buffered = Buffer.concat([buffered, chunk]); + for (let boundary; (boundary = buffered.indexOf(0)) >= 0;) { + const raw = buffered.subarray(0, boundary); + buffered = buffered.subarray(boundary + 1); + if (!raw.length) continue; + const message = JSON.parse(raw.toString("utf8")); + if (message.id) { + const entry = this.pending.get(message.id); + if (!entry) continue; + clearTimeout(entry.timer); + this.pending.delete(message.id); + if (message.error) entry.reject(new Error(`CDP ${entry.method} failed (code ${message.error.code}).`)); + else entry.resolve(message.result); + } else for (const handler of this.handlers) handler(message); + } + }); + const fail = () => { + this.closed = true; + for (const entry of this.pending.values()) { + clearTimeout(entry.timer); + entry.reject(new Error("The isolated browser closed before completing a CDP command.")); + } + this.pending.clear(); + }; + child.once("error", fail); + child.once("exit", fail); + child.stdio[3].on("error", fail); + } + call(method, params = {}, sessionId) { + if (this.closed) return Promise.reject(new Error("The isolated browser is already closed.")); + const id = this.nextId++; + return new Promise((resolvePromise, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`CDP ${method} timed out.`)); + }, 15_000); + this.pending.set(id, { resolve: resolvePromise, reject, timer, method }); + this.child.stdio[3].write(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }) + "\0"); + }); + } + async evaluate(expression, sessionId) { + const response = await this.call("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }, sessionId); + assert(!response.exceptionDetails, "Browser evaluation failed; inspect the candidate without dumping authenticated page content."); + return response.result?.value; + } +} + +async function findChrome() { + const candidates = process.env.CHROME_BIN ? [process.env.CHROME_BIN] : ["/usr/bin/google-chrome", "/usr/bin/chromium"]; + for (const path of candidates) { + try { await access(path, constants.X_OK); return path; } catch {} + } + throw new Error("No existing Chrome binary found. Browser-visible HMR is unverified; do not replace this with a server-only PASS."); +} + +async function proveHmr(web, backupRoot, evidenceRoot, receiptRoot) { + const chrome = await findChrome(); + const profile = await mkdtemp(join(tmpdir(), "spaceapp-dev-hmr-")); + const child = spawn(chrome, ["--headless=new", "--remote-debugging-pipe", `--user-data-dir=${profile}`, "--window-size=1280,900", "--no-first-run", "--no-default-browser-check", "--disable-background-networking", "--disable-component-update", "--disable-sync", "--disable-extensions", "--disable-default-apps", "--disable-dev-shm-usage", "about:blank"], { + // No inherited vault/provider credentials. Chromium keeps its normal sandbox. + env: { ...Object.fromEntries(["PATH", "LANG", "LC_ALL", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"].filter((key) => process.env[key]).map((key) => [key, process.env[key]])), HOME: profile, XDG_CONFIG_HOME: join(profile, "config"), XDG_CACHE_HOME: join(profile, "cache") }, + stdio: ["ignore", "ignore", "pipe", "pipe", "pipe"] + }); + let diagnostic = ""; + child.stderr.on("data", bytes => { diagnostic = (diagnostic + bytes.toString()).slice(-4000); }); + const cdp = new PipeCdp(child); + let edit; + let session; + let navigationCount = 0; + const hmrFrames = []; + try { + const version = await cdp.call("Browser.getVersion"); + const target = await cdp.call("Target.createTarget", { url: "about:blank" }); + session = (await cdp.call("Target.attachToTarget", { targetId: target.targetId, flatten: true })).sessionId; + cdp.handlers.add((message) => { + if (message.sessionId !== session) return; + if (message.method === "Page.frameNavigated" && !message.params?.frame?.parentId) navigationCount++; + if (message.method !== "Network.webSocketFrameReceived") return; + try { + const payload = JSON.parse(message.params.response.payloadData); + if (["update", "full-reload", "error"].includes(payload.type)) hmrFrames.push({ type: payload.type, paths: (payload.updates || []).map((entry) => entry.path) }); + } catch {} + }); + await cdp.call("Page.enable", {}, session); + await cdp.call("Runtime.enable", {}, session); + await cdp.call("Network.enable", {}, session); + // The anonymous page is allowed to load only this isolated stack. + await cdp.call("Fetch.enable", { patterns: [{ urlPattern: "*" }] }, session); + const allowed = new Set([web, new URL(web).origin]); + cdp.handlers.add((message) => { + if (message.sessionId !== session || message.method !== "Fetch.requestPaused") return; + const { requestId, request } = message.params; + const permitted = request.url.startsWith("data:") || request.url.startsWith("blob:") || allowed.has(new URL(request.url).origin); + void cdp.call(permitted ? "Fetch.continueRequest" : "Fetch.failRequest", permitted ? { requestId } : { requestId, errorReason: "BlockedByClient" }, session).catch(() => {}); + }); + await cdp.call("Page.navigate", { url: web }, session); + const view = () => cdp.evaluate(`(() => { + const brand = document.querySelector('.login-brand'); + const field = document.querySelector('#operator-email'); + const box = brand?.getBoundingClientRect(); + return { label: brand?.innerText || '', email: field?.value || '', nonce: window.__devStackHmrNonce, + timeOrigin: performance.timeOrigin, visible: !!box && box.width > 0 && box.height > 0 && getComputedStyle(brand).visibility !== 'hidden' }; + })()`, session); + await until("Real login screen", async () => { const state = await view(); return state.visible && state.label.includes("Sign in to Space") && state; }); + const nonce = randomUUID(); + const email = `hmr-state-${nonce}@example.invalid`; + await cdp.evaluate(`window.__devStackHmrNonce = ${JSON.stringify(nonce)}; document.querySelector('#operator-email').focus();`, session); + await cdp.call("Input.insertText", { text: email }, session); + const before = await view(); + assert.equal(before.email, email); + const baselineNavigations = navigationCount; + const frameStart = hmrFrames.length; + const screenshot = async (name) => { + const clip = await cdp.evaluate(`(() => { const b=document.querySelector('.login-brand').getBoundingClientRect(); return {x:b.x,y:b.y,width:b.width,height:b.height,scale:1}; })()`, session); + const result = await cdp.call("Page.captureScreenshot", { format: "png", clip, captureBeyondViewport: false }, session); + await writeFile(join(evidenceRoot, name), Buffer.from(result.data, "base64"), { mode: 0o600 }); + }; + await screenshot("hmr-before.png"); + const marker = `hmr-${randomUUID()}`; + edit = await editSource("apps/web/src/features/auth/LoginScreen.tsx", (text) => replaceOnce(text, '"Sign in to Space"', JSON.stringify(`Sign in to Space ${marker}`)), backupRoot, root, receiptRoot); + await until("Browser DOM hot update", async () => { const state = await view(); return state.visible && state.label.includes(marker) && state; }); + const assertSamePage = async () => { + const state = await view(); + assert.equal(state.nonce, nonce, "A full page reload cannot satisfy HMR."); + assert.equal(state.timeOrigin, before.timeOrigin); + assert.equal(navigationCount, baselineNavigations); + assert.equal(state.email, email, "React Refresh must preserve controlled input state."); + }; + await assertSamePage(); + const changedFrames = hmrFrames.slice(frameStart); + assert(changedFrames.some((frame) => frame.type === "update" && frame.paths.some((path) => /\/LoginScreen\.(tsx|js)(\?|$)/.test(path))), "No matching real Vite-client update frame was observed."); + assert(!changedFrames.some((frame) => ["error", "full-reload"].includes(frame.type)), "Vite error/full reload is not HMR success."); + await screenshot("hmr-after.png"); + await edit.restore(); + await until("Browser reverse hot update", async () => { const state = await view(); return state.visible && state.label.includes("Sign in to Space") && !state.label.includes(marker); }); + await assertSamePage(); + await screenshot("hmr-restored.png"); + receipts.push({ probe: "real-browser-vite-hmr", browser: version.product, sameDocument: true, inputStateRetained: true, reverseUpdateObserved: true, sourceRestoredHash: edit.originalHash }); + console.log("[dev-stack-change-probes] PASS real browser DOM HMR, same document and state, reverse HMR, original bytes restored"); + } catch (error) { + throw new Error(`${error.message}${diagnostic ? ` Browser diagnostic: ${diagnostic.trim()}` : ""}`); + } finally { + try { await edit?.restore(); } + finally { + await cdp.call("Browser.close").catch(() => {}); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); + await until("Isolated browser exit", () => child.exitCode !== null || child.signalCode !== null, 10_000); + await rm(profile, { recursive: true, force: true }); + } + } +} + +async function main() { + checkRunner(); + const evidenceRoot = resolve(process.env.SPACE_DEV_EVIDENCE_DIR || join(process.env.RUNNER_TEMP, "dev-stack-acceptance-evidence")); + const backupRoot = await mkdtemp(join(process.env.RUNNER_TEMP, "dev-stack-source-backup-")); + const receiptRoot = join(process.env.RUNNER_TEMP, "dev-stack-source-edit-receipts"); + await mkdir(evidenceRoot, { recursive: true, mode: 0o700 }); + const git = async (...args) => (await exec("git", args, { cwd: root })).stdout.trim(); + const sha = await git("rev-parse", "HEAD"); + const baselineStatus = await git("status", "--porcelain=v1"); + await git("diff", "--exit-code", "--", "apps/api/src/health-routes.ts", "apps/web/src/features/auth/LoginScreen.tsx"); + const lib = await import(pathToFileURL(join(root, "deploy/docker/dev-stack-lib.mjs")).href); + const safety = await import(pathToFileURL(join(root, "deploy/docker/dev-stack-safety.mjs")).href); + safety.assertSafeDevStackOverrides(); + const config = await lib.resolveConfig(process.env, root); + const persistedConfig = JSON.parse(await readFile(config.configFile, "utf8")); + for (const key of ["root", "stateRoot", "project", "ports"]) assert.deepEqual(persistedConfig[key], config[key], `Running config ${key} differs from this checkout's selected configuration.`); + const ownership = await safety.readStateOwnership(config); + const api = `http://127.0.0.1:${config.ports.api}`; + const web = `http://127.0.0.1:${config.ports.web}`; + const groups = async () => (await safety.readProcessRecords(config, ownership)).filter((entry) => ["api", "web", "worker", "cli-host"].includes(entry.name)).sort((a, b) => a.name.localeCompare(b.name)); + const supervisor = await safety.ownedSupervisorPid(config); + assert(supervisor, "A verified source-stack supervisor must already be running."); + const baselineGroups = await groups(); + assert.equal(baselineGroups.length, 4); + const initial = await until("Initial complete readiness", async () => { const body = await json(`${api}/readyz`); return ready(body) && body; }); + const marker = `api-watch-${randomUUID()}`; + const expectedPackageProbe = process.env.SPACE_DEV_EXPECTED_PACKAGE_PROBE || null; + if (expectedPackageProbe) assert(/^dev_stack_rebuild_[A-Za-z0-9_]+$/.test(expectedPackageProbe), "Unexpected package-probe marker format."); + let edit; + let observedStart; + let passed = false; + try { + try { + edit = await editSource("apps/api/src/health-routes.ts", (text) => { + const updated = replaceOnce(text, + 'async () => ({ ok: true, service: "space-api" })', + `async () => ({ ok: true, service: "space-api", devStackWatchProbe: ${JSON.stringify(marker)}, devStackWatchPid: process.pid${expectedPackageProbe ? ", devStackPackageProbe: __devStackRebuildProbe" : ""} })`); + return expectedPackageProbe ? 'import { __devStackRebuildProbe } from "@space/contracts";\n' + updated : updated; + }, backupRoot, root, receiptRoot); + await until("API source watch marker", async () => { + const [health, body] = await Promise.all([json(`${api}/healthz`), json(`${api}/readyz`)]); + if (health?.devStackWatchProbe === marker && (!expectedPackageProbe || health.devStackPackageProbe === expectedPackageProbe) && Number.isInteger(health.devStackWatchPid) && health.devStackWatchPid > 1 && ready(body) && body.apiStartedAt !== initial.apiStartedAt) { + observedStart = body.apiStartedAt; + return true; + } + return false; + }); + assert.equal(await safety.ownedSupervisorPid(config), supervisor); + assert.deepEqual(await groups(), baselineGroups, "API watch must not restart the source-stack group leaders."); + } finally { await edit?.restore(); } + await until("API restoration watch", async () => { + const [health, body] = await Promise.all([json(`${api}/healthz`), json(`${api}/readyz`)]); + return health?.ok === true && !Object.hasOwn(health, "devStackWatchProbe") && ready(body) && body.apiStartedAt !== observedStart; + }); + assert.equal(await safety.ownedSupervisorPid(config), supervisor); + assert.deepEqual(await groups(), baselineGroups); + receipts.push({ probe: "api-source-watch", markerObserved: true, restartObserved: true, reverseRestartObserved: true, sourceRestoredHash: edit.originalHash }); + if (expectedPackageProbe) receipts.push({ probe: "shared-package-runtime-import", importedMarkerObservedOverHttp: true }); + console.log("[dev-stack-change-probes] PASS API source watch restart and restoration, same stack supervisor and service leaders"); + await proveHmr(web, backupRoot, evidenceRoot, receiptRoot); + assert.equal(await git("rev-parse", "HEAD"), sha); + assert.equal(await git("status", "--porcelain=v1"), baselineStatus, "Probe must leave candidate status exactly as found."); + passed = true; + } finally { + await writeFile(join(evidenceRoot, "hotreload-receipt.json"), JSON.stringify({ candidateSha: sha, passed, receipts }, null, 2) + "\n", { mode: 0o600 }); + // A nonempty backup directory is intentionally retained for safe manual recovery. + await rmdir(backupRoot).catch(() => {}); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { console.error(`[dev-stack-change-probes] FAIL ${error.message}`); process.exitCode = 1; }); +} diff --git a/deploy/docker/dev-stack-probes.test.mjs b/deploy/docker/dev-stack-probes.test.mjs new file mode 100644 index 000000000..bc6e0110a --- /dev/null +++ b/deploy/docker/dev-stack-probes.test.mjs @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { EventEmitter, once } from "node:events"; +import { existsSync } from "node:fs"; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import test from "node:test"; +import { editSource, PipeCdp, replaceOnce, restoreSourceEdit } from "./dev-stack-hotreload.mjs"; +import { killOwnedWorkerWithPidfd } from "./dev-stack-worker-probe.mjs"; + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "spaceapp-probe-test-")); + const backupRoot = join(root, "backup"); + await mkdir(backupRoot); + const path = join(root, "source.ts"); + await writeFile(path, "// before\r\nexport const unicode = 'こんにちは';\r\n", { mode: 0o640 }); + await chmod(path, 0o640); + return { root, path, backupRoot }; +} + +test("source anchors fail closed on missing or ambiguous matches", () => { + assert.equal(replaceOnce("before", "before", "after"), "after"); + assert.throws(() => replaceOnce("absent", "before", "after"), /exactly once/); + assert.throws(() => replaceOnce("before before", "before", "after"), /exactly once/); +}); + +test("source edits restore exact bytes and permissions across serialized receipts", async () => { + const { root, path, backupRoot } = await fixture(); + try { + const original = await readFile(path); + const edit = await editSource("source.ts", text => text.replace("before", "after"), backupRoot, root); + assert.match(await readFile(path, "utf8"), /after/); + assert.equal((await stat(edit.backup)).mode & 0o777, 0o600); + assert.equal((await stat(path)).mode & 0o777, 0o640); + await restoreSourceEdit(JSON.parse(JSON.stringify(edit))); + assert.deepEqual(await readFile(path), original); + assert.equal((await stat(path)).mode & 0o777, 0o640); + assert.equal(existsSync(edit.backup), false); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("restoration preserves concurrent edits and retains its recovery backup", async () => { + const { root, path, backupRoot } = await fixture(); + try { + const original = await readFile(path); + const edit = await editSource("source.ts", text => text.replace("before", "after"), backupRoot, root); + const edited = await readFile(path); + await writeFile(path, "// concurrent edit\n"); + await assert.rejects(() => edit.restore(), /Concurrent modification/); + assert.equal(await readFile(path, "utf8"), "// concurrent edit\n"); + assert.deepEqual(await readFile(edit.backup), original); + await writeFile(path, edited); + await edit.restore(); + await edit.restore(); + assert.deepEqual(await readFile(path), original); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("durable source receipts recover an interrupted editor without its finally block", { timeout: 5000 }, async () => { + const { root, path, backupRoot } = await fixture(); + const receiptRoot = join(root, "receipts"); + let editor; + try { + const original = await readFile(path); + const script = join(root, "editor.mjs"); + await writeFile(script, ` + import { editSource } from ${JSON.stringify(new URL("./dev-stack-hotreload.mjs", import.meta.url).href)}; + await editSource("source.ts", text => text.replace("before", "after"), ${JSON.stringify(backupRoot)}, ${JSON.stringify(root)}, ${JSON.stringify(receiptRoot)}); + process.send("edited"); + setInterval(() => {}, 1000); + `); + editor = spawn(process.execPath, [script], { stdio: ["ignore", "ignore", "ignore", "ipc"] }); + await once(editor, "message"); + const exited = once(editor, "exit"); + editor.kill("SIGTERM"); + await exited; + assert.equal(editor.signalCode, "SIGTERM"); + assert.match(await readFile(path, "utf8"), /after/); + const receiptFile = join(receiptRoot, "source.ts.json"); + const receipt = JSON.parse(await readFile(receiptFile, "utf8")); + assert.equal((await stat(receiptFile)).mode & 0o777, 0o600); + await restoreSourceEdit(receipt); + assert.deepEqual(await readFile(path), original); + assert.equal(existsSync(receiptFile), false); + assert.equal(existsSync(receipt.backup), false); + } finally { + if (editor?.exitCode === null && editor?.signalCode === null) editor.kill("SIGKILL"); + await rm(root, { recursive: true, force: true }); + } +}); + +test("tampered backups and non-regular source targets cannot be restored", async () => { + const { root, path, backupRoot } = await fixture(); + try { + const edit = await editSource("source.ts", text => text.replace("before", "after"), backupRoot, root); + await writeFile(edit.backup, "tampered"); + await assert.rejects(() => edit.restore(), /checksum changed/); + assert.match(await readFile(path, "utf8"), /after/); + await rm(path); + await symlink(edit.backup, path); + await assert.rejects(() => edit.restore(), /non-regular/); + await assert.rejects(() => editSource("source.ts", text => text + "x", backupRoot, root), /regular tracked file/); + await assert.rejects(() => editSource("../outside.ts", text => text, backupRoot, root), /inside its checkout/); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("CDP pipe handles fragmented and out-of-order responses and rejects closed browsers", async () => { + const child = new EventEmitter(); + child.stdio = [null, null, null, new PassThrough(), new PassThrough()]; + const requests = []; + child.stdio[3].on("data", bytes => requests.push(JSON.parse(bytes.toString().replace(/\0$/, "")))); + const cdp = new PipeCdp(child); + const first = cdp.call("Browser.getVersion"); + const second = cdp.evaluate("1 + 1", "session-id"); + assert.equal(requests[1].sessionId, "session-id"); + const response = JSON.stringify({ id: requests[1].id, result: { result: { value: 2 } } }) + "\0"; + child.stdio[4].write(response.slice(0, 7)); + child.stdio[4].write(response.slice(7) + JSON.stringify({ id: requests[0].id, result: { product: "fixture" } }) + "\0"); + assert.equal(await second, 2); + assert.deepEqual(await first, { product: "fixture" }); + const error = cdp.call("Unknown.command"); + child.stdio[4].write(JSON.stringify({ id: requests[2].id, error: { code: -32601 } }) + "\0"); + await assert.rejects(() => error, /CDP Unknown.command failed/); + const pending = cdp.call("Page.enable"); + child.emit("exit", 1); + await assert.rejects(() => pending, /isolated browser closed/); + assert.equal(cdp.pending.size, 0); + await assert.rejects(() => cdp.call("Browser.close"), /already closed/); + child.stdio[3].destroy(); + child.stdio[4].destroy(); +}); + +test("worker pidfd fault injection signals only the exact owned worker", { skip: process.platform !== "linux" }, async (t) => { + for (const scenario of ["owned", "wrong token", "wrong group", "wrong argv"]) await t.test(scenario, async () => { + const root = await mkdtemp(join(tmpdir(), "spaceapp-worker-probe-")); + let child; + try { + await mkdir(join(root, "apps/worker/dist"), { recursive: true }); + await writeFile(join(root, "apps/worker/dist/worker.js"), "setInterval(() => {}, 1000);\n"); + const token = "a".repeat(48); + child = spawn(process.execPath, [scenario === "wrong argv" ? "./apps/worker/dist/worker.js" : "apps/worker/dist/worker.js"], { + cwd: root, detached: true, stdio: "ignore", env: { ...process.env, SPACE_DEV_PROCESS_TOKEN: token } + }); + const exited = once(child, "exit"); + const record = { pid: child.pid, pgid: scenario === "wrong group" ? child.pid + 1 : child.pid, sid: child.pid }; + if (scenario === "owned") { + await killOwnedWorkerWithPidfd(record, token); + await exited; + assert.equal(child.signalCode, "SIGKILL"); + } else { + await assert.rejects(() => killOwnedWorkerWithPidfd(record, scenario === "wrong token" ? "b".repeat(48) : token), /no fallback attempted/); + assert.doesNotThrow(() => process.kill(child.pid, 0)); + child.kill("SIGKILL"); + await exited; + } + } finally { + if (child?.exitCode === null && child?.signalCode === null) child.kill("SIGKILL"); + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/deploy/docker/dev-stack-test-fixtures.mjs b/deploy/docker/dev-stack-test-fixtures.mjs new file mode 100644 index 000000000..b17f54213 --- /dev/null +++ b/deploy/docker/dev-stack-test-fixtures.mjs @@ -0,0 +1,5 @@ +// Synthetic credentials used only by disposable dev-stack acceptance fixtures. +export const devStackTestOwner = Object.freeze({ + email: "dev-stack-ci@example.com", + password: "dev-stack-ci-password-42!" +}); diff --git a/deploy/docker/dev-stack-verify.mjs b/deploy/docker/dev-stack-verify.mjs index 7fa952ece..c605e2b58 100644 --- a/deploy/docker/dev-stack-verify.mjs +++ b/deploy/docker/dev-stack-verify.mjs @@ -4,6 +4,7 @@ import { readFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import WebSocket from "ws"; +import { devStackTestOwner } from "./dev-stack-test-fixtures.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const stateRoot = resolve(process.env.SPACE_DEV_STACK_DIR || join(root, ".dev-stack")); @@ -77,8 +78,7 @@ const ready = await waitReady(); console.log(`[dev-stack-verify] PASS readiness database=${ready.dependencies.database} worker=${ready.dependencies.worker} cliHost=${ready.dependencies.cliHost}`); const setupToken = (await readFile(join(stateRoot, "secrets", "setup-token"), "utf8")).trim(); -const email = "dev-stack-ci@example.com"; -const password = "dev-stack-ci-password-42!"; +const { email, password } = devStackTestOwner; const claimed = await request("/api/setup/claim", { method: "POST", body: { token: setupToken, email, password } diff --git a/deploy/docker/dev-stack-worker-probe.mjs b/deploy/docker/dev-stack-worker-probe.mjs new file mode 100644 index 000000000..c1a9543d5 --- /dev/null +++ b/deploy/docker/dev-stack-worker-probe.mjs @@ -0,0 +1,224 @@ +#!/usr/bin/env node +// Inject one verified worker failure in opted-in Linux CI, then exercise normal restart. +import assert from "node:assert/strict"; +import { spawn, execFile } from "node:child_process"; +import { lstat, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { connect } from "node:net"; +import { join, resolve } from "node:path"; +import { pathToFileURL, fileURLToPath } from "node:url"; +import { devStackTestOwner } from "./dev-stack-test-fixtures.mjs"; +import { promisify } from "node:util"; + +const exec = promisify(execFile); +const pause = (ms) => new Promise((done) => setTimeout(done, ms)); +const arg = process.argv.indexOf("--repo"); +const root = arg >= 0 && process.argv[arg + 1] ? resolve(process.argv[arg + 1]) : null; + +async function until(label, action, timeoutMs = 90_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const result = await action(); + if (result) return result; + await pause(200); + } + throw new Error(`${label} did not reach its asserted condition within ${timeoutMs}ms.`); +} + +async function optionalText(path) { + try { return await readFile(path, "utf8"); } + catch (error) { if (error.code === "ENOENT") return null; throw error; } +} + +function ready(body) { + return body?.ok === true && ["database", "worker", "cliHost"].every((key) => body.dependencies?.[key] === "RUNNING"); +} + +async function request(base, path, { method = "GET", body, cookie } = {}) { + const response = await fetch(base + path, { + method, signal: AbortSignal.timeout(5_000), cache: "no-store", + headers: { accept: "application/json", ...(body ? { "content-type": "application/json" } : {}), ...(cookie ? { cookie } : {}) }, + body: body ? JSON.stringify(body) : undefined + }); + assert(response.ok, `Local fixture request ${method} ${path} failed with HTTP ${response.status}.`); + return { response, payload: await response.json() }; +} + +async function readiness(base) { + try { const { payload } = await request(base, "/readyz"); return ready(payload) && payload; } + catch { return false; } +} + +async function loginAndRooms(base) { + const { response, payload } = await request(base, "/api/auth/login", { + method: "POST", body: devStackTestOwner + }); + assert.equal(payload.isAuthenticated, true); + assert.equal(payload.user?.role, "ADMIN"); + assert.equal(typeof payload.user?.id, "string"); + const cookies = response.headers.getSetCookie?.() || []; + const cookie = (cookies[0] || response.headers.get("set-cookie") || "").split(";", 1)[0]; + assert(cookie, "Ordinary owner login must return a session cookie."); + const rooms = (await request(base, "/api/rooms", { cookie })).payload; + const list = Array.isArray(rooms) ? rooms : rooms?.data; + assert(Array.isArray(list) && list.length > 0); + return { ownerId: payload.user.id, roomIds: list.map((room) => room.id).sort() }; +} + +async function liveMembers(groupIds) { + const result = await exec("ps", ["-axo", "pid=,pgid=,stat="], { timeout: 5_000 }); + return result.stdout.split(/\r?\n/).filter(Boolean).map((line) => { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)$/); + assert(match, "Unexpected process inventory format; refusing to infer cleanup."); + return { pid: Number(match[1]), pgid: Number(match[2]), state: match[3] }; + }).filter((entry) => groupIds.has(entry.pgid) && !entry.state.startsWith("Z")); +} + +async function portClosed(port) { + return new Promise((resolvePromise, reject) => { + const socket = connect({ host: "127.0.0.1", port }); + socket.setTimeout(2_000); + socket.once("connect", () => { socket.destroy(); resolvePromise(false); }); + socket.once("error", (error) => { socket.destroy(); error.code === "ECONNREFUSED" ? resolvePromise(true) : reject(new Error("Cannot establish whether the owned loopback API port closed.")); }); + socket.once("timeout", () => { socket.destroy(); reject(new Error("API port connection timed out; a silent hang is not proof of shutdown.")); }); + }); +} + +export async function killOwnedWorkerWithPidfd(record, token) { + // pidfd anchors the exact process even if its numeric PID is reused between checks. + // Token travels over stdin, never a shell argument or a printed environment. + const code = `import json, os, signal, sys +data = json.load(sys.stdin) +pid = data['pid'] +if not hasattr(os, 'pidfd_open') or not hasattr(signal, 'pidfd_send_signal'): + raise SystemExit('Linux pidfd support is required; no broad-kill fallback is permitted') +fd = os.pidfd_open(pid, 0) +try: + env = open('/proc/%d/environ' % pid, 'rb').read().split(b'\\0') + command = open('/proc/%d/cmdline' % pid, 'rb').read().split(b'\\0') + expected = ('SPACE_DEV_PROCESS_TOKEN=' + data['token']).encode() + if expected not in env or len(command) < 2 or command[1] != b'apps/worker/dist/worker.js': + raise SystemExit('Recorded worker identity does not match this owned source stack') + if os.getpgid(pid) != data['pgid'] or os.getsid(pid) != data['sid']: + raise SystemExit('Recorded worker process group/session no longer matches') + signal.pidfd_send_signal(fd, signal.SIGKILL, None, 0) +finally: + os.close(fd) +`; + await new Promise((resolvePromise, reject) => { + const child = spawn("python3", ["-c", code], { stdio: ["pipe", "ignore", "pipe"] }); + const timer = setTimeout(() => { child.kill("SIGKILL"); reject(new Error("Owned-worker pidfd helper timed out; no fallback attempted.")); }, 10_000); + let stderr = ""; + child.stderr.on("data", (chunk) => { if (stderr.length < 2_000) stderr += chunk.toString(); }); + child.once("error", (error) => { clearTimeout(timer); reject(error); }); + child.once("close", (code) => { clearTimeout(timer); code === 0 ? resolvePromise() : reject(new Error(`Owned-worker pidfd injection failed (exit ${code}); no fallback attempted. ${stderr.trim()}`)); }); + child.stdin.on("error", () => {}); + child.stdin.end(JSON.stringify({ pid: record.pid, pgid: record.pgid, sid: record.sid, token })); + }); +} + +async function main() { + assert(root, "Pass --repo with the disposable checkout path."); + assert.equal(process.env.GITHUB_ACTIONS, "true"); + assert.equal(process.env.RUNNER_OS, "Linux"); + assert.equal(process.env.SPACE_DEV_ACCEPTANCE_MUTATIONS, "1"); + assert(process.env.RUNNER_TEMP); + const initialExitFile = process.env.SPACE_DEV_INITIAL_EXIT_FILE; + assert(initialExitFile, "The initial npm-launcher exit-status witness is required."); + assert.equal(await optionalText(initialExitFile), null, "Initial stack has already exited; do not inject a fault into a failed setup."); + const log = resolve(process.env.SPACE_DEV_STACK_LOG || join(process.env.RUNNER_TEMP, "dev-stack.log")); + const evidenceRoot = resolve(process.env.SPACE_DEV_EVIDENCE_DIR || join(process.env.RUNNER_TEMP, "dev-stack-acceptance-evidence")); + await mkdir(evidenceRoot, { recursive: true, mode: 0o700 }); + const git = async (...args) => (await exec("git", args, { cwd: root })).stdout.trim(); + const sha = await git("rev-parse", "HEAD"); + const baselineStatus = await git("status", "--porcelain=v1"); + const lib = await import(pathToFileURL(join(root, "deploy/docker/dev-stack-lib.mjs")).href); + const safety = await import(pathToFileURL(join(root, "deploy/docker/dev-stack-safety.mjs")).href); + safety.assertSafeDevStackOverrides(); + const config = await lib.resolveConfig(process.env, root); + const persistedConfig = JSON.parse(await readFile(config.configFile, "utf8")); + for (const key of ["root", "stateRoot", "project", "ports"]) assert.deepEqual(persistedConfig[key], config[key], `Running config ${key} differs from this checkout's selected configuration.`); + assert((await lstat(config.stateRoot)).isDirectory()); + assert(!(await lstat(config.stateRoot)).isSymbolicLink()); + const ownership = await safety.readStateOwnership(config); + const supervisor = await safety.ownedSupervisorPid(config); + assert(supervisor, "A verified live source supervisor is required."); + const records = await safety.readProcessRecords(config, ownership); + const workers = records.filter((entry) => entry.name === "worker"); + assert.equal(workers.length, 1); + const worker = workers[0]; + const groupIds = new Set(records.map((entry) => entry.pgid)); + const api = `http://127.0.0.1:${config.ports.api}`; + const initialReady = await until("Initial complete readiness", () => readiness(api)); + const owner = await loginAndRooms(api); + const resources = await lib.dockerResources(config, ownership); + assert(resources.containers.length >= 2 && resources.volumes.length > 0); + const logOffset = (await stat(log)).size; + const receipt = { candidateSha: sha, passed: false, injected: false, clearFailureObserved: false, nonzeroExitObserved: false, failFastCleanupObserved: false, explicitRestartReady: false, originalOwnerAndRoomsPreserved: false }; + let recovery; + let recoveryError; + try { + await killOwnedWorkerWithPidfd(worker, ownership.token); + receipt.injected = true; + const suffix = async () => (await readFile(log)).subarray(logOffset).toString("utf8"); + await until("Explicit new worker-failure diagnostic", async () => (await suffix()).includes("worker exited unexpectedly (SIGKILL); stopping the stack.")); + receipt.clearFailureObserved = true; + const exitStatus = await until("Nonzero original npm-launcher exit witness", async () => { + const raw = await optionalText(initialExitFile); + if (raw === null) return false; + assert(/^[0-9]+\s*$/.test(raw), "Invalid launcher exit witness."); + assert.notEqual(Number(raw), 0, "A worker crash must not terminate the launcher successfully."); + return { code: Number(raw) }; + }); + receipt.nonzeroExitObserved = true; + receipt.launcherExitCode = exitStatus.code; + await until("Whole owned source stack stopped", async () => { + if (await safety.ownedSupervisorPid(config, { strict: false })) return false; + if ((await liveMembers(groupIds)).length) return false; + const remaining = await lib.dockerResources(config, ownership); + if (remaining.containers.length || remaining.networks.length) return false; + assert.deepEqual([...remaining.volumes].sort(), [...resources.volumes].sort(), "Fail-fast down must preserve owned persistent volumes for an explicit restart."); + return portClosed(config.ports.api); + }); + receipt.failFastCleanupObserved = true; + console.log("[dev-stack-worker-probe] PASS owned worker SIGKILL produced a clear failure, nonzero exit, and complete fail-fast shutdown"); + + const recoveryExit = join(process.env.RUNNER_TEMP, "dev-stack-recovery.exit"); + await rm(recoveryExit, { force: true }); + const recoveryOffset = (await stat(log)).size; + // Same documented command; no reset, dev-login, reused auth session, or alternate environment. + recovery = spawn("bash", ["-c", 'set +e; npm run dev:stack >>"$1" 2>&1; code=$?; printf "%s\\n" "$code" >"$2.tmp"; mv "$2.tmp" "$2"; exit "$code"', "dev-stack-recovery", log, recoveryExit], { cwd: root, detached: true, stdio: "ignore", env: process.env }); + recovery.once("error", (error) => { recoveryError = error; }); + assert(Number.isInteger(recovery.pid) && recovery.pid > 1, "Could not launch the ordinary recovery command."); + await writeFile(join(process.env.RUNNER_TEMP, "dev-stack-recovery-shell.pid"), `${recovery.pid}\n`, { mode: 0o600 }); + const afterReady = await until("Explicit normal restart readiness", async () => { + assert(!recoveryError, "The ordinary recovery launcher could not start."); + assert.equal(await optionalText(recoveryExit), null, "Recovered launcher exited before reaching readiness; inspect the redacted stack log."); + const body = await readiness(api); + if (!body) return false; + const newRecords = await safety.readProcessRecords(config, ownership); + const newWorker = newRecords.find((entry) => entry.name === "worker"); + if (!newWorker || newWorker.pid === worker.pid || !await safety.ownedSupervisorPid(config)) return false; + const output = (await readFile(log)).subarray(recoveryOffset).toString("utf8"); + return output.includes('"service":"space-worker"') && output.includes('"status":"running"') && body; + }, 420_000); + assert.notEqual(await safety.ownedSupervisorPid(config), supervisor); + assert.notEqual(afterReady.apiStartedAt, initialReady.apiStartedAt); + assert.equal((await safety.readStateOwnership(config)).token, ownership.token); + receipt.explicitRestartReady = true; + assert.deepEqual(await loginAndRooms(api), owner, "The same owner credentials and room IDs must survive ordinary restart."); + receipt.originalOwnerAndRoomsPreserved = true; + assert.equal(await git("rev-parse", "HEAD"), sha); + assert.equal(await git("status", "--porcelain=v1"), baselineStatus); + receipt.passed = true; + console.log("[dev-stack-worker-probe] PASS explicit normal restart, ready dependencies, original owner login and room identities"); + } finally { + await writeFile(join(evidenceRoot, "worker-recovery-receipt.json"), JSON.stringify(receipt, null, 2) + "\n", { mode: 0o600 }); + // Keep recovery services available for the workflow's existing always-down/reset. + // With stdio detached and a recorded shell PID, they do not keep this probe alive. + recovery?.unref(); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { console.error(`[dev-stack-worker-probe] FAIL ${error.message}`); process.exitCode = 1; }); +} diff --git a/deploy/docker/dev-stack.mjs b/deploy/docker/dev-stack.mjs index 4013881b9..bbd3bc6b8 100755 --- a/deploy/docker/dev-stack.mjs +++ b/deploy/docker/dev-stack.mjs @@ -82,7 +82,10 @@ export async function ensureDarwinNodePtySpawnHelpers(root, platform = process.p return repaired; } -async function waitSupervisorExit(config, pid, timeoutMs = 15_000) { +// Four service groups can each use 7s graceful + 2s forced termination, then +// Docker stops the dependent containers. Include bounded VM/CLI overhead too; +// 15s could expire while a healthy Colima teardown was still making progress. +async function waitSupervisorExit(config, pid, timeoutMs = 90_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await ownedSupervisorPid(config, { strict: false }) !== pid) return; @@ -364,8 +367,10 @@ export async function start(config) { }); }; - process.once("SIGINT", () => finishShutdown("SIGINT")); - process.once("SIGTERM", () => finishShutdown("SIGTERM")); + // Keep handlers installed throughout asynchronous cleanup: a concurrent down + // or reset must join the same shutdown, not restore the default fatal signal. + process.on("SIGINT", () => finishShutdown("SIGINT")); + process.on("SIGTERM", () => finishShutdown("SIGTERM")); process.on("SIGHUP", reload); try { diff --git a/deploy/docker/dev-stack.test.mjs b/deploy/docker/dev-stack.test.mjs index 0f0338867..a78704677 100644 --- a/deploy/docker/dev-stack.test.mjs +++ b/deploy/docker/dev-stack.test.mjs @@ -88,14 +88,18 @@ test("build markers ignore generated-directory timestamps but detect source chan }); test("startup build ownership covers cancellation, success, and failure", { skip: process.platform === "win32" }, async (t) => { - for (const mode of ["cancel", "renamed-cancel", "success", "failure", "spawn-failure"]) await t.test(mode, async () => { - const cancel = mode.endsWith("cancel"); + for (const mode of ["cancel", "renamed-cancel", "slow-down", "repeated-stop", "success", "failure", "spawn-failure"]) await t.test(mode, async () => { + const delayedDocker = ["slow-down", "repeated-stop"].includes(mode); + const cancel = mode.endsWith("cancel") || delayedDocker; const root = await mkdtemp(join(tmpdir(), "spaceapp-build-cancel-")); const config = await resolveConfig({}, root); const bin = join(root, "fake-bin"); const familyFile = join(root, "build-family.json"); const migrationFile = join(root, "migration-started"); + const dockerStopping = join(root, "docker-stopping"); + const dockerStopped = join(root, "docker-stopped"); let supervisor; + let downCaller; let unrelated; let family; let output = ""; @@ -106,7 +110,13 @@ test("startup build ownership covers cancellation, success, and failure", { skip await writeFile(join(root, "Dockerfile"), "RUN npm install opencode-ai@1.18.4\n"); await writeFile(join(bin, "docker"), `#!${process.execPath} const args = process.argv.slice(2); - if (args[0] === "inspect") console.log("healthy"); + const fs = require("node:fs"); + if (${delayedDocker} && args[0] === "ps" && !fs.existsSync(${JSON.stringify(dockerStopped)})) console.log("fixture-container"); + else if (${delayedDocker} && args[0] === "compose" && args.includes("down")) { + fs.appendFileSync(${JSON.stringify(dockerStopping)}, "down\\n"); + setTimeout(() => fs.writeFileSync(${JSON.stringify(dockerStopped)}, "stopped"), ${mode === "slow-down" ? 16100 : 300}); + } + else if (args[0] === "inspect") console.log("healthy"); else if (args[0] === "compose" && args.includes("ps")) console.log("fixture-service"); `, { mode: 0o700 }); await writeFile(join(bin, "npm"), `#!${process.execPath} @@ -163,10 +173,35 @@ test("startup build ownership covers cancellation, success, and failure", { skip while (!existsSync(familyFile) && Date.now() < startedDeadline && supervisor.exitCode === null) await sleep(25); assert.equal(existsSync(familyFile), mode !== "spawn-failure", output); if (existsSync(familyFile)) family = JSON.parse(await readFile(familyFile, "utf8")); - if (cancel) supervisor.kill("SIGTERM"); - const stoppedDeadline = Date.now() + 10000; + if (delayedDocker) { + const downLauncher = join(root, "down.mjs"); + await writeFile(downLauncher, ` + import { down } from ${JSON.stringify(new URL("./dev-stack.mjs", import.meta.url).href)}; + import { resolveConfig } from ${JSON.stringify(new URL("./dev-stack-lib.mjs", import.meta.url).href)}; + down(await resolveConfig({}, ${JSON.stringify(root)})).catch(error => { console.error(error.message); process.exitCode = 1; }); + `); + downCaller = spawn(process.execPath, [downLauncher], { env: { ...process.env, PATH: `${bin}:${process.env.PATH || ""}` }, stdio: ["ignore", "pipe", "pipe"] }); + downCaller.stdout.on("data", chunk => { output += chunk.toString(); }); + downCaller.stderr.on("data", chunk => { output += chunk.toString(); }); + const stoppingDeadline = Date.now() + 10000; + while (!existsSync(dockerStopping) && Date.now() < stoppingDeadline) await sleep(25); + assert.equal(existsSync(dockerStopping), true, output); + if (mode === "repeated-stop") { + supervisor.kill("SIGTERM"); + supervisor.kill("SIGINT"); + await sleep(25); + supervisor.kill("SIGINT"); + } + } else if (cancel) supervisor.kill("SIGTERM"); + const stoppedDeadline = Date.now() + (delayedDocker ? 25000 : 10000); while (supervisor.exitCode === null && supervisor.signalCode === null && Date.now() < stoppedDeadline) await sleep(25); assert.notEqual(supervisor.exitCode ?? supervisor.signalCode, null, output); + if (downCaller) { + while (downCaller.exitCode === null && downCaller.signalCode === null && Date.now() < stoppedDeadline) await sleep(25); + assert.equal(downCaller.exitCode, 0, output); + assert.equal(supervisor.signalCode, null, "Repeated stop signals must not interrupt the supervisor's cleanup."); + assert.equal(await readFile(dockerStopping, "utf8"), "down\n", "Only the supervisor should run Docker teardown while it is alive."); + } for (const pid of [family?.leader, family?.descendant].filter(Number.isInteger)) { const result = await run("ps", ["-p", String(pid), "-o", "stat="], { capture: true, allowFailure: true }); assert.ok(result.code !== 0 || result.stdout.startsWith("Z"), `build PID ${pid} survived cancellation: ${result.stdout}`); @@ -182,6 +217,7 @@ test("startup build ownership covers cancellation, success, and failure", { skip assert.equal(existsSync(config.pidFile), false); assert.doesNotThrow(() => process.kill(unrelated.pid, 0), "an unrelated process must remain alive"); } finally { + if (downCaller?.exitCode === null && downCaller?.signalCode === null) downCaller.kill("SIGKILL"); for (const pid of [supervisor?.pid, family?.leader, unrelated?.pid]) { if (Number.isInteger(pid)) try { process.kill(-pid, "SIGKILL"); } catch {} } diff --git a/package.json b/package.json index 2bebe7ce0..df26ec4bc 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "dev:worker": "npm run dev -w @space/worker", "dev:stack": "node deploy/docker/dev-stack.mjs", "verify:dev-stack": "node deploy/docker/dev-stack-verify.mjs", - "test:dev-stack": "node --test deploy/docker/dev-stack.test.mjs", + "test:dev-stack": "node --test deploy/docker/dev-stack.test.mjs deploy/docker/dev-stack-probes.test.mjs", "db:migrate": "npm run migrate -w @space/db", "build": "npm run build -w @space/contracts && npm run build -w @space/icons && npm run build -w @space/streaming && npm run build -w @space/opencode-control && npm run build -w @space/memory-graph && npm run build -w @space/browser-host && npm run build -w @space/cli-host && npm run build -w @space/codex-app-server && npm run build -w @space/runtime && npm run build -w @space/db && npm run build -w @space/gateway && node scripts/build-demo-projects.mjs && npm run build -w @space/api && npm run build -w @space/worker && npm run build -w @space/web && node scripts/fix-build-permissions.mjs", "public:export:verify": "node scripts/public-export.mjs --verify", From 2bd0b6a66dbb702bdda21ec0490a4cf4816871ab Mon Sep 17 00:00:00 2001 From: 1932819331hy-debug <1932819331hy@gmail.com> Date: Mon, 19 Oct 2026 01:19:09 +0900 Subject: [PATCH 62/71] fix(dev): isolate source stack review findings --- apps/api/src/agent-bootstrap.ts | 25 +++++--- apps/api/tests/agent-bootstrap.test.ts | 34 +++++++++++ apps/api/tests/memory-search-root.test.ts | 33 +++++++++++ .../src/memory-maintenance-activities.ts | 4 +- deploy/docker/dev-stack-lib.mjs | 10 +++- deploy/docker/dev-stack.mjs | 3 +- deploy/docker/dev-stack.test.mjs | 59 ++++++++++++++++++- packages/runtime/src/shared-state.ts | 9 +-- packages/runtime/src/space-memory-files.ts | 13 ++-- packages/runtime/tests/shared-state.test.ts | 27 ++++++++- .../runtime/tests/space-memory-files.test.ts | 17 ++++++ 11 files changed, 211 insertions(+), 23 deletions(-) create mode 100644 apps/api/tests/memory-search-root.test.ts diff --git a/apps/api/src/agent-bootstrap.ts b/apps/api/src/agent-bootstrap.ts index 72a5be3eb..613c66646 100644 --- a/apps/api/src/agent-bootstrap.ts +++ b/apps/api/src/agent-bootstrap.ts @@ -1,4 +1,4 @@ -import { spaceAgentCorePath, spaceMemoryIndexPath, spaceMemoryMonthlyPath } from "@space/runtime"; +import { SPACE_MEMORY_DIRECTORY, spaceAgentCorePath, spaceMemoryDirectory, spaceMemoryIndexPath, spaceMemoryMonthlyPath } from "@space/runtime"; export interface SpaceAgentBootstrapInput { channel: "WEB_CHAT" | "CLI"; @@ -73,6 +73,8 @@ function cliAgentFilesLine(channel: SpaceAgentBootstrapInput["channel"]): string export function buildSpaceAgentBootstrapPrompt(input: SpaceAgentBootstrapInput): string { const memoryMonth = input.currentMemoryMonth ?? currentMemoryMonth(); + const memoryDirectory = spaceMemoryDirectory(); + const installedMemory = memoryDirectory === SPACE_MEMORY_DIRECTORY; const browserState = input.browserToolsEnabled ? `${input.activeBrowserSessionCount ?? 0} active managed browser session(s) are visible in this room.` : "Managed browser tools are disabled until Space browser runtime gates pass."; @@ -87,15 +89,15 @@ export function buildSpaceAgentBootstrapPrompt(input: SpaceAgentBootstrapInput): "Space Agent Bootstrap:", `- Channel: ${input.channel}. Match the main Codex /etc VS Code session rules where the runtime exposes them.`, ...identityLines(input), - `- Read the compact ${spaceAgentCorePath()} core once. Load only task-relevant domain rules; read /opt/spaceapp/docs/agent_local_workspace_hygiene.md before local project/dev-server/cleanup work.`, + `- Read the compact ${spaceAgentCorePath()} core once. Load only task-relevant domain rules; ${installedMemory ? "read /opt/spaceapp/docs/agent_local_workspace_hygiene.md" : "follow the current source checkout's CONTRIBUTING.md"} before local project/dev-server/cleanup work.`, `- Canonical Space Memory: ${spaceMemoryIndexPath()} plus ${spaceMemoryMonthlyPath(memoryMonth)}.`, - "- Memory parity: read/search/save through the Space Memory bridge (/opt/spaceapp/bin/space-memory-recall, /opt/spaceapp/bin/space-memory-save) or Space API memory endpoints. Treat Space Memory in /opt/spaceapp/docs as canonical operational memory; Codex ad-hoc notes are fallback only.", + `- Memory parity: ${installedMemory ? "read/search/save through the Space Memory bridge (/opt/spaceapp/bin/space-memory-recall, /opt/spaceapp/bin/space-memory-save) or Space API memory endpoints" : "read/search/save through Space API memory endpoints; do not use an installed-instance memory bridge"}. Treat Space Memory in ${memoryDirectory} as canonical operational memory; Codex ad-hoc notes are fallback only.`, "- Tools: choose verified native tools first. MCP is on-demand fallback or Space-specific access, never a mandatory substitute for a capable native tool. Keep evidence and authorization requirements regardless of transport.", "- Images: use native vision whenever available. Vision MCP is only for verified text-only input pipelines; unknown model support requires checking capability metadata first.", "- Skills: load only relevant guidance when needed. Workflow skills require operator opt-in; a skill must not force an inferior tool. Do not list every skill or tool at startup.", input.channel === "CLI" ? "- Space-only operations: use space-capability info [tool] or space-capability call with JSON on stdin when needed. No MCP catalog or server startup is required for native tasks." : null, agentFilesLine, - input.channel === "CLI" ? "- Timed or recurring work: `node /opt/spaceapp/scripts/space-task.mjs add --title T --prompt P --every 2h|--daily HH:MM|--at