diff --git a/.dockerignore b/.dockerignore index 243260fe7..7339b1554 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,6 +15,10 @@ node_modules **/node_modules ui/.vite electron/dist +# The server image never builds or ships the Electron desktop workspace. Keep +# it out of the post-install COPY so pnpm does not discover a new eighth +# workspace and auto-install Electron after the cacheable dependency layer. +apps/desktop # Editor / IDE .vscode diff --git a/.github/workflows/docker-smoke.yml b/.github/workflows/docker-smoke.yml new file mode 100644 index 000000000..aecfc8fb2 --- /dev/null +++ b/.github/workflows/docker-smoke.yml @@ -0,0 +1,93 @@ +name: Docker Smoke + +on: + workflow_dispatch: + pull_request: + branches: [dev, master] + paths: + - ".github/workflows/docker-smoke.yml" + - "Dockerfile" + - ".dockerignore" + - "docker-compose.yml" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "turbo.json" + - "scripts/docker-runtime-smoke*.mjs" + - "scripts/guardian/**" + - "src/core/paths.ts" + - "src/core/runtime-profile.ts" + - "src/main.ts" + - "src/server/**" + - "src/webui/**" + - "src/workspaces/**" + - "services/uta/**" + - "packages/**" + - "default/**" + push: + # Dev-targeted PRs provide integration coverage. Keep direct push smoke on + # master so routine serial merges do not duplicate the expensive image job. + branches: [master] + paths: + - ".github/workflows/docker-smoke.yml" + - "Dockerfile" + - ".dockerignore" + - "docker-compose.yml" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "turbo.json" + - "scripts/docker-runtime-smoke*.mjs" + - "scripts/guardian/**" + - "src/core/paths.ts" + - "src/core/runtime-profile.ts" + - "src/main.ts" + - "src/server/**" + - "src/webui/**" + - "src/workspaces/**" + - "services/uta/**" + - "packages/**" + - "default/**" + +permissions: + contents: read + +concurrency: + group: docker-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: docker/setup-buildx-action@v3 + + - name: Build server image + uses: docker/build-push-action@v6 + with: + context: . + load: true + tags: openalice:ci + cache-from: type=gha,scope=openalice-docker + cache-to: type=gha,mode=max,scope=openalice-docker + + - name: Exercise Guardian, Workspace PTY, and CLI gateway + env: + OPENALICE_DOCKER_SMOKE_LOG_FILE: artifacts/docker-smoke.log + run: node scripts/docker-runtime-smoke.mjs --skip-build --image openalice:ci + + - name: Upload failure diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: docker-smoke-logs + path: artifacts/docker-smoke.log + if-no-files-found: ignore + retention-days: 7 diff --git a/AGENTS.md b/AGENTS.md index d8f288d3f..6284783fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,6 +108,7 @@ Add checks according to the touched surface: | Workspace issues, schedules, headless dispatch | Follow [Workspace issues and scheduling](docs/workspace-issues-and-scheduling.md) | | Guardian locks, process ownership, takeover | `pnpm test:guardian-recovery`; exercise the real launcher path | | Desktop, IPC, PTY, managed Pi, shell, packaging | Follow [Managed Workspace runtime](docs/managed-workspace-runtime.md) and run the matching Electron/package smoke | +| Docker/server image, Compose, remote deployment | Follow [Docker deployment](docs/docker-deployment.md) and run `pnpm docker:smoke` | | Persisted data shape | Add an idempotent migration + spec, register it, then run `pnpm build:migration-index` | | Onboarding/first run/auth | Use isolated data; exercise dev and packaged onboarding paths where relevant | @@ -148,6 +149,8 @@ Read the relevant guide before editing its subsystem: delivery modes, promotions, external contributions, and risk gates. - [[docs/managed-workspace-runtime.md]] — [Managed Workspace runtime](docs/managed-workspace-runtime.md): Electron packaging, managed Pi, PortableGit/Bash, runtime profiles, and Workspace PATH. +- [[docs/docker-deployment.md]] — [Docker deployment](docs/docker-deployment.md): server image topology, + remote-host safety, persistence, health, and container acceptance. - [[docs/workspace-agent-guidance.md]] — [Workspace agent guidance](docs/workspace-agent-guidance.md): prompt layers, skill ownership, live CLI authority, and guidance versioning. - [[docs/workspace-lifecycle.md]] — [Workspace and Session lifecycle](docs/workspace-lifecycle.md): offboarding, diff --git a/Dockerfile b/Dockerfile index 3e4fc90d8..94d78b8ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,14 +14,14 @@ WORKDIR /src # pnpm via corepack (ships with Node 22). Pin the version we develop with # so the install plan is reproducible. -RUN corepack enable && corepack prepare pnpm@10.29.2 --activate +RUN corepack enable && corepack prepare pnpm@11.7.0 --activate # Cache-friendly: copy only manifests first so the dep-resolution layer -# stays warm across source-only changes. `scripts/` joins this layer +# stays warm across source-only changes. The postinstall helper joins this layer # because the root postinstall hook (`fix-pty-perms.mjs`) runs at the end # of `pnpm install` and must already exist on disk. COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ -COPY scripts ./scripts +COPY scripts/fix-pty-perms.mjs ./scripts/fix-pty-perms.mjs COPY packages/guardian-runtime/package.json packages/guardian-runtime/ COPY packages/ibkr/package.json packages/ibkr/ COPY packages/opentypebb/package.json packages/opentypebb/ @@ -31,16 +31,12 @@ COPY ui/package.json ui/ RUN pnpm install --frozen-lockfile -# Source + build. Mirrors root `pnpm build` (turbo: workspace packages + -# UI Vite build + services/uta, then `tsup` bundles the Alice backend -# into `dist/main.js` — UTA ends up at `services/uta/dist/uta.js`), but -# EXCLUDES the Electron desktop shell: `apps/desktop` matches the -# `apps/*` workspace glob, yet its package.json is deliberately absent -# from the manifest layer above, so its deps (electron, ~500MB) are -# never installed — an unfiltered `turbo run build` would die on TS2307 -# "Cannot find module 'electron'". The server image never needs it. +# Source + build. Mirrors root `pnpm build` (turbo: workspace packages + UI +# Vite build + services/uta, then `tsup` bundles Alice into `dist/main.js`). +# `.dockerignore` removes `apps/desktop`, so Electron is not a discovered +# workspace and cannot trigger a late dependency install in this server build. COPY . . -RUN pnpm exec turbo run build --filter='!@traderalice/desktop' \ +RUN pnpm exec turbo run build \ && pnpm exec tsup src/main.ts --format esm --dts # Strip dev deps (typescript, turbo, vitest, vite, …) before the runtime @@ -71,11 +67,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Two agent CLIs installed globally so they're on PATH for the PTY # sessions OpenAlice spawns. Both come from npm (codex's npm package is # a thin wrapper that pulls down the Rust binary on install). -# Smoke-checking versions at build time fails the build loud if either -# package broke. +# Keep these explicit: an unchanged Dockerfile layer must resolve to the same +# runtime instead of silently changing when an upstream `latest` tag moves. +ARG CLAUDE_CODE_VERSION=2.1.202 +ARG CODEX_VERSION=0.144.1 RUN npm install -g \ - @anthropic-ai/claude-code \ - @openai/codex \ + "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ + "@openai/codex@${CODEX_VERSION}" \ && claude --version \ && codex --version \ && npm cache clean --force @@ -88,6 +86,18 @@ COPY --from=build /src/services/uta/dist ./services/uta/dist COPY --from=build /src/ui/dist ./ui/dist COPY --from=build /src/default ./default COPY --from=build /src/src/workspaces/templates ./src/workspaces/templates +# Workspace CLI launchers and their sibling payload are runtime resources just +# like templates. Keep the image-owned directory intact for `cliBinPath()`, and +# install the same self-contained launcher set into /usr/local/bin. Debian login +# shells reset PATH, so ENV alone would make these commands disappear from the +# actual Workspace terminal. Installing only the launchers would also break +# their sibling `openalice-cli.cjs` lookup. +COPY --from=build /src/src/workspaces/cli/bin ./src/workspaces/cli/bin +RUN install -m 0755 /app/src/workspaces/cli/bin/alice /usr/local/bin/alice \ + && install -m 0755 /app/src/workspaces/cli/bin/alice-uta /usr/local/bin/alice-uta \ + && install -m 0755 /app/src/workspaces/cli/bin/alice-workspace /usr/local/bin/alice-workspace \ + && install -m 0755 /app/src/workspaces/cli/bin/traderhub /usr/local/bin/traderhub \ + && install -m 0644 /app/src/workspaces/cli/bin/openalice-cli.cjs /usr/local/bin/openalice-cli.cjs # tsup bundles backend deps into the entry files where possible, but # native modules (node-pty, longbridge, etc.) stay as runtime requires. COPY --from=build /src/node_modules ./node_modules @@ -126,6 +136,11 @@ ENV OPENALICE_APP_HOME=/app \ VOLUME ["/data"] EXPOSE 47331 +# Compose and remote orchestrators can distinguish "container process exists" +# from "Alice HTTP surface is ready" without requiring curl in the slim image. +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD ["node", "-e", "const p=process.env.OPENALICE_WEB_PORT||'47331';fetch('http://127.0.0.1:'+p+'/api/version').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] + # tini handles signal forwarding + zombie reaping; Guardian then spawns # UTA → Alice and supervises the lifecycle (see scripts/guardian/prod.mjs). ENTRYPOINT ["/usr/bin/tini", "--"] diff --git a/docker-compose.yml b/docker-compose.yml index db67faf7e..38c58e8f4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: image: openalice:local container_name: openalice restart: unless-stopped + stop_grace_period: 30s ports: # Web UI. The MCP server (47332) is intentionally NOT exposed — it's # only consumed by the CLIs running inside this container. @@ -32,6 +33,13 @@ services: # the interactive OAuth flow. stdin_open: true tty: true + # Bound Docker's json-file growth on long-running remote hosts. Product + # state remains in /data; these are only container stdout/stderr logs. + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" volumes: openalice-data: diff --git a/docs/README.md b/docs/README.md index cdd979fa5..a0c7349b2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ GitHub navigation. | [[docs/project-structure.md]] | [Project structure](project-structure.md) | Process boundaries, source ownership, state roots, architectural entry points | | [[docs/development-workflow.md]] | [Development workflow](development-workflow.md) | Branches, delivery modes, PRs, promotions, external review, risk gates | | [[docs/managed-workspace-runtime.md]] | [Managed Workspace runtime](managed-workspace-runtime.md) | Electron packaging, managed Pi, PortableGit/Bash, runtime profile, Workspace PATH | +| [[docs/docker-deployment.md]] | [Docker deployment](docker-deployment.md) | Server image topology, remote-host safety, persistence, health, and container acceptance | | [[docs/workspace-agent-guidance.md]] | [Workspace agent guidance](workspace-agent-guidance.md) | Always-loaded prompt contract, skill ownership, live CLI authority, guidance versioning | | [[docs/workspace-lifecycle.md]] | [Workspace and Session lifecycle](workspace-lifecycle.md) | Offboarding, departed directories, handoff, restore/purge, Session retirement | | [[docs/workspace-issues-and-scheduling.md]] | [Workspace issues and scheduling](workspace-issues-and-scheduling.md) | Markdown issue contract, global board, schedule scanner, headless execution, Inbox delivery | diff --git a/docs/docker-deployment.md b/docs/docker-deployment.md new file mode 100644 index 000000000..264c2e43f --- /dev/null +++ b/docs/docker-deployment.md @@ -0,0 +1,115 @@ +# Docker Deployment + +This guide owns the OpenAlice server-image contract, Docker Compose lifecycle, +remote-host safety boundary, and container smoke requirements. It complements +[[docs/project-structure.md]] and [[docs/managed-workspace-runtime.md]]. + +## Topology + +The image is the non-Electron production topology: + +```text +tini (PID 1) +└── scripts/guardian/prod.mjs + ├── Alice HTTP + Workspace process + └── optional UTA process + +/app immutable image resources +/data persistent operator state and Workspaces +``` + +Only Alice's web port `47331` is published. The CLI/MCP gateway and UTA stay on +container loopback. Workspace agents reach Alice through the injected +`alice`, `alice-workspace`, `alice-uta`, and `traderhub` CLI launchers; remote +clients must not expose the internal tool gateway as a replacement API. + +The server image installs pinned Claude Code and Codex runtimes. Unlike the +desktop package, it does not currently bundle managed Pi, and it does not +install opencode. Version changes are deliberate Dockerfile changes so a +cached/rebuilt image cannot silently acquire a different native runtime. + +## Start and Authenticate + +```bash +docker compose up -d --build +docker compose ps +docker compose logs openalice +``` + +The first boot prints a one-time admin token. Store it in a password manager +and use it on the web login screen. The token hash, sessions, Workspaces, +credentials, reports, and trading state persist in the `openalice-data` +volume. Authenticate the agent runtime you intend to use: + +```bash +docker exec -it openalice claude +docker exec -it openalice codex login +``` + +Never set `OPENALICE_DISABLE_AUTH=1` on a remote deployment. That switch exists +for isolated automated smokes only. Expose port `47331` through HTTPS (for +example Caddy, nginx, Tailscale, or a private tunnel) rather than publishing an +unencrypted public endpoint. Configure `OPENALICE_TRUSTED_PROXIES` only with +the actual proxy peer addresses; an overly broad trusted-proxy range weakens +the localhost/auth boundary. + +## Health and Lifecycle + +The image healthcheck calls the public `/api/version` route from container +loopback. `docker compose ps` should report `healthy` after Alice is ready. +`stop_grace_period: 30s` gives Guardian time to stop PTYs and UTA before Docker +forces termination. Compose also rotates stdout/stderr logs (`10m`, three +files) so an always-on host does not grow an unbounded Docker json log. + +Useful operations: + +```bash +docker compose logs --tail=200 -f openalice +docker compose restart openalice +docker compose down +docker compose up -d --build +``` + +`docker compose down` preserves the named volume. `docker compose down -v` is +a factory reset and permanently removes user data. + +## Backup and Restore + +Stop the container before taking a filesystem-consistent volume snapshot: + +```bash +docker compose stop openalice +docker run --rm \ + -v openalice_openalice-data:/data:ro \ + -v "$PWD":/backup \ + alpine tar -czf /backup/openalice-data.tgz -C /data . +docker compose start openalice +``` + +Compose derives the volume prefix from the project directory; confirm the real +name with `docker volume ls` before backup. Restore into an empty volume while +OpenAlice is stopped. Treat the archive as sensitive: it can contain sealed +broker credentials, the local sealing key, agent logins, reports, and private +Workspace history. + +## Runtime Acceptance + +`pnpm docker:smoke` is the local definition of a usable server image. It: + +1. builds an isolated, uniquely tagged image; +2. starts it in lite mode with a temporary Docker volume and random host port; +3. waits for Alice HTTP readiness; +4. creates a real Chat Workspace with the shell adapter; +5. opens the real Workspace PTY WebSocket; +6. runs `alice` inside that PTY and requires a live CLI manifest response; +7. offboards the Workspace and removes its container, volume, and owned image. + +The smoke uses no AI credential and no broker. It deliberately checks an +observable CLI round trip rather than only asserting that files exist. Docker +build cache is shared infrastructure and is retained; only resources owned by +the smoke are deleted. Use `--keep` or `--keep-image` for investigation. + +CI builds with BuildKit's GitHub cache, reuses that caller-owned image with +`--skip-build --image openalice:ci`, and uploads redacted container diagnostics +on failure. The Docker workflow runs for deployment/runtime surfaces on PRs to +`dev` or `master`, and again for matching direct changes on `master`. diff --git a/docs/project-structure.md b/docs/project-structure.md index 9a85c800c..132086f79 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -5,6 +5,7 @@ and persistent-state layout. Update it when a top-level subsystem moves or a new long-lived process, package, or state root is introduced. Related guides: [[docs/managed-workspace-runtime.md]], +[[docs/docker-deployment.md]], [[docs/workspace-lifecycle.md]], [[docs/workspace-issues-and-scheduling.md]], [[docs/conversation-provenance.md]], and [[docs/market-data-architecture.md]]. diff --git a/package.json b/package.json index 07ebb6195..f1ac77a46 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "scripts": { "dev": "tsx scripts/guardian/dev.ts", "dev:onboarding": "tsx scripts/onboarding-test-dev.ts", + "docker:build": "docker build --tag openalice:local .", + "docker:smoke": "node scripts/docker-runtime-smoke.mjs", "prebuild": "tsx scripts/build-migration-index.ts", "build": "turbo run build && tsup src/main.ts --format esm --dts", "build:migration-index": "tsx scripts/build-migration-index.ts", diff --git a/scripts/docker-runtime-smoke-lib.mjs b/scripts/docker-runtime-smoke-lib.mjs new file mode 100644 index 000000000..8c2e4789f --- /dev/null +++ b/scripts/docker-runtime-smoke-lib.mjs @@ -0,0 +1,84 @@ +import { randomUUID } from 'node:crypto' + +export function buildDockerRuntimeSmokePlan(argv, opts = {}) { + const errors = [] + const warnings = [] + const flags = new Set() + let image = null + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (arg === '--') continue + if (arg === '--image') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) errors.push('[docker-smoke] --image requires a tag') + else { + image = value + index += 1 + } + continue + } + if (['--skip-build', '--keep', '--keep-image', '--help', '-h'].includes(arg)) flags.add(arg) + else errors.push(`[docker-smoke] unknown option: ${arg}`) + } + + const skipBuild = flags.has('--skip-build') + if (skipBuild && !image) errors.push('[docker-smoke] --skip-build requires --image ') + + const suffix = (opts.randomUUID?.() ?? randomUUID()).replaceAll('-', '').slice(0, 12).toLowerCase() + const ownsImage = image === null + const resolvedImage = image ?? `openalice:docker-smoke-${suffix}` + if (!ownsImage && flags.has('--keep-image')) { + warnings.push('[docker-smoke] --keep-image has no effect for a caller-owned --image tag') + } + + return { + errors, + warnings, + options: { + help: flags.has('--help') || flags.has('-h'), + image: resolvedImage, + keep: flags.has('--keep'), + keepImage: flags.has('--keep-image'), + ownsImage, + skipBuild, + suffix, + containerName: `openalice-docker-smoke-${suffix}`, + volumeName: `openalice-docker-smoke-${suffix}`, + }, + } +} + +export function parsePublishedPort(raw) { + const line = raw.trim().split(/\r?\n/).find(Boolean) + if (!line) throw new Error('Docker did not publish port 47331') + const match = line.match(/:(\d+)$/) + const port = match ? Number.parseInt(match[1], 10) : Number.NaN + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`cannot parse Docker published port from ${JSON.stringify(line)}`) + } + return port +} + +export function stripTerminalControl(text) { + return text + .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '') + .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/\r/g, '') +} + +export function redactDockerLogs(text) { + const lines = text.split(/\r?\n/) + let redactNextToken = false + return lines.map((line) => { + if (line.includes('First-run admin token')) { + redactNextToken = true + return line + } + if (redactNextToken && /^\s+[A-Za-z0-9_-]{24,}\s*$/.test(line)) { + redactNextToken = false + return ' [ephemeral admin token redacted]' + } + return line + }).join('\n') +} diff --git a/scripts/docker-runtime-smoke-lib.spec.ts b/scripts/docker-runtime-smoke-lib.spec.ts new file mode 100644 index 000000000..bfa3681cd --- /dev/null +++ b/scripts/docker-runtime-smoke-lib.spec.ts @@ -0,0 +1,94 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +import { + buildDockerRuntimeSmokePlan, + parsePublishedPort, + redactDockerLogs, + stripTerminalControl, +} from './docker-runtime-smoke-lib.mjs' + +describe('Docker runtime smoke plan', () => { + it('owns a unique image for the default build-and-smoke path', () => { + const plan = buildDockerRuntimeSmokePlan([], { randomUUID: () => 'ABCDEF12-3456-7890-abcd-ef1234567890' }) + + expect(plan.errors).toEqual([]) + expect(plan.options).toMatchObject({ + image: 'openalice:docker-smoke-abcdef123456', + ownsImage: true, + skipBuild: false, + containerName: 'openalice-docker-smoke-abcdef123456', + }) + }) + + it('requires a caller-owned image when skipping the build', () => { + expect(buildDockerRuntimeSmokePlan(['--skip-build']).errors).toContain( + '[docker-smoke] --skip-build requires --image ', + ) + const plan = buildDockerRuntimeSmokePlan(['--skip-build', '--image', 'openalice:ci']) + expect(plan.errors).toEqual([]) + expect(plan.options).toMatchObject({ image: 'openalice:ci', ownsImage: false, skipBuild: true }) + }) + + it('parses Docker port output and terminal output deterministically', () => { + expect(parsePublishedPort('127.0.0.1:49173\n')).toBe(49173) + expect(parsePublishedPort('[::1]:49174')).toBe(49174) + expect(() => parsePublishedPort('')).toThrow('did not publish') + expect(stripTerminalControl('\u001b[32mOpenAlice\u001b[0m\r\n')).toBe('OpenAlice\n') + }) + + it('redacts the ephemeral first-run token from failure logs', () => { + const logs = [ + 'First-run admin token (save this):', + '', + ' abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG', + 'engine started', + ].join('\n') + expect(redactDockerLogs(logs)).not.toContain('abcdefghijklmnopqrstuvwxyz') + expect(redactDockerLogs(logs)).toContain('[ephemeral admin token redacted]') + }) +}) + +describe('Dockerfile runtime contract', () => { + const root = resolve(import.meta.dirname, '..') + const dockerfile = readFileSync(resolve(root, 'Dockerfile'), 'utf8') + const dockerignore = readFileSync(resolve(root, '.dockerignore'), 'utf8') + const compose = readFileSync(resolve(root, 'docker-compose.yml'), 'utf8') + const workflow = readFileSync(resolve(root, '.github/workflows/docker-smoke.yml'), 'utf8') + const packageJson = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { packageManager: string } + + it('keeps the image pnpm version aligned with packageManager', () => { + const pnpmVersion = packageJson.packageManager.replace(/^pnpm@/, '') + expect(dockerfile).toContain(`corepack prepare pnpm@${pnpmVersion} --activate`) + }) + + it('installs the complete sibling-based Workspace CLI set for login shells', () => { + expect(dockerfile).toContain('COPY --from=build /src/src/workspaces/cli/bin') + expect(dockerfile).toContain('/usr/local/bin/openalice-cli.cjs') + for (const command of ['alice', 'alice-uta', 'alice-workspace', 'traderhub']) { + expect(dockerfile).toContain(`/usr/local/bin/${command}`) + } + expect(dockerfile).not.toMatch(/ln -s[^\n]*\/usr\/local\/bin\/(alice|traderhub)/) + }) + + it('pins native agent runtime versions and exposes a healthcheck', () => { + expect(dockerfile).toMatch(/ARG CLAUDE_CODE_VERSION=\d+\.\d+\.\d+/) + expect(dockerfile).toMatch(/ARG CODEX_VERSION=\d+\.\d+\.\d+/) + expect(dockerfile).toContain('HEALTHCHECK ') + expect(dockerfile).toContain('/api/version') + }) + + it('keeps the server build desktop-free and the remote lifecycle bounded', () => { + expect(dockerignore).toMatch(/^apps\/desktop$/m) + expect(compose).toContain('stop_grace_period: 30s') + expect(compose).toContain('max-size: "10m"') + expect(compose).toContain('max-file: "3"') + }) + + it('runs the real Workspace smoke in Docker CI', () => { + expect(workflow).toContain('docker/build-push-action@v6') + expect(workflow).toContain('docker-runtime-smoke.mjs --skip-build --image openalice:ci') + expect(workflow).toContain('OPENALICE_DOCKER_SMOKE_LOG_FILE') + }) +}) diff --git a/scripts/docker-runtime-smoke.mjs b/scripts/docker-runtime-smoke.mjs new file mode 100644 index 000000000..a41dae036 --- /dev/null +++ b/scripts/docker-runtime-smoke.mjs @@ -0,0 +1,304 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + buildDockerRuntimeSmokePlan, + parsePublishedPort, + redactDockerLogs, + stripTerminalControl, +} from './docker-runtime-smoke-lib.mjs' + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)) +const plan = buildDockerRuntimeSmokePlan(process.argv.slice(2)) +const { + containerName, + image, + keep, + keepImage, + ownsImage, + skipBuild, + suffix, + volumeName, +} = plan.options +const logFile = process.env['OPENALICE_DOCKER_SMOKE_LOG_FILE']?.trim() + +function printHelp() { + console.log(`Usage: pnpm docker:smoke [options] + +Build and run an isolated OpenAlice server image, create a real Chat Workspace, +open a shell PTY, and execute the injected alice CLI through its live gateway. +No external AI credential or broker account is used. + +Options: + --skip-build Reuse a caller-owned image (requires --image) + --image Build/reuse this caller-owned image tag + --keep Keep the container, volume, and owned image for debugging + --keep-image Keep only the temporary image built by this run + -h, --help Show this help +`) +} + +function docker(args, options = {}) { + const result = spawnSync('docker', args, { + cwd: repoRoot, + encoding: options.inherit ? undefined : 'utf8', + stdio: options.inherit ? 'inherit' : 'pipe', + env: process.env, + }) + if (result.error) throw result.error + if (result.status !== 0 && !options.allowFailure) { + const details = [result.stdout, result.stderr].filter(Boolean).join('\n').trim() + throw new Error(`docker ${args[0]} failed (${result.status ?? result.signal ?? 'unknown'})${details ? `:\n${details}` : ''}`) + } + return { + ok: result.status === 0, + stdout: typeof result.stdout === 'string' ? result.stdout : '', + stderr: typeof result.stderr === 'string' ? result.stderr : '', + } +} + +function sleep(ms) { + return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)) +} + +async function fetchJson(baseUrl, path, options = {}, expectedStatus = 200) { + const response = await fetch(`${baseUrl}${path}`, { + ...options, + headers: { + ...(options.body !== undefined ? { 'content-type': 'application/json' } : {}), + ...options.headers, + }, + }) + const text = await response.text() + let body = null + try { body = text ? JSON.parse(text) : null } catch { body = text } + if (response.status !== expectedStatus) { + throw new Error(`${options.method ?? 'GET'} ${path} returned ${response.status}: ${JSON.stringify(body)}`) + } + return body +} + +async function waitForHttp(baseUrl, timeoutMs = 90_000) { + const deadline = Date.now() + timeoutMs + let lastError = null + while (Date.now() < deadline) { + const state = docker(['inspect', '--format', '{{.State.Status}}', containerName], { allowFailure: true }) + if (state.ok && state.stdout.trim() === 'exited') { + throw new Error('container exited before the HTTP surface became ready') + } + try { + const version = await fetchJson(baseUrl, '/api/version') + return version + } catch (error) { + lastError = error + await sleep(250) + } + } + throw new Error(`Alice HTTP surface did not become ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`) +} + +function decodeWsData(data) { + if (typeof data === 'string') return Promise.resolve(data) + if (data instanceof ArrayBuffer) return Promise.resolve(Buffer.from(data).toString('utf8')) + if (ArrayBuffer.isView(data)) { + return Promise.resolve(Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8')) + } + if (data && typeof data.arrayBuffer === 'function') { + return data.arrayBuffer().then((buffer) => Buffer.from(buffer).toString('utf8')) + } + return Promise.resolve(String(data ?? '')) +} + +function runWorkspaceCliThroughPty(baseUrl, sessionId) { + const wsUrl = new URL('/api/workspaces/pty', baseUrl) + wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:' + wsUrl.searchParams.set('session', sessionId) + wsUrl.searchParams.set('cols', '120') + wsUrl.searchParams.set('rows', '32') + wsUrl.searchParams.set('client', `docker-smoke-${suffix}`) + wsUrl.searchParams.set('kind', 'smoke') + wsUrl.searchParams.set('takeover', '1') + + const marker = `OA_DOCKER_${suffix.toUpperCase()}` + const startMarker = `__${marker}_START__` + const exitMarker = `__${marker}_EXIT__:0` + const command = [ + `m=${marker}`, + `printf '\\n__%s_START__\\n' "$m"`, + 'command -v alice', + 'alice', + 'status=$?', + `printf '\\n__%s_EXIT__:%s\\n' "$m" "$status"`, + ].join('; ') + + return new Promise((resolvePromise, rejectPromise) => { + const ws = new WebSocket(wsUrl) + ws.binaryType = 'arraybuffer' + let output = '' + let settled = false + const finish = (error) => { + if (settled) return + settled = true + clearTimeout(timeout) + try { ws.close() } catch { /* noop */ } + if (error) rejectPromise(error) + else resolvePromise(stripTerminalControl(output)) + } + const timeout = setTimeout(() => { + finish(new Error(`PTY CLI round-trip timed out. Output:\n${stripTerminalControl(output).slice(-4000)}`)) + }, 30_000) + + // PersistentSession reserves text frames for JSON control messages. PTY + // keystrokes must be binary, matching the browser terminal transport. + ws.addEventListener('open', () => ws.send(Buffer.from(`${command}\r`, 'utf8'))) + ws.addEventListener('message', (event) => { + void decodeWsData(event.data).then((chunk) => { + output += chunk + const clean = stripTerminalControl(output) + if (clean.includes(exitMarker)) { + const result = clean.slice(clean.lastIndexOf(startMarker)) + if (!result.includes('/usr/local/bin/alice')) { + finish(new Error(`Workspace PATH did not resolve the image-owned alice launcher:\n${result.slice(-4000)}`)) + } else if (!result.includes('OpenAlice CLI')) { + finish(new Error(`alice did not read its live CLI manifest:\n${result.slice(-4000)}`)) + } else { + finish(null) + } + } + }).catch(finish) + }) + ws.addEventListener('error', () => finish(new Error('PTY WebSocket failed'))) + ws.addEventListener('close', () => { + if (!settled) finish(new Error(`PTY WebSocket closed before ${exitMarker}`)) + }) + }) +} + +function collectFailureLogs() { + const logs = docker(['logs', containerName], { allowFailure: true }) + const inspect = docker([ + 'inspect', + '--format', + 'state={{json .State}} health={{json .State.Health}} network={{json .NetworkSettings.Ports}}', + containerName, + ], { allowFailure: true }) + const report = redactDockerLogs([ + '=== docker state ===', + inspect.stdout, + inspect.stderr, + '=== docker logs ===', + logs.stdout, + logs.stderr, + ].filter(Boolean).join('\n')).trim() + if (logFile && report) { + const target = resolve(repoRoot, logFile) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, `${report}\n`) + console.error(`[docker-smoke] failure log: ${target}`) + } + return report +} + +if (plan.options.help) { + printHelp() + process.exit(0) +} +if (plan.errors.length > 0) { + for (const error of plan.errors) console.error(error) + printHelp() + process.exit(1) +} +for (const warning of plan.warnings) console.warn(warning) + +let containerCreated = false +let volumeCreated = false +let passed = false +let finalCode = 0 + +try { + docker(['info']) + if (!skipBuild) { + console.log(`[docker-smoke] building ${image}`) + docker(['build', '--tag', image, '.'], { inherit: true }) + } else { + console.log(`[docker-smoke] reusing caller-owned image ${image}`) + } + + docker(['volume', 'create', '--label', 'openalice.smoke=1', volumeName]) + volumeCreated = true + const run = docker([ + 'run', '--detach', + '--name', containerName, + '--label', 'openalice.smoke=1', + '--publish', '127.0.0.1::47331', + '--env', 'OPENALICE_DISABLE_AUTH=1', + '--env', 'OPENALICE_TRADING_MODE=lite', + '--mount', `type=volume,source=${volumeName},target=/data`, + image, + ]) + containerCreated = true + console.log(`[docker-smoke] container: ${containerName} (${run.stdout.trim().slice(0, 12)})`) + + const port = parsePublishedPort(docker(['port', containerName, '47331/tcp']).stdout) + const baseUrl = `http://127.0.0.1:${port}` + const version = await waitForHttp(baseUrl) + console.log(`[docker-smoke] HTTP ready: ${baseUrl} (${version?.version ?? 'version route OK'})`) + + const created = await fetchJson(baseUrl, '/api/workspaces', { + method: 'POST', + body: JSON.stringify({ tag: `docker-smoke-${suffix}`, template: 'chat', agents: ['shell'] }), + }, 201) + const workspaceId = created?.workspace?.id + if (typeof workspaceId !== 'string' || !workspaceId) throw new Error('workspace create response omitted workspace.id') + console.log(`[docker-smoke] Workspace created: ${workspaceId}`) + + const session = await fetchJson(baseUrl, `/api/workspaces/${workspaceId}/sessions/spawn`, { + method: 'POST', + body: JSON.stringify({ agent: 'shell' }), + }, 201) + if (typeof session?.sessionId !== 'string' || !session.sessionId) { + throw new Error('shell spawn response omitted sessionId') + } + console.log(`[docker-smoke] shell Session spawned: ${session.sessionId}`) + + const terminalOutput = await runWorkspaceCliThroughPty(baseUrl, session.sessionId) + console.log('[docker-smoke] Workspace PTY + alice manifest round-trip: OK') + if (process.env['OPENALICE_DOCKER_SMOKE_VERBOSE'] === '1') console.log(terminalOutput) + + await fetchJson(baseUrl, `/api/workspaces/${workspaceId}/offboard`, { + method: 'POST', + body: JSON.stringify({ reason: 'docker runtime smoke complete' }), + }) + console.log('[docker-smoke] Workspace offboarding: OK') + passed = true +} catch (error) { + finalCode = 1 + console.error(`[docker-smoke] ${error instanceof Error ? error.message : String(error)}`) + if (containerCreated) { + const report = collectFailureLogs() + if (report) console.error(report.split('\n').slice(-160).join('\n')) + } +} finally { + if (keep) { + console.log(`[docker-smoke] kept container=${containerName} volume=${volumeName} image=${image}`) + } else { + const cleanupFailures = [] + const cleanup = (label, args) => { + const result = docker(args, { allowFailure: true }) + if (!result.ok) cleanupFailures.push(`${label}: ${(result.stderr || result.stdout).trim() || 'docker command failed'}`) + } + if (containerCreated) cleanup('container', ['rm', '--force', containerName]) + if (volumeCreated) cleanup('volume', ['volume', 'rm', '--force', volumeName]) + if (ownsImage && !keepImage) cleanup('image', ['image', 'rm', '--force', image]) + if (cleanupFailures.length > 0) { + finalCode = 1 + console.error(`[docker-smoke] owned resource cleanup failed:\n${cleanupFailures.join('\n')}`) + } + } +} + +if (passed) console.log('[docker-smoke] passed') +process.exit(finalCode) diff --git a/src/workspaces/adapters/shell.spec.ts b/src/workspaces/adapters/shell.spec.ts index 609391322..50f44217b 100644 --- a/src/workspaces/adapters/shell.spec.ts +++ b/src/workspaces/adapters/shell.spec.ts @@ -45,6 +45,7 @@ describe('composeShellCommand', () => { it('keeps POSIX login-shell behavior without a managed shell', () => { expect(composeShellCommand({ SHELL: '/bin/bash' }, 'darwin')).toEqual(['/bin/bash', '--login']); - expect(composeShellCommand({}, 'linux')).toEqual(['/bin/zsh', '--login']); + expect(composeShellCommand({}, 'darwin')).toEqual(['/bin/zsh', '--login']); + expect(composeShellCommand({}, 'linux')).toEqual(['/bin/bash', '--login']); }); }); diff --git a/src/workspaces/adapters/shell.ts b/src/workspaces/adapters/shell.ts index 563057e5a..f469212f8 100644 --- a/src/workspaces/adapters/shell.ts +++ b/src/workspaces/adapters/shell.ts @@ -2,7 +2,8 @@ import type { CliAdapter, SpawnContext } from '../cli-adapter.js'; import { resolveBashPath } from '@/core/shell-resolver.js'; /** - * The bare-metal terminal — `zsh --login` (or whatever's on `$SHELL`), + * The bare-metal terminal — the inherited login shell, or a platform-native + * default (`zsh` on macOS, `bash` on Linux), * dropped into the workspace's cwd. No transcript discovery, no resume. * This is the "I just want a terminal, leave me alone" path the user * articulated: "反正 terminal 都开了,用户自己开个 vim 我也管不着". @@ -37,5 +38,6 @@ export function composeShellCommand( if (platform === 'win32') { return [env['SHELL'] ?? env['ComSpec'] ?? env['COMSPEC'] ?? 'cmd.exe']; } - return [env['SHELL'] ?? '/bin/zsh', '--login']; + const defaultShell = platform === 'darwin' ? '/bin/zsh' : '/bin/bash'; + return [env['SHELL'] ?? defaultShell, '--login']; }