From 651b082ee3de2f0d35ec48af2394f58d69928f2f Mon Sep 17 00:00:00 2001 From: Rafael <85370839+rafiannazzo@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:06:19 +0100 Subject: [PATCH] Add isolated source development stack with real owner and terminal smoke Signed-off-by: Rafael <85370839+rafiannazzo@users.noreply.github.com> --- .github/workflows/dev-stack.yml | 53 ++++++ .gitignore | 1 + CONTRIBUTING.md | 98 +++++++++- apps/api/src/app.ts | 2 +- apps/api/src/storage-readiness.ts | 10 +- apps/api/tests/storage-warning.test.ts | 25 +++ apps/web/vite.config.ts | 10 +- package.json | 2 + scripts/dev-stack-config.mjs | 140 ++++++++++++++ scripts/dev-stack-opencode.mjs | 32 ++++ scripts/dev-stack-processes.mjs | 74 ++++++++ scripts/dev-stack-smoke.mjs | 195 ++++++++++++++++++++ scripts/dev-stack.mjs | 204 +++++++++++++++++++++ scripts/public-export.mjs | 8 + scripts/tests/dev-stack-processes.test.mjs | 22 +++ scripts/tests/dev-stack-smoke.test.mjs | 49 +++++ scripts/tests/dev-stack.test.mjs | 77 ++++++++ scripts/tests/public-suite.test.mjs | 3 + 18 files changed, 993 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/dev-stack.yml create mode 100644 scripts/dev-stack-config.mjs create mode 100644 scripts/dev-stack-opencode.mjs create mode 100644 scripts/dev-stack-processes.mjs create mode 100644 scripts/dev-stack-smoke.mjs create mode 100644 scripts/dev-stack.mjs create mode 100644 scripts/tests/dev-stack-processes.test.mjs create mode 100644 scripts/tests/dev-stack-smoke.test.mjs create mode 100644 scripts/tests/dev-stack.test.mjs diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml new file mode 100644 index 000000000..4522d14b4 --- /dev/null +++ b/.github/workflows/dev-stack.yml @@ -0,0 +1,53 @@ +name: Contributor dev stack + +on: + pull_request: + paths: + - "scripts/dev-stack*.mjs" + - "scripts/tests/dev-stack*.test.mjs" + - "apps/**" + - "packages/**" + - "Dockerfile" + - "package*.json" + - ".github/workflows/dev-stack.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: dev-stack-${{ github.ref }} + cancel-in-progress: true + +jobs: + source-stack: + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + cache: npm + - name: Install contributor prerequisites + run: sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep sqlite3 + - name: Install locked dependencies + run: npm ci + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + - name: Check smoke failure detection + run: node --test scripts/tests/dev-stack*.test.mjs + - name: Start from source and exercise owner and CLI flows + timeout-minutes: 25 + run: | + npm run dev:stack > "$RUNNER_TEMP/dev-stack.log" 2>&1 & + node scripts/dev-stack-smoke.mjs --report "$RUNNER_TEMP/dev-stack-proof.json" + - name: Remove owned resources + if: always() + run: npm run dev:stack -- reset + - name: Prove cleanup + if: always() + run: | + if [ -f "$RUNNER_TEMP/dev-stack-proof.json" ]; then + node scripts/dev-stack-smoke.mjs --verify-cleanup "$RUNNER_TEMP/dev-stack-proof.json" + fi diff --git a/.gitignore b/.gitignore index 81e68b26a..ba0b11dac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Generated outputs and local tool reports. These are safe to regenerate; do # not commit them, and clean them only with an allowlist. node_modules/ +.dev-stack/ dist/ dist-mock/ dist-types/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 078772388..d67f5cb57 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,9 +24,105 @@ ELECTRON_SKIP_BINARY_DOWNLOAD=1 npm ci Skipping the Electron download is suitable for core development. Desktop packaging requires its normal toolchain and separate platform acceptance. -Dependency installation may use the network; the smoke command below does not +Dependency installation may use the network; the offline smoke command described later does not need a provider account, API key, Docker, PostgreSQL or Temporal. +## Run the full app from source + +Use Node **>=22.12 <23**, npm, Git, ripgrep, SQLite 3, and Docker with Compose v2 or newer. +Docker must be running. On macOS use Docker Desktop or Colima with Docker +Compose installed; on Windows 11 run these commands inside WSL2, with Docker +Desktop's WSL integration enabled. Native Windows is not supported by this +Unix-socket/PTY development command. Keep the checkout in the WSL Linux +filesystem for reliable file watching. Linux needs the usual Python 3, Make +and C++ build tools if `node-pty` has no prebuilt binary for your platform. + +After the locked dependency installation above: + +```sh +npm run dev:stack +# In another terminal, from this same checkout: +npm run dev:stack -- down +npm run dev:stack -- reset +``` + +The first start downloads the published pgvector and Temporal images, builds +the internal packages, applies the database migrations, and installs the exact +OpenCode package pinned in `Dockerfile` into `.dev-stack/vendor`. The database +image digests come directly from the published Compose template. It then +starts the API in watch mode, Vite, the worker and the CLI host from this +checkout. The command stays in the foreground and also works without a TTY. +It prints the URL and, until an owner exists, the one-time setup token. Open +the URL, create the owner using that token, then sign in. Getting Started's +OpenCode pane can open its terminal UI without an AI provider account. Sending +a prompt may require a provider account; the dev stack does not supply one. +Provider inference checks are not enabled; terminal availability does not +certify that a model can answer a prompt. +The normal owner flow is used; the dev-login shortcut is disabled. + +Default loopback-only ports are: + +| Service | Port | Override before first start | +| --- | --- | --- | +| Web UI | 15411 | `SPACE_DEV_WEB_PORT` | +| API | 15410 | `SPACE_DEV_API_PORT` | +| PostgreSQL | 15432 | `SPACE_DEV_POSTGRES_PORT` | +| Temporal | 17233 | `SPACE_DEV_TEMPORAL_PORT` | +| OpenCode server | 15412 | `SPACE_DEV_OPENCODE_PORT` | + +For example, `SPACE_DEV_WEB_PORT=16411 npm run dev:stack`. Ports are saved in +the checkout's state; reset before selecting different ports. Every listener +binds to `127.0.0.1`. An installed app on 4910/4911 keeps its own data and +ports. Each checkout gets a distinct Compose project and named database +volume. Passwords, session keys, wrappers, provider homes and logs live in the +git-ignored `.dev-stack/`; secret files have mode 0600. A short, private socket +directory under `/tmp/spaceapp-dev-*` avoids the macOS Unix-socket path limit. +The application and OpenCode receive isolated homes and no inherited provider +keys. Docker alone uses your existing Docker context to reach its daemon. + +Changes in `apps/api/src` restart the API, and changes in `apps/web/src` use +Vite hot reload. After changing an internal `packages/*` module or worker +source, run `npm run dev:stack -- rebuild` in another terminal: this rebuilds +the packages and restarts the API, worker and CLI host. Open terminals are +interrupted by that explicit rebuild; reopen the pane afterwards. + +`down` (or Ctrl-C) stops the owned processes and removes the project's +containers/network, retaining the database volume and state for next time. +`reset` also removes that project's volume, state and socket directory. These +commands never prune other Docker resources. The browser service, companion +services and root-only features stay disabled. Logs are under +`.dev-stack/logs`; unexpected process exits or prolonged failed readiness +checks stop the stack with a named error. `/readyz` requires the real worker. + +If startup fails, inspect the named service log and check `docker compose +version` and `docker info`. A port-in-use error means another checkout or +process uses that port; stop that stack or reset this checkout and choose +different ports. If the owner token expires, stop and start the stack to +refresh its 15-minute setup window. A claimed owner cannot be replaced with +the token; reset deletes this development database if you need a fresh owner. +Use a checkout you own, and never point this command at an operator instance. + +The CI acceptance check uses real owner authentication and a WebSocket-backed +OpenCode terminal, then verifies cleanup: + +```sh +node scripts/dev-stack-smoke.mjs --report /tmp/spaceapp-dev-proof.json +npm run dev:stack -- reset +node scripts/dev-stack-smoke.mjs --verify-cleanup /tmp/spaceapp-dev-proof.json +``` + +Run that smoke against a fresh dev stack; it creates a disposable owner. The +report contains resource identities and process IDs, not credentials. + +Runtime verification on 2026-10-23 used Node 22.23.4 on macOS arm64 and a +Linux arm64 container with Docker Desktop. Both completed owner setup, +logout/login, real Temporal worker readiness, an OpenCode terminal with output, +normal shutdown and reset cleanup. The macOS run also exercised API/CSS reload, +the rebuild command and intentional worker-exit supervision. The Linux run +verified the configured storage root through the live authenticated admin API. +This is container-based Linux evidence; bare-metal Linux and WSL have not been +verified. No provider inference was requested or tested. + ## First executed task, without a provider ```sh diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ed4470709..a12166f02 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1788,7 +1788,7 @@ export async function createApp(options: CreateAppOptions = {}): Promise collectStorageReadiness(config.installRoot)); const spaceCapabilityInventoryCollector = options.spaceCapabilityInventoryCollector ?? collectDefaultSpaceCapabilityInventory; const spaceMemorySearcher = options.spaceMemorySearcher ?? ((q) => searchDefaultSpaceMemory(q, decisionsService)); const canonicalMemory = diff --git a/apps/api/src/storage-readiness.ts b/apps/api/src/storage-readiness.ts index d4b6dbf85..3ff0bae23 100644 --- a/apps/api/src/storage-readiness.ts +++ b/apps/api/src/storage-readiness.ts @@ -1,7 +1,7 @@ import { stat, statfs } from "node:fs/promises"; import { storageReadinessSchema, type StorageReadiness } from "@space/contracts"; -// The launch storage check of /readyz (moved out of the composition root): a dedicated /opt/spaceapp volume with 20 GB free. +// Production/browser-heavy launch requires dedicated app storage with 20 GB free. const storageMinimumRecommendedFreeBytes = 20 * 1024 * 1024 * 1024; function usedPercent(sizeBytes: number, availableBytes: number): number { @@ -22,16 +22,16 @@ async function collectStorageMount(targetPath: string) { }; } -export async function collectStorageReadiness(): Promise { - const [root, app] = await Promise.all([collectStorageMount("/"), collectStorageMount("/opt/spaceapp")]); +export async function collectStorageReadiness(installRoot = "/opt/spaceapp"): Promise { + const [root, app] = await Promise.all([collectStorageMount("/"), collectStorageMount(installRoot)]); const dedicatedAppVolume = root.deviceId !== app.deviceId; const freeSpaceBlocked = app.availableBytes < storageMinimumRecommendedFreeBytes; const rootWarn = root.usedPercent >= 80; const status = !dedicatedAppVolume || freeSpaceBlocked ? "BLOCKED" : rootWarn ? "WARN" : "VERIFIED"; const statusReason = !dedicatedAppVolume - ? "Dedicated /opt/spaceapp volume is not detected; production/browser-heavy launch requires isolated dedicated app storage with at least 20GB free." + ? `Dedicated ${installRoot} volume is not detected; production/browser-heavy launch requires isolated dedicated app storage with at least 20GB free.` : freeSpaceBlocked - ? "Dedicated /opt/spaceapp volume exists but available space is below the 20GB minimum recommendation." + ? `Dedicated ${installRoot} volume exists but available space is below the 20GB minimum recommendation.` : rootWarn ? "Root filesystem is above the 80% warning threshold." : "Storage readiness meets the current Space launch thresholds."; diff --git a/apps/api/tests/storage-warning.test.ts b/apps/api/tests/storage-warning.test.ts index 607b8b4bc..423a9f916 100644 --- a/apps/api/tests/storage-warning.test.ts +++ b/apps/api/tests/storage-warning.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { createApp } from "../src/app.js"; import { getApiConfig } from "../src/config.js"; @@ -47,6 +50,28 @@ async function loginCookie(app: Awaited>): Promise< const APP_TEST_TIMEOUT_MS = 60_000; describe("public profile storage warning", () => { + it("inspects the configured installation root without requiring /opt/spaceapp", async () => { + const installRoot = await mkdtemp(join(tmpdir(), "space-dev-storage-")); + expect(getApiConfig({}).installRoot).toBe("/opt/spaceapp"); + const app = await createApp({ + config: getApiConfig({ SPACE_INSTALL_ROOT: installRoot, SPACE_BROWSER_SESSIONS_ENABLED: "false" }), + auth: { sessionSecret: "storage-path-test-session-secret", devLogin: true, secureCookies: false } + }); + try { + const headers = { cookie: await loginCookie(app) }; + const storage = await app.inject({ method: "GET", url: "/api/admin/storage", headers }); + expect(storage.statusCode).toBe(200); + expect(storage.json()).toMatchObject({ app: { path: installRoot }, minimumRecommendedFreeBytes: 20 * 1024 ** 3 }); + expect(storage.json().dedicatedAppVolume).toBe(storage.json().root.deviceId !== storage.json().app.deviceId); + const admin = await app.inject({ method: "GET", url: "/api/admin", headers }); + expect(admin.statusCode).toBe(200); + expect(admin.json().storageWarning).toBe(""); + } finally { + await app.close(); + await rm(installRoot, { recursive: true, force: true }); + } + }, APP_TEST_TIMEOUT_MS); + it("does not show the browser-heavy storage banner when browser sessions are disabled", async () => { const app = await createApp({ config: getApiConfig({ diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index cd7612d98..8bf47c97c 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -34,11 +34,11 @@ export default defineConfig({ host: process.env.SPACE_WEB_HOST ?? "0.0.0.0", port: Number.parseInt(process.env.SPACE_WEB_PORT ?? "4911", 10), proxy: { - "/api": "http://127.0.0.1:4910", - "/plugins": "http://127.0.0.1:4910", - "/healthz": "http://127.0.0.1:4910", - "/readyz": "http://127.0.0.1:4910", - "/version": "http://127.0.0.1:4910" + "/api": { target: process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910", ws: true }, + "/plugins": process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910", + "/healthz": process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910", + "/readyz": process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910", + "/version": process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910" } }, test: { diff --git a/package.json b/package.json index 077e2c4f7..02228f742 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,8 @@ "@napi-rs/canvas": "1.0.9" }, "scripts": { + "dev:stack": "node scripts/dev-stack.mjs", + "test:dev-stack": "node --test scripts/tests/dev-stack*.test.mjs", "dev:api": "npm run dev -w @space/api", "dev:web": "npm run dev -w @space/web", "dev:worker": "npm run dev -w @space/worker", diff --git a/scripts/dev-stack-config.mjs b/scripts/dev-stack-config.mjs new file mode 100644 index 000000000..b31d2102e --- /dev/null +++ b/scripts/dev-stack-config.mjs @@ -0,0 +1,140 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { chmod, copyFile, lstat, mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export async function readPins(root) { + root = root instanceof URL ? fileURLToPath(root) : root; + const compose = await readFile(join(root, 'packages/run-spaceapp/templates/compose.yml'), 'utf8'); + const dockerfile = await readFile(join(root, 'Dockerfile'), 'utf8'); + const image = service => { + const value = compose.match(new RegExp(`^ ${service}:\\n\\s+image: ([^\\n]+)`, 'm'))?.[1]; + if (!value || !/@sha256:[a-f0-9]{64}$/.test(value)) throw new Error(`Missing pinned ${service} image in public compose.`); + return value; + }; + const opencode = dockerfile.match(/(?:@opencode\/cli|opencode-ai)@\d+\.\d+\.\d+(?:-[\w.-]+)?/)?.[0]; + if (!opencode) throw new Error('Missing pinned OpenCode package in Dockerfile.'); + return { postgres: image('postgres'), temporal: image('temporal'), opencode }; +} + +const defaults = { api: 15410, web: 15411, postgres: 15432, temporal: 17233, opencode: 15412 }; + +export async function preparePty(root, platform = process.platform, arch = process.arch) { + if (platform !== 'darwin') return; + // node-pty 1.1.0's macOS npm prebuild ships this executable as 0644. + // Touch only its helper, not the native addon or the rest of node_modules. + for (const directory of [`prebuilds/${platform}-${arch}`, 'build/Release']) { + const helper = join(root, 'node_modules/node-pty', directory, 'spawn-helper'); + const info = await lstat(helper).catch(error => { if (error.code === 'ENOENT') return null; throw error; }); + if (info?.isFile() && !info.isSymbolicLink()) await chmod(helper, 0o755); + } +} + +export async function loadConfig(root) { + root = await realpath(root); + const state = join(root, '.dev-stack'); + const info = await lstat(state); + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Dev stack state must be an owned directory, not a symlink.'); + const config = JSON.parse(await readFile(join(state, 'owner.json'), 'utf8')); + const id = createHash('sha256').update(root).digest('hex').slice(0, 12); + if (config.root !== root || config.state !== state || config.project !== `spaceapp-dev-${id}` || config.socketRoot !== `/tmp/spaceapp-dev-${id}`) { + throw new Error('Dev stack state does not belong to this checkout; refusing cleanup.'); + } + return config; +} + +export async function initialize(root, env) { + root = await realpath(root); + const state = join(root, '.dev-stack'); + const ports = Object.fromEntries(Object.entries(defaults).map(([name, fallback]) => { + const value = env[`SPACE_DEV_${name.toUpperCase()}_PORT`] ?? String(fallback); + if (!/^\d+$/.test(value) || +value < 1024 || +value > 65535) throw new Error(`Invalid ${name} port; use 1024–65535.`); + return [name, +value]; + })); + if (new Set(Object.values(ports)).size !== Object.keys(ports).length) throw new Error('Dev stack ports must be distinct.'); + try { await lstat(state); return await loadConfig(root); } catch (error) { if (error.code !== 'ENOENT') throw error; } + const id = createHash('sha256').update(root).digest('hex').slice(0, 12); + const config = { root, state, project: `spaceapp-dev-${id}`, socketRoot: `/tmp/spaceapp-dev-${id}`, ports }; + await mkdir(state, { mode: 0o700 }); + await writeFile(join(state, 'owner.json'), JSON.stringify(config, null, 2), { flag: 'wx', mode: 0o600 }); + for (const name of ['secrets', 'bin', 'home', 'logs', 'memory', 'memory-graph', 'artifacts', 'workspaces', 'providers/opencode', 'providers/opencode/tmp']) { + await mkdir(join(state, name), { recursive: true, mode: 0o700 }); + } + const password = randomBytes(32).toString('hex'); + for (const [name, value] of Object.entries({ + 'postgres-password': password, + 'database-url': `postgresql://spaceapp:${password}@127.0.0.1:${ports.postgres}/spaceapp`, + 'session-secret': randomBytes(48).toString('hex'), + 'setup-token': randomBytes(32).toString('hex'), + 'opencode-password': randomBytes(32).toString('hex') + })) await writeFile(join(state, 'secrets', name), value, { flag: 'wx', mode: 0o600 }); + return config; +} + +export function composeDocument(config, pins) { + return { + name: config.project, + services: { + postgres: { + image: pins.postgres, + environment: { POSTGRES_DB: 'spaceapp', POSTGRES_USER: 'spaceapp', POSTGRES_PASSWORD_FILE: '/run/secrets/postgres-password' }, + ports: [`127.0.0.1:${config.ports.postgres}:5432`], + volumes: ['postgres-data:/var/lib/postgresql/data'], secrets: ['postgres-password'], + healthcheck: { test: ['CMD-SHELL', 'pg_isready -U spaceapp -d spaceapp'], interval: '2s', timeout: '5s', retries: 60 } + }, + temporal: { + image: pins.temporal, + depends_on: { postgres: { condition: 'service_healthy' } }, + environment: { DB: 'postgres12', DB_PORT: '5432', POSTGRES_USER: 'spaceapp', POSTGRES_SEEDS: 'postgres', DYNAMIC_CONFIG_FILE_PATH: 'config/dynamicconfig/docker.yaml' }, + entrypoint: ['/bin/sh', '-ec'], + command: ['export POSTGRES_PWD="$$(cat /run/secrets/postgres-password)"; exec /etc/temporal/entrypoint.sh autosetup'], + ports: [`127.0.0.1:${config.ports.temporal}:7233`], secrets: ['postgres-password'], + healthcheck: { test: ['CMD-SHELL', 'temporal operator cluster health --address temporal:7233 >/dev/null && temporal operator namespace describe --namespace default --address temporal:7233 >/dev/null'], interval: '3s', timeout: '10s', retries: 80 } + } + }, + secrets: { 'postgres-password': { file: './secrets/postgres-password' } }, + volumes: { 'postgres-data': {} } + }; +} + +export async function prepareFiles(config, pins) { + const { state, root, socketRoot } = config; + await writeFile(join(state, 'compose.json'), JSON.stringify(composeDocument(config, pins), null, 2), { mode: 0o600 }); + // Unix sockets have a 104-byte path limit on macOS; use a short, independently owned directory. + try { + await mkdir(socketRoot, { mode: 0o700 }); + await writeFile(join(socketRoot, 'owner'), root, { flag: 'wx', mode: 0o600 }); + } catch (error) { + if (error.code !== 'EEXIST' || (await lstat(socketRoot)).isSymbolicLink() || await readFile(join(socketRoot, 'owner'), 'utf8') !== root) { + throw new Error('Dev stack socket directory is not owned by this checkout.', { cause: error }); + } + } + for (const name of ['space_memory.md', 'space_memory_monthly.md', 'installation-guide.md']) { + try { await copyFile(join(root, 'starter-memory', name), join(state, 'memory', name), 1); await chmod(join(state, 'memory', name), 0o600); } + catch (error) { if (error.code !== 'EEXIST') throw error; } + } + const quote = value => `'${value.replaceAll("'", "'\\''")}'`; + await writeFile(join(state, 'bin', 'opencode-vscode-parity'), `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(join(root, 'scripts/dev-stack-opencode.mjs'))} ${quote(state)} "$@"\n`, { mode: 0o700 }); +} + +export async function childEnvironment(config, inherited = process.env) { + const { state, root, socketRoot, ports } = config; + const secret = name => readFile(join(state, 'secrets', name), 'utf8'); + const env = Object.fromEntries(['PATH', 'LANG', 'LC_ALL', 'TERM', 'COLORTERM', 'CI'].filter(key => inherited[key]).map(key => [key, inherited[key]])); + return { + ...env, HOME: join(state, 'home'), XDG_CONFIG_HOME: join(state, 'home/config'), XDG_DATA_HOME: join(state, 'home/data'), XDG_CACHE_HOME: join(state, 'home/cache'), + PATH: `${join(state, 'bin')}:${root}/node_modules/.bin:${inherited.PATH || '/usr/bin:/bin'}`, + SPACE_PUBLIC_DISTRIBUTION: 'true', SPACE_DEV_LOGIN: 'false', SPACEAPP_WORKFLOWS_ENABLED: 'true', + SPACE_API_HOST: '127.0.0.1', SPACE_API_PORT: String(ports.api), SPACE_WEB_HOST: '127.0.0.1', SPACE_WEB_PORT: String(ports.web), SPACE_API_ORIGIN: `http://127.0.0.1:${ports.api}`, + SPACE_RUNTIME_STORE: 'postgres', SPACE_DATABASE_URL: await secret('database-url'), SPACE_SESSION_SECRET: await secret('session-secret'), SPACE_SETUP_TOKEN_FILE: join(state, 'secrets/setup-token'), + SPACE_TEMPORAL_ADDRESS: `127.0.0.1:${ports.temporal}`, SPACE_TEMPORAL_TASK_QUEUE: config.project, + SPACE_CLI_ENABLED: 'true', SPACE_CLI_HOST_SOCKET: join(socketRoot, 'host.sock'), SPACE_CLI_COMMAND_PATH: join(state, 'bin'), SPACE_CLI_WORKSPACE_ROOT: join(state, 'workspaces'), + SPACE_MEMORY_ROOT: join(state, 'memory'), SPACE_STARTER_MEMORY_ROOT: join(root, 'starter-memory'), SPACE_MEMORY_INDEX_PATH: join(state, 'memory/space_memory.md'), SPACE_MEMORY_MONTHLY_PATH: join(state, 'memory/space_memory_monthly.md'), SPACE_MEMORY_LOCK_PATH: join(state, 'memory/.memory.lock'), SPACE_MEMORY_GRAPH_ROOT: join(state, 'memory-graph'), SPACE_ARTIFACT_ROOT: join(state, 'artifacts'), + SPACE_CODEX_HOME: join(state, 'providers/codex'), SPACE_CODEX_APP_SERVER_HOME: join(state, 'providers/codex'), SPACE_CODEX_GOALS_DB_PATH: join(state, 'codex-goals.sqlite'), SPACE_CODEX_CLI_MODE_DEFAULTS_PROJECTION_PATH: join(state, 'codex-cli-mode-defaults.json'), + SPACE_TELEGRAM_SECRET_ROOT: join(state, 'telegram'), SPACE_TELEGRAM_OWNERSHIP_MANIFEST: join(state, 'telegram/ownership.json'), SPACE_STREAMING_SECRET_ROOT: join(state, 'streaming'), + SPACE_APP_DIAGNOSTICS_ROOT: join(state, 'diagnostics'), SPACE_MAINTENANCE_AUTOPILOT_STATE_FILE: join(state, 'maintenance/state.json'), SPACE_CLI_ACCOUNT_BALANCER_STATE_FILE: join(state, 'balancer/state.json'), SPACE_CONNECTOR_KEY_FILE: join(state, 'secrets/connector.key'), SPACE_DESIGN_GATEWAY_KEY_FILE: join(state, 'secrets/design.key'), + SPACE_INSTALL_ROOT: join(state, 'workspaces'), SPACE_PROJECTS_ROOT: join(state, 'workspaces'), SPACE_DEMO_PROJECTS_VAR_ROOT: join(state, 'demo-projects'), + SPACE_DEMO_PROJECTS_ENABLED: 'false', SPACE_BROWSER_SESSIONS_ENABLED: 'false', SPACE_BROWSER_EVIDENCE_ENABLED: 'false', SPACE_FILES_HOST_MODE: 'disabled', SPACE_CLI_ROOT_ENABLED: 'false', SPACE_T3CODE_ENABLED: 'false', SPACE_AGENT_TOOLS_WRITER: 'disabled', SPACE_TELEMETRY_ENABLED: 'false', SPACE_CHESS_MOVE_SOCKET: 'disabled', + SPACE_ROOM_AGENT_AUTOPILOT_ENABLED: 'false', SPACE_STREAMING_BOT_API_BASE_URL: `http://127.0.0.1:${ports.api}` + }; +} diff --git a/scripts/dev-stack-opencode.mjs b/scripts/dev-stack-opencode.mjs new file mode 100644 index 000000000..e376dc48b --- /dev/null +++ b/scripts/dev-stack-opencode.mjs @@ -0,0 +1,32 @@ +// Invoked only by the generated, checkout-local opencode-vscode-parity wrapper. +import { spawn } from 'node:child_process'; +import { access, readFile } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { join } from 'node:path'; + +const [state, ...args] = process.argv.slice(2); +const { ports } = JSON.parse(await readFile(join(state, 'owner.json'), 'utf8')); +const command = join(state, 'vendor/node_modules/.bin/opencode'); +if (args[0] === '--run-inner') args.shift(); +if (args[0] === 'credential-smoke') { + console.error('This contributor stack verifies the OpenCode terminal only; no provider inference check is configured.'); + process.exit(1); +} +if (['credential-status', 'credential-observation'].includes(args[0])) { + await access(command, constants.X_OK); + console.log(args[0] === 'credential-observation' ? 'OBSERVATION:dev-opencode-installed' : 'READY'); + process.exit(0); +} +const provider = join(state, 'providers/opencode'); +const env = { + PATH: process.env.PATH, TERM: process.env.TERM || 'xterm-256color', LANG: process.env.LANG || 'C.UTF-8', + HOME: provider, TMPDIR: join(provider, 'tmp'), XDG_CONFIG_HOME: join(provider, 'config'), + XDG_DATA_HOME: join(provider, 'data'), XDG_CACHE_HOME: join(provider, 'cache'), XDG_STATE_HOME: join(provider, 'state'), + OPENCODE_DISABLE_AUTOUPDATE: 'true', OPENCODE_SERVER_USERNAME: 'opencode', + OPENCODE_SERVER_PASSWORD: await readFile(join(state, 'secrets/opencode-password'), 'utf8') +}; +const terminal = !args.length || ['--continue', '-c', '--session', '-s'].includes(args[0]); +const child = spawn(command, terminal ? ['--server', `http://127.0.0.1:${ports.opencode}`, ...args, process.cwd()] : [...args, '--standalone'], { env, stdio: 'inherit' }); +for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, () => child.kill(signal)); +child.once('error', () => { console.error('OpenCode could not start; run dev:stack again.'); process.exitCode = 1; }); +child.once('exit', code => { process.exitCode = code ?? 1; }); diff --git a/scripts/dev-stack-processes.mjs b/scripts/dev-stack-processes.mjs new file mode 100644 index 000000000..6280ec953 --- /dev/null +++ b/scripts/dev-stack-processes.mjs @@ -0,0 +1,74 @@ +import { spawn } from 'node:child_process'; +import { mkdirSync, writeFileSync, appendFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; + +export class Processes { + constructor({ root, state, project, env, secrets = [], onFailure = () => {} }) { + Object.assign(this, { root, state, project, env, secrets, onFailure }); + this.children = new Map(); + mkdirSync(join(state, 'logs'), { recursive: true, mode: 0o700 }); + } + start(name, command, args, { temporary = false, env = this.env, cwd = this.root } = {}) { + const path = join(this.state, 'logs', `${name}.log`); + writeFileSync(path, '', { mode: 0o600 }); + const child = spawn(command, args, { cwd, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] }); + const entry = { child, stopping: false, finished: false, done: null }; + this.children.set(name, entry); + this.record(); + const output = line => { + for (const secret of this.secrets) if (secret) line = line.replaceAll(secret, '[redacted]'); + appendFileSync(path, line); + process.stdout.write(`[${name}] ${line}`); + }; + for (const stream of [child.stdout, child.stderr]) { + let pending = ''; + stream.setEncoding('utf8'); + stream.on('data', chunk => { + pending += chunk; + let end; + while ((end = pending.indexOf('\n')) !== -1) { output(pending.slice(0, end + 1)); pending = pending.slice(end + 1); } + if (pending.length > 1024 * 1024) { output('[output line too large]\n'); pending = ''; } + }); + stream.on('end', () => { if (pending) output(`${pending}\n`); }); + } + entry.done = new Promise(resolve => { + let settled = false; + const finish = (code, signal) => { + if (settled) return; + settled = true; + entry.finished = true; + this.children.delete(name); + this.record(); + if (!entry.stopping && (!temporary || code !== 0)) this.onFailure(new Error(`${name} exited unexpectedly (${code ?? signal}). See .dev-stack/logs/${name}.log.`)); + resolve(code ?? 1); + }; + child.once('error', () => finish(1, 'spawn error')); + child.once('close', finish); + }); + return entry; + } + async run(name, command, args, options = {}) { + const code = await this.start(name, command, args, { ...options, temporary: true }).done; + if (code !== 0) throw new Error(`${name} failed (${code}); see .dev-stack/logs/${name}.log.`); + } + record() { + writeFileSync(join(this.state, 'runtime.json'), JSON.stringify({ supervisorPid: process.pid, + services: Object.fromEntries([...this.children].map(([name, entry]) => [name, entry.child.pid])) }), { mode: 0o600 }); + } + async stop(names = [...this.children.keys()]) { + const entries = names.map(name => this.children.get(name)).filter(Boolean); + for (const entry of entries) { + if (entry.finished) continue; + entry.stopping = true; + try { process.kill(-entry.child.pid, 'SIGTERM'); } catch (error) { if (error.code !== 'ESRCH') throw error; } + } + await Promise.race([Promise.all(entries.map(entry => entry.done)), delay(5000, undefined, { ref: false })]); + for (const entry of entries) { + if (entry.finished) continue; + // The process group also contains watch-mode grandchildren. + try { process.kill(-entry.child.pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') throw error; } + } + await Promise.all(entries.map(entry => entry.done)); + } +} diff --git a/scripts/dev-stack-smoke.mjs b/scripts/dev-stack-smoke.mjs new file mode 100644 index 000000000..3b75f5dba --- /dev/null +++ b/scripts/dev-stack-smoke.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { randomBytes, randomUUID } from "node:crypto"; +import { access, readFile, realpath, writeFile } from "node:fs/promises"; +import { createConnection } from "node:net"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { WebSocket } from "ws"; +import { loadConfig } from "./dev-stack-config.mjs"; + +const execute = promisify(execFile); +const checkout = dirname(dirname(fileURLToPath(import.meta.url))); +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + +export function assertReady(body) { + assert.equal(body?.ok, true, "API is not ready"); + assert.equal(body.dependencies?.worker, "RUNNING", "Temporal worker must be enabled and running"); + assert.equal(body.dependencies?.cliHost, "RUNNING", "CLI host must be running"); +} + +export function observeTerminal(url, { cookie, paneId, sessionId, timeoutMs = 90_000 }) { + return new Promise((resolve, reject) => { + const socket = new WebSocket(url, { headers: { cookie }, handshakeTimeout: 15_000 }); + let ready = false, running = false, stdoutBytes = 0, finished = false; + const timer = setTimeout(() => fail(new Error("OpenCode terminal did not become ready with live stdout")), timeoutMs); + function fail(error) { + if (finished) return; + finished = true; + clearTimeout(timer); + socket.terminate(); + reject(error); + } + socket.on("error", () => fail(new Error("OpenCode terminal connection failed"))); + socket.on("close", () => fail(new Error("OpenCode terminal closed before acceptance"))); + socket.on("message", raw => { + if (finished) return; + try { + const frame = JSON.parse(String(raw)); + if (frame.type === "error") throw new Error(`OpenCode terminal error: ${frame.code}`); + if (frame.type === "ready") { + assert.equal(frame.paneId, paneId, "Unexpected terminal pane identity"); + assert.equal(frame.sessionId, sessionId, "Unexpected terminal session identity"); + assert.equal(frame.runtimeId, "cli:opencode", "Unexpected terminal runtime identity"); + ready = true; + } + if (frame.type === "status") { + if (["ERROR", "EXITED"].includes(frame.status)) throw new Error(`OpenCode terminal ${frame.status}`); + running = frame.status === "RUNNING"; + } + if (frame.type === "output" && frame.stream === "stdout" && typeof frame.data === "string" && frame.data.trim()) { + stdoutBytes += Buffer.byteLength(frame.data); + } + if (ready && running && stdoutBytes > 0) { + finished = true; + clearTimeout(timer); + resolve({ socket, stdoutBytes }); + } + } catch (error) { fail(error); } + }); + }); +} + +async function ownedProcessIds(config) { + const runtime = JSON.parse(await readFile(join(config.state, "runtime.json"), "utf8")); + const roots = [runtime.supervisorPid, ...Object.values(runtime.services ?? {})]; + assert(roots.every(pid => Number.isInteger(pid) && pid > 1), "Invalid owned process metadata"); + const { stdout } = await execute("ps", ["-eo", "pid=,ppid="]); + const pairs = stdout.trim().split("\n").map(line => line.trim().split(/\s+/).map(Number)); + const pids = new Set(roots); + let previous; + do { + previous = pids.size; + for (const [pid, parent] of pairs) if (pids.has(parent)) pids.add(pid); + } while (previous !== pids.size); + return [...pids]; +} + +export async function exerciseDevStack({ root = checkout, reportPath, timeoutMs = 15 * 60_000 }) { + assert(reportPath, "Use --report PATH to retain cleanup evidence outside the state directory"); + let config; + const deadline = Date.now() + timeoutMs; + while (!config && Date.now() < deadline) { + try { config = await loadConfig(root); } + catch (error) { if (error.code !== "ENOENT") throw error; await delay(250); } + } + assert(config, "Dev stack did not initialize its owned state"); + const report = resolve(reportPath); + assert(report !== config.state && !report.startsWith(`${config.state}/`), "Keep the smoke report outside reset-owned state"); + const api = `http://127.0.0.1:${config.ports.api}`; + let cookie = "", csrf; + async function request(path, body) { + const response = await fetch(`${api}${path}`, { + method: body === undefined ? "GET" : "POST", + headers: { ...(cookie ? { cookie } : {}), ...(body === undefined ? {} : { "content-type": "application/json" }), ...(csrf ? { [csrf.headerName]: csrf.csrfToken } : {}) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: AbortSignal.timeout(30_000) + }); + // Never print response bodies, cookies, credentials or WebSocket tickets. + assert(response.ok, `${path} returned HTTP ${response.status}`); + const session = response.headers.getSetCookie().find(value => value.startsWith("space_session=")); + if (session) cookie = session.split(";")[0]; + return response.json(); + } + let ready = false; + while (!ready && Date.now() < deadline) { + try { assertReady(await request("/readyz?strict=1")); ready = true; } + catch { await delay(1000); } + } + assert(ready, "Dev stack did not become ready with workflows and CLI host enabled"); + const web = await fetch(`http://127.0.0.1:${config.ports.web}`, { signal: AbortSignal.timeout(10_000) }); + assert(web.ok && (await web.text()).includes('
'), "Vite did not serve the application"); + assert.equal((await request("/api/setup/status")).setupRequired, true, "Smoke requires a fresh, unclaimed dev stack"); + const token = (await readFile(join(config.state, "secrets/setup-token"), "utf8")).trim(); + const credentials = { email: "dev-stack-smoke@example.test", password: randomBytes(32).toString("hex") }; + // A local reviewer can inspect the same synthetic owner's UI before reset. + // This file is private, never logged or included in the evidence report. + await writeFile(join(config.state, "secrets/smoke-owner.json"), JSON.stringify(credentials), { mode: 0o600, flag: "wx" }); + const claimed = await request("/api/setup/claim", { token, ...credentials }); + assert.equal(claimed.isAuthenticated, true, "Owner claim did not authenticate"); + assert.equal(claimed.user?.role, "ADMIN", "Owner claim did not create an administrator"); + assert(cookie, "Owner claim did not set a session cookie"); + csrf = await request("/api/auth/csrf"); + await request("/api/auth/logout", {}); + cookie = ""; csrf = undefined; + assert.equal((await request("/api/auth/login", credentials)).isAuthenticated, true, "Owner could not sign back in"); + csrf = await request("/api/auth/csrf"); + const rooms = await request("/api/rooms"); + const room = rooms.data.find(entry => entry.name === "Getting Started"); + assert(room, "Getting Started room was not created"); + const panes = await request(`/api/panes?roomId=${encodeURIComponent(room.id)}`); + const pane = panes.data.find(entry => entry.terminalRuntimeId === "cli:opencode"); + assert(pane, "Getting Started has no OpenCode pane"); + const opened = await request(`/api/panes/${encodeURIComponent(pane.id)}/cli/session`, { runtimeId: "cli:opencode", includeTranscript: false }); + assert(opened.websocket?.token, "OpenCode session has no terminal ticket"); + const query = new URLSearchParams({ + sessionId: opened.session.sessionId, token: opened.websocket.token, + protocolVersion: "2", browserClientId: randomUUID(), tabLineageId: randomUUID(), pageClientId: randomUUID(), + clientMode: "INTERACTIVE", initialCols: "100", initialRows: "30" + }); + const observed = await observeTerminal(`${api.replace(/^http/, "ws")}/api/panes/${encodeURIComponent(pane.id)}/cli/terminal?${query}`, { + cookie, paneId: pane.id, sessionId: opened.session.sessionId + }); + try { + const running = await request(`/api/panes/${encodeURIComponent(pane.id)}/cli/session`, { runtimeId: "cli:opencode", includeTranscript: false }); + assert.equal(running.session.sessionId, opened.session.sessionId, "Starting proof unexpectedly replaced the CLI session"); + assert.equal(running.session.status, "RUNNING", "CLI session is not running"); + assertReady(await request("/readyz?strict=1")); + const evidence = { ...config, processIds: await ownedProcessIds(config), roomId: room.id, paneId: pane.id, sessionId: opened.session.sessionId, stdoutBytes: observed.stdoutBytes, ownerLogin: true, workerRunning: true, cliRunning: true }; + await writeFile(report, `${JSON.stringify(evidence, null, 2)}\n`, { mode: 0o600 }); + return { passed: true, ownerLogin: true, workerRunning: true, cliRunning: true, stdoutBytes: observed.stdoutBytes }; + } finally { observed.socket.close(); } +} + +async function portOpen(port) { + return new Promise(resolve => { + const socket = createConnection({ host: "127.0.0.1", port }); + const finish = open => { socket.destroy(); resolve(open); }; + socket.once("connect", () => finish(true)); + socket.once("error", () => finish(false)); + socket.setTimeout(1000, () => finish(false)); + }); +} + +export async function verifyCleanup(reportPath, root = checkout) { + const proof = JSON.parse(await readFile(reportPath, "utf8")); + assert.equal(proof.root, await realpath(root), "Cleanup report belongs to another checkout"); + assert.equal(proof.state, join(proof.root, ".dev-stack")); + assert.match(proof.project, /^spaceapp-dev-[a-f0-9]{12}$/); + for (const path of [proof.state, proof.socketRoot]) { + await assert.rejects(access(path), { code: "ENOENT" }, `Reset left owned state: ${path}`); + } + for (const pid of proof.processIds) { + assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }, `Reset left owned process ${pid}`); + } + for (const port of Object.values(proof.ports)) assert.equal(await portOpen(port), false, `Reset left port ${port} listening`); + for (const kind of ["container", "volume", "network"]) { + const { stdout } = await execute("docker", [kind, "ls", ...(kind === "container" ? ["-a"] : []), "-q", "--filter", `label=com.docker.compose.project=${proof.project}`]); + assert.equal(stdout.trim(), "", `Reset left a dev-stack ${kind}`); + } + return { passed: true, cleaned: true }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + try { + assert(args.length === 2 && ["--report", "--verify-cleanup"].includes(args[0]), "Usage: node scripts/dev-stack-smoke.mjs --report FILE | --verify-cleanup FILE"); + const result = args[0] === "--report" ? await exerciseDevStack({ reportPath: args[1] }) : await verifyCleanup(args[1]); + process.stdout.write(`${JSON.stringify(result)}\n`); + } catch (error) { + process.stderr.write(`Dev-stack smoke failed: ${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/dev-stack.mjs b/scripts/dev-stack.mjs new file mode 100644 index 000000000..7c0507c46 --- /dev/null +++ b/scripts/dev-stack.mjs @@ -0,0 +1,204 @@ +#!/usr/bin/env node +import { createConnection, createServer } from 'node:net'; +import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; +import { childEnvironment, initialize, loadConfig, prepareFiles, preparePty, readPins } from './dev-stack-config.mjs'; +import { Processes } from './dev-stack-processes.mjs'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const action = process.argv[2] || 'up'; +const npm = process.env.npm_execpath; +const npmArgs = args => npm ? [process.execPath, [npm, ...args]] : ['npm', args]; + +async function request(config, action) { + return new Promise((resolve, reject) => { + const socket = createConnection(join(config.socketRoot, 'control.sock')); + socket.setTimeout(300_000, () => socket.destroy(new Error('Dev stack control timed out.'))); + socket.once('connect', () => socket.end(`${action}\n`)); + let data = ''; + socket.on('data', chunk => { data += chunk; }); + socket.once('error', reject); + socket.once('end', () => data.trim() === 'ok' ? resolve() : reject(new Error(data.trim() || 'Dev stack control disconnected.'))); + }); +} + +async function waitFor(check, label, stopped, timeout = 120_000) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (stopped()) throw new Error(`${label} interrupted.`); + if (await check().catch(() => false)) return; + await delay(300); + } + throw new Error(`${label} did not become ready; inspect .dev-stack/logs.`); +} + +async function docker(config, commands) { + const { spawn } = await import('node:child_process'); + await new Promise((resolve, reject) => { + const child = spawn('docker', ['compose', '--project-name', config.project, '--file', join(config.state, 'compose.json'), ...commands], { cwd: config.root, stdio: 'inherit' }); + child.once('error', reject); + child.once('exit', code => code === 0 ? resolve() : reject(new Error(`Docker Compose ${commands[0]} failed (${code}).`))); + }); +} + +async function stopOrReset() { + let config; + try { config = await loadConfig(root); } + catch (error) { if (error.code === 'ENOENT') { console.log('No dev stack state in this checkout.'); return; } throw error; } + try { await request(config, 'down'); } + catch (error) { if (!['ENOENT', 'ECONNREFUSED'].includes(error.code)) throw error; } + await docker(config, ['down', ...(action === 'reset' ? ['--volumes'] : []), '--remove-orphans', '--timeout', '10']); + if (action === 'reset') { + // Check ownership again immediately before deletion. Never accept a caller-supplied state path/project. + await loadConfig(root); + const info = await lstat(config.socketRoot).catch(() => null); + if (info) { + if (info.isSymbolicLink() || await readFile(join(config.socketRoot, 'owner'), 'utf8') !== root) throw new Error('Socket directory ownership changed; refusing reset.'); + await rm(config.socketRoot, { recursive: true }); + } + await rm(config.state, { recursive: true }); + } + console.log(`Dev stack ${action === 'reset' ? 'reset' : 'stopped'} (${config.project}).`); +} + +async function up() { + if (process.platform === 'win32') throw new Error('Run dev:stack inside WSL2, with Docker Desktop WSL integration enabled.'); + const [major, minor] = process.versions.node.split('.').map(Number); + if (major !== 22 || minor < 12) throw new Error('Use Node >=22.12 <23, then npm ci.'); + const config = await initialize(root, process.env); + const pins = await readPins(root); + await prepareFiles(config, pins); + const socket = join(config.socketRoot, 'control.sock'); + try { await request(config, 'ping'); throw new Error('This checkout already has a dev stack running.'); } + catch (error) { if (!['ENOENT', 'ECONNREFUSED'].includes(error.code)) throw error; } + await rm(socket, { force: true }); + let stopping = false; + let failure; + let complete; + const finished = new Promise(resolve => { complete = resolve; }); + const stop = error => { + if (error && !failure) { failure = error; console.error(`DEV STACK FAILURE: ${error.message}`); } + stopping = true; + complete(); + void processes.stop().catch(error => { failure ??= error; }); + }; + const env = await childEnvironment(config); + const secrets = await Promise.all(['postgres-password', 'database-url', 'session-secret', 'opencode-password'].map(name => readFile(join(config.state, 'secrets', name), 'utf8'))); + const processes = new Processes({ ...config, env, secrets, onFailure: stop }); + let watchdog; + let rebuilding = false; + let cleanupDone; + const cleaned = new Promise(resolve => { cleanupDone = resolve; }); + const server = createServer({ allowHalfOpen: true }, connection => { + let message = ''; + connection.on('data', chunk => { message += chunk; if (message.length > 32) connection.destroy(); }); + connection.once('end', () => { + const command = message.trim(); + if (command === 'ping') return connection.end('ok\n'); + if (command === 'down') { stop(); void cleaned.then(error => connection.end(error ? `${error.message}\n` : 'ok\n')); return; } + if (command === 'rebuild') { + if (rebuilding) return connection.end('A rebuild is already running.\n'); + rebuilding = true; + void (async () => { + await processes.stop(['api', 'worker', 'cli-host']); + await build(); + startSources(); + await ready(); + console.log('Internal packages rebuilt; API, worker and CLI host restarted.'); + connection.end('ok\n'); + })().catch(error => { connection.end(`${error.message}\n`); stop(error); }).finally(() => { rebuilding = false; }); + return; + } + connection.end('Unknown control command.\n'); + }); + }); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socket, resolve); }); + const onSignal = () => stop(); + process.once('SIGINT', onSignal); + process.once('SIGTERM', onSignal); + const runNpm = (name, args, options) => { const [command, argv] = npmArgs(args); return processes.run(name, command, argv, options); }; + const build = async () => { + if (stopping) throw new Error('Build interrupted.'); + await runNpm('build-packages', ['run', 'check']); + if (stopping) throw new Error('Build interrupted.'); + await runNpm('build-worker', ['run', 'build', '-w', '@space/worker']); + }; + const startSources = () => { + if (stopping) throw new Error('Startup interrupted.'); + processes.start('cli-host', process.execPath, ['--import', 'tsx', 'packages/cli-host/src/main.ts']); + processes.start('worker', process.execPath, ['apps/worker/dist/worker.js']); + processes.start('api', process.execPath, ['node_modules/tsx/dist/cli.mjs', 'watch', '--clear-screen=false', 'apps/api/src/server.ts']); + }; + const ready = () => waitFor(async () => { + const response = await fetch(`http://127.0.0.1:${config.ports.api}/readyz`, { signal: AbortSignal.timeout(2500) }); + const body = await response.json(); + return response.ok && body.ok === true; + }, 'API /readyz', () => stopping); + try { + console.log(`Starting isolated dev stack ${config.project}; logs: ${join(config.state, 'logs')}`); + // Docker alone needs the contributor's context/plugin configuration. App and provider children never inherit it. + const dockerEnv = Object.fromEntries(['PATH', 'HOME', 'DOCKER_CONFIG', 'DOCKER_HOST', 'DOCKER_CONTEXT', 'DOCKER_CERT_PATH', 'DOCKER_TLS_VERIFY'].filter(key => process.env[key]).map(key => [key, process.env[key]])); + await processes.run('docker-up', 'docker', ['compose', '--project-name', config.project, '--file', join(config.state, 'compose.json'), 'up', '-d', '--wait', '--wait-timeout', '240'], { env: dockerEnv }); + await build(); + await preparePty(root); + await processes.run('pty-check', process.execPath, ['scripts/contributor-smoke.mjs']); + await processes.run('migrate', process.execPath, ['packages/db/dist/migrate.js']); + const vendor = join(config.state, 'vendor'); + await mkdir(vendor, { recursive: true, mode: 0o700 }); + const packageName = pins.opencode.slice(0, pins.opencode.lastIndexOf('@')); + const version = pins.opencode.slice(pins.opencode.lastIndexOf('@') + 1); + const installed = await readFile(join(vendor, 'node_modules', packageName, 'package.json'), 'utf8').then(JSON.parse).catch(() => null); + if (installed?.version !== version) { + await writeFile(join(vendor, 'package.json'), JSON.stringify({ private: true, name: 'spaceapp-dev-tools', dependencies: { [packageName]: version } }), { mode: 0o600 }); + await runNpm('install-opencode', ['install', '--prefix', vendor, '--no-audit', '--no-fund'], { env: { ...env, npm_config_cache: join(config.state, 'npm-cache') } }); + } + const provider = join(config.state, 'providers/opencode'); + const opencodeEnv = { ...env, HOME: provider, TMPDIR: join(provider, 'tmp'), XDG_CONFIG_HOME: join(provider, 'config'), XDG_DATA_HOME: join(provider, 'data'), XDG_CACHE_HOME: join(provider, 'cache'), XDG_STATE_HOME: join(provider, 'state'), OPENCODE_DISABLE_AUTOUPDATE: 'true', OPENCODE_SERVER_USERNAME: 'opencode', OPENCODE_SERVER_PASSWORD: await readFile(join(config.state, 'secrets/opencode-password'), 'utf8') }; + processes.start('opencode', join(vendor, 'node_modules/.bin/opencode'), ['serve', '--hostname', '127.0.0.1', '--port', String(config.ports.opencode)], { env: opencodeEnv, cwd: join(config.state, 'workspaces') }); + await waitFor(async () => (await fetch(`http://127.0.0.1:${config.ports.opencode}/api/info`, { signal: AbortSignal.timeout(2000), headers: { authorization: `Basic ${Buffer.from(`opencode:${opencodeEnv.OPENCODE_SERVER_PASSWORD}`).toString('base64')}` } })).ok, 'OpenCode server', () => stopping); + startSources(); + processes.start('web', process.execPath, [join(root, 'node_modules/vite/bin/vite.js'), '--host', '127.0.0.1', '--port', String(config.ports.web), '--strictPort'], { cwd: join(root, 'apps/web') }); + await ready(); + await waitFor(async () => (await fetch(`http://127.0.0.1:${config.ports.web}/`, { signal: AbortSignal.timeout(2000) })).ok, 'Vite', () => stopping); + const setup = await fetch(`http://127.0.0.1:${config.ports.api}/api/setup/status`).then(response => response.json()); + console.log(`DEV_STACK_READY http://127.0.0.1:${config.ports.web}`); + if (setup.setupRequired) console.log(`One-time owner setup token: ${await readFile(join(config.state, 'secrets/setup-token'), 'utf8')}`); + console.log('Browser/companion services are disabled. API and web reload automatically. After packages/* edits: npm run dev:stack -- rebuild'); + let misses = 0; + let checking = false; + watchdog = setInterval(async () => { + if (checking || rebuilding || stopping) return; + checking = true; + try { + const response = await fetch(`http://127.0.0.1:${config.ports.api}/readyz`, { signal: AbortSignal.timeout(2500) }); + const healthy = response.ok && (await response.json()).ok === true; + misses = healthy ? 0 : misses + 1; + } catch { misses++; } + finally { checking = false; } + if (misses >= 5) stop(new Error('API readiness failed for 15 seconds (including reload grace).')); + }, 3000); + await finished; + } catch (error) { stop(error); } + finally { + clearInterval(watchdog); + stopping = true; + await processes.stop().catch(error => { failure ??= error; console.error(error.message); }); + await docker(config, ['down', '--remove-orphans', '--timeout', '10']).catch(error => { failure ??= error; console.error(error.message); }); + await rm(join(config.state, 'runtime.json'), { force: true }); + cleanupDone(failure); + await new Promise(resolve => server.close(resolve)); + await rm(socket, { force: true }); + process.off('SIGINT', onSignal); + process.off('SIGTERM', onSignal); + } + if (failure) throw failure; +} + +try { + if (['down', 'reset'].includes(action)) await stopOrReset(); + else if (action === 'rebuild') await request(await loadConfig(root), 'rebuild'); + else if (action === 'up') await up(); + else throw new Error('Usage: npm run dev:stack [-- up|down|reset|rebuild]'); +} catch (error) { console.error(`dev:stack: ${error.message}`); process.exitCode = 1; } diff --git a/scripts/public-export.mjs b/scripts/public-export.mjs index c7dbddb50..b8be97f6e 100644 --- a/scripts/public-export.mjs +++ b/scripts/public-export.mjs @@ -134,6 +134,14 @@ const publicScripts = new Set([ "scripts/quality-known-limitations.mjs", "scripts/tests/quality-known-limitations.test.mjs", "scripts/contributor-smoke.mjs", + "scripts/dev-stack.mjs", + "scripts/dev-stack-config.mjs", + "scripts/dev-stack-processes.mjs", + "scripts/dev-stack-opencode.mjs", + "scripts/dev-stack-smoke.mjs", + "scripts/tests/dev-stack.test.mjs", + "scripts/tests/dev-stack-processes.test.mjs", + "scripts/tests/dev-stack-smoke.test.mjs", "scripts/fixtures/offline-cli.mjs", "scripts/tests/contributor-smoke.test.mjs" ]); diff --git a/scripts/tests/dev-stack-processes.test.mjs b/scripts/tests/dev-stack-processes.test.mjs new file mode 100644 index 000000000..14540ffbf --- /dev/null +++ b/scripts/tests/dev-stack-processes.test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +test('a child exit is reported and stopping terminates the owned child group', async t => { + const { Processes } = await import('../dev-stack-processes.mjs'); + const directory = await mkdtemp(join(tmpdir(), 'dev-process-test-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const failures = []; + const processes = new Processes({ root: directory, state: directory, project: 'test-dev-stack', env: process.env, secrets: ['sensitive-value'], onFailure: error => failures.push(error.message) }); + t.after(() => processes.stop()); + const normal = processes.start('running', process.execPath, ['-e', 'setInterval(()=>{},1000)']); + const failed = processes.start('broken', process.execPath, ['-e', 'console.log("sensitive-value");process.exit(7)']); + await failed.done; + assert.equal(failures.length, 1); + assert.match(failures[0], /broken.*7/); + assert.doesNotMatch(await readFile(join(directory, 'logs/broken.log'), 'utf8'), /sensitive-value/); + await processes.stop(); + assert.throws(() => process.kill(normal.child.pid, 0), { code: 'ESRCH' }); +}); diff --git a/scripts/tests/dev-stack-smoke.test.mjs b/scripts/tests/dev-stack-smoke.test.mjs new file mode 100644 index 000000000..19b7f94b5 --- /dev/null +++ b/scripts/tests/dev-stack-smoke.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { WebSocketServer } from "ws"; +import { assertReady, observeTerminal } from "../dev-stack-smoke.mjs"; + +test("readiness cannot pass with disabled workflows or an unavailable CLI host", () => { + assert.throws(() => assertReady({ ok: true, dependencies: { worker: "disabled", cliHost: "RUNNING" } }), /worker/); + assert.throws(() => assertReady({ ok: true, dependencies: { worker: "RUNNING", cliHost: "UNAVAILABLE" } }), /CLI host/); + assert.throws(() => assertReady({ ok: false, dependencies: { worker: "RUNNING", cliHost: "RUNNING" } }), /ready/); +}); + +async function terminal(frames, operation) { + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await new Promise(resolve => server.once("listening", resolve)); + server.on("connection", (socket, request) => { + assert.equal(request.headers.cookie, "space_session=synthetic"); + for (const frame of frames) socket.send(JSON.stringify(frame)); + }); + try { + return await operation(`ws://127.0.0.1:${server.address().port}`); + } finally { + for (const socket of server.clients) socket.terminate(); + await new Promise(resolve => server.close(resolve)); + } +} + +const ready = { type: "ready", paneId: "pane:smoke", sessionId: "session:smoke", runtimeId: "cli:opencode" }; +const options = { cookie: "space_session=synthetic", paneId: ready.paneId, sessionId: ready.sessionId, timeoutMs: 100 }; + +test("terminal acceptance requires matching identity, RUNNING and real stdout", async () => { + await terminal([ready, { type: "status", status: "RUNNING" }, { type: "output", stream: "stdout", data: "OpenCode" }], async url => { + const observed = await observeTerminal(url, options); + assert.equal(observed.stdoutBytes, 8); + observed.socket.terminate(); + }); +}); + +test("an allocated or silent terminal is not a running UI", async () => { + for (const frames of [[ready], [ready, { type: "status", status: "RUNNING" }]]) { + await terminal(frames, url => assert.rejects(observeTerminal(url, options), /did not become ready/)); + } +}); + +test("another session's output and terminal errors fail the proof", async () => { + await terminal([{ ...ready, sessionId: "session:other" }], url => + assert.rejects(observeTerminal(url, options), /identity/)); + await terminal([{ type: "error", code: "CLI_TERMINAL_ATTACH_FAILED", message: "synthetic" }], url => + assert.rejects(observeTerminal(url, options), /terminal error/)); +}); diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs new file mode 100644 index 000000000..5879a94a5 --- /dev/null +++ b/scripts/tests/dev-stack.test.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, stat, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +const moduleUrl = new URL('../dev-stack-config.mjs', import.meta.url); +const load = () => import(moduleUrl); + +test('reads current image digests and OpenCode package from their source of truth', async t => { + const { readPins } = await load(); + const pins = await readPins(new URL('../../', import.meta.url)); + assert.match(pins.postgres, /^pgvector\/pgvector:.+@sha256:[a-f0-9]{64}$/); + assert.match(pins.temporal, /^temporalio\/auto-setup:.+@sha256:[a-f0-9]{64}$/); + assert.match(pins.opencode, /^(?:@opencode\/cli|opencode-ai)@\d+\.\d+\.\d+/); + const root = await mkdtemp(join(tmpdir(), 'dev-pins-test-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(join(root, 'packages/run-spaceapp/templates'), { recursive: true }); + await writeFile(join(root, 'Dockerfile'), 'RUN npm install --global opencode-ai@9.8.7'); + await writeFile(join(root, 'packages/run-spaceapp/templates/compose.yml'), `services:\n postgres:\n image: pgvector/test@sha256:${'a'.repeat(64)}\n temporal:\n image: temporal/test@sha256:${'b'.repeat(64)}\n`); + const changed = await readPins(root); + assert.equal(changed.opencode, 'opencode-ai@9.8.7'); + assert.equal(changed.postgres, `pgvector/test@sha256:${'a'.repeat(64)}`); + await writeFile(join(root, 'packages/run-spaceapp/templates/compose.yml'), ' postgres:\n image: pgvector/test:latest'); + await assert.rejects(readPins(root), /pinned postgres/); +}); + +test('secrets are private and persistent, while container ports stay on loopback', async t => { + const { initialize, composeDocument, childEnvironment } = await load(); + const root = await mkdtemp(join(tmpdir(), 'dev-stack-test-')); + t.after(() => rm(root, { recursive: true, force: true })); + const config = await initialize(root, { SPACE_DEV_API_PORT: '15410', SPACE_DEV_WEB_PORT: '15411' }); + const again = await initialize(root, {}); + assert.equal(again.project, config.project); + assert.equal(again.ports.api, 15410); + for (const name of ['postgres-password', 'database-url', 'session-secret', 'setup-token', 'opencode-password']) { + assert.equal((await stat(join(config.state, 'secrets', name))).mode & 0o777, 0o600); + assert.equal(await readFile(join(config.state, 'secrets', name), 'utf8'), await readFile(join(again.state, 'secrets', name), 'utf8')); + } + const compose = composeDocument(config, { postgres: 'postgres@sha256:abc', temporal: 'temporal@sha256:def' }); + assert.equal(compose.services.postgres.ports[0], '127.0.0.1:15432:5432'); + assert.equal(compose.services.temporal.ports[0], '127.0.0.1:17233:7233'); + assert.deepEqual(Object.keys(compose.services), ['postgres', 'temporal']); + const env = await childEnvironment(config, { PATH: '/usr/bin:/bin', OPENAI_API_KEY: 'do-not-inherit', SPACE_DEV_LOGIN: 'true', SPACE_DATABASE_URL: 'operator-db', NODE_OPTIONS: '--require bad' }); + assert.equal(env.OPENAI_API_KEY, undefined); + assert.equal(env.NODE_OPTIONS, undefined); + 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_WORKFLOWS_ENABLED, 'true'); + assert.match(env.SPACE_DATABASE_URL, /@127\.0\.0\.1:15432\/spaceapp$/); + assert.equal(env.SPACE_API_ORIGIN, 'http://127.0.0.1:15410'); +}); + +test('rejects unsafe or duplicate ports and refuses unowned state', async t => { + const { initialize } = await load(); + const root = await mkdtemp(join(tmpdir(), 'dev-stack-test-')); + t.after(() => rm(root, { recursive: true, force: true })); + await assert.rejects(initialize(root, { SPACE_DEV_API_PORT: '4910junk' }), /port/i); + await assert.rejects(initialize(root, { SPACE_DEV_API_PORT: '15411' }), /port/i); + const config = await initialize(root, {}); + await writeFile(join(config.state, 'owner.json'), JSON.stringify({ root: '/different-checkout' })); + await assert.rejects(initialize(root, {}), /belong|owned|owner/i); +}); + +test('repairs the packaged macOS PTY helper without broad dependency permission changes', async t => { + const { preparePty } = await load(); + const root = await mkdtemp(join(tmpdir(), 'dev-pty-test-')); + t.after(() => rm(root, { recursive: true, force: true })); + const directory = join(root, 'node_modules/node-pty/prebuilds/darwin-arm64'); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, 'spawn-helper'), 'fixture', { mode: 0o644 }); + await writeFile(join(directory, 'pty.node'), 'fixture', { mode: 0o644 }); + await preparePty(root, 'darwin', 'arm64'); + assert.equal((await stat(join(directory, 'spawn-helper'))).mode & 0o777, 0o755); + assert.equal((await stat(join(directory, 'pty.node'))).mode & 0o777, 0o644); +}); diff --git a/scripts/tests/public-suite.test.mjs b/scripts/tests/public-suite.test.mjs index 1b541bb80..bd22b3d8c 100644 --- a/scripts/tests/public-suite.test.mjs +++ b/scripts/tests/public-suite.test.mjs @@ -21,3 +21,6 @@ import "./verify-export-provenance.test.mjs"; import './opencode-first-run.test.mjs'; import './opencode-standalone.test.mjs'; +import './dev-stack.test.mjs'; +import './dev-stack-processes.test.mjs'; +import './dev-stack-smoke.test.mjs';