diff --git a/__tests__/scripts/acp-docker-env-sync.test.ts b/__tests__/scripts/acp-docker-env-sync.test.ts new file mode 100644 index 0000000000..a372212906 --- /dev/null +++ b/__tests__/scripts/acp-docker-env-sync.test.ts @@ -0,0 +1,140 @@ +// @vitest-environment node +// +// Drift-detection for the examples/acp-docker quickstart. Invariants enforced: +// the generated pin must equal versions.agentServer from the single source of +// truth (config/defaults.json); the no-config compose fallback must stay on +// `latest-python`; and the pinned tag must never fall below the Canvas +// compatibility floor (compatibility.minimumAgentServer). Any of those drifting +// fails this test. +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { + computeAgentServerImage, + renderEnvLine, + upsertEnvLine, +} from "../../scripts/gen-acp-docker-env.mjs"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); + +function read(rel: string): string { + return readFileSync(path.join(repoRoot, rel), "utf-8"); +} + +const config = JSON.parse(read("config/defaults.json")) as { + images: { agentServer: string }; + versions: { agentServer: string }; + compatibility: { minimumAgentServer: string }; +}; + +const pinnedImage = `${config.images.agentServer}:${config.versions.agentServer}-python`; + +// Numeric-semver comparison. Throws (rather than silently comparing NaN) if a +// pin carries a non-numeric segment — these defaults.json fields are dotted +// numeric version pins, so a sha or pre-release tag landing here is a config +// error the floor check should surface loudly. +function parseSemver(v: string): number[] { + if (!/^\d+(\.\d+)*$/.test(v)) { + throw new Error(`expected a dotted numeric version, got "${v}"`); + } + return v.split(".").map(Number); +} + +function gte(a: string, b: string): boolean { + const pa = parseSemver(a); + const pb = parseSemver(b); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const da = pa[i] ?? 0; + const db = pb[i] ?? 0; + if (da !== db) return da > db; + } + return true; +} + +describe("examples/acp-docker stays in sync with config/defaults.json", () => { + it("computeAgentServerImage derives the pinned SoT tag", () => { + expect(computeAgentServerImage(config)).toBe(pinnedImage); + }); + + it("renders the AGENT_SERVER_IMAGE line with the pinned tag", () => { + expect(renderEnvLine(config)).toBe(`AGENT_SERVER_IMAGE=${pinnedImage}`); + }); + + it("the pinned SoT version satisfies the Canvas compatibility floor", () => { + expect( + gte(config.versions.agentServer, config.compatibility.minimumAgentServer), + ).toBe(true); + }); + + it("the no-config compose fallback uses the SoT registry with the latest-python tag", () => { + const compose = read("examples/acp-docker/docker-compose.yml"); + const match = compose.match(/AGENT_SERVER_IMAGE:-([^}]+)\}/); + expect(match?.[1]).toBe(`${config.images.agentServer}:latest-python`); + }); +}); + +describe("gen-acp-docker-env.mjs is safe to import", () => { + // The module exports helpers (imported above, and by future consumers), so + // importing it must not run main(). The entrypoint guard compares + // import.meta.url against process.argv[1] — but argv[1] is undefined in some + // ESM contexts (e.g. `node --input-type=module -e "import(...)"`), and an + // unguarded pathToFileURL(argv[1]) throws ERR_INVALID_ARG_TYPE at import, + // before any export is reachable. Reproduces that exact context. + const scriptPath = path.join(repoRoot, "scripts", "gen-acp-docker-env.mjs"); + + it("imports without throwing when process.argv[1] is undefined", () => { + const url = pathToFileURL(scriptPath).href; + const res = spawnSync( + process.execPath, + ["--input-type=module", "-e", `import(${JSON.stringify(url)})`], + { encoding: "utf-8" }, + ); + expect(res.stderr).not.toMatch(/ERR_INVALID_ARG_TYPE/); + expect(res.status).toBe(0); + }); +}); + +describe("upsertEnvLine", () => { + const line = + "AGENT_SERVER_IMAGE=ghcr.io/openhands/agent-server:1.28.1-python"; + + it("appends the line to an empty file", () => { + expect(upsertEnvLine("", line)).toBe(`${line}\n`); + }); + + it("replaces an existing assignment in place, preserving other lines", () => { + const existing = + "SESSION_API_KEY=abc\n" + + "AGENT_SERVER_IMAGE=ghcr.io/openhands/agent-server:1.25.0-python\n" + + "CLAUDE_CODE_OAUTH_TOKEN=zzz\n"; + expect(upsertEnvLine(existing, line)).toBe( + `SESSION_API_KEY=abc\n${line}\nCLAUDE_CODE_OAUTH_TOKEN=zzz\n`, + ); + }); + + it("is idempotent — re-running yields one assignment, unchanged content", () => { + const once = upsertEnvLine("", line); + expect(upsertEnvLine(once, line)).toBe(once); + expect(once.match(/^AGENT_SERVER_IMAGE=/gm)?.length).toBe(1); + }); + + it("treats a commented template line as documentation and appends the real value", () => { + // .env.example ships `# AGENT_SERVER_IMAGE=...` as a documented knob; after + // `cp .env.example .env` the comment stays and the generator adds the value. + const existing = + "# AGENT_SERVER_IMAGE=ghcr.io/openhands/agent-server:latest-python\n"; + expect(upsertEnvLine(existing, line)).toBe( + `${existing.trimEnd()}\n${line}\n`, + ); + }); + + it("throws rather than rewrite every line when given a keyless line", () => { + expect(() => upsertEnvLine("FOO=bar\n", "novalue")).toThrow(); + }); +}); diff --git a/examples/acp-docker/.env.example b/examples/acp-docker/.env.example index e129a73beb..30f326e542 100644 --- a/examples/acp-docker/.env.example +++ b/examples/acp-docker/.env.example @@ -4,12 +4,9 @@ # credentials" step (they ride the conversation start request as secrets). # Set values here only if you want them baked into the container instead. -# Pin the agent-server image. Default in docker-compose.yml is 1.25.0-python, -# the first release that includes software-agent-sdk#3510 (required — Canvas -# delivers ACP creds as loopback LookupSecrets that only #3510 resolves without -# deadlocking). Bump to a newer release, or a post-#3510 main commit: -# gh api repos/OpenHands/software-agent-sdk/commits/main --jq '.sha[0:7]' -# AGENT_SERVER_IMAGE=ghcr.io/openhands/agent-server:1.25.0-python +# Pin the agent-server image. The compose default is `latest-python`. +# For a reproducible pin (driven by config/defaults.json): npm run example:acp-docker:env +# AGENT_SERVER_IMAGE=ghcr.io/openhands/agent-server:latest-python # --- Claude Code --- # A Pro/Max OAuth token, OR an API key. Do NOT set ANTHROPIC_BASE_URL with the diff --git a/examples/acp-docker/README.md b/examples/acp-docker/README.md index 538f619e4b..c463561c86 100644 --- a/examples/acp-docker/README.md +++ b/examples/acp-docker/README.md @@ -12,27 +12,40 @@ for the full walkthrough; this is the quick start. ```bash cd examples/acp-docker -cp .env.example .env # optional — only if baking creds into the container docker compose up ``` -This starts `ghcr.io/openhands/agent-server:1.25.0-python` on +This starts `ghcr.io/openhands/agent-server:latest-python` on `http://localhost:8010` with a persistent `acp-data` volume. The image pre-installs the ACP CLI wrappers and the SDK rewrites `npx -y ` to those pinned binaries in-pod, so Canvas can keep sending the default `npx` command unchanged. -> **Minimum version:** `1.25.0-python`. Canvas delivers every ACP credential as -> a loopback `LookupSecret`; only software-agent-sdk#3510 (first released in -> v1.25.0) resolves it off the event loop. An older image deadlocks the first -> ACP turn with `Failed to start ACP server: timed out`. +For a **reproducible, pinned** image, generate `.env` from the repo's single +source of truth (`config/defaults.json`) first — it pins `AGENT_SERVER_IMAGE` +to the exact `versions.agentServer` release, so two people get the same build: -To pin a newer release or a post-#3510 main build: +```bash +npm run example:acp-docker:env # from the repo root; writes examples/acp-docker/.env +cd examples/acp-docker && docker compose up +``` + +> **Version compatibility.** The common paths keep Canvas and agent-server in +> sync: zero-config Compose uses `latest-python`, while the pinned path reads +> `versions.agentServer` from the same `config/defaults.json` used by the Canvas +> launchers. If you carry an old hand-written `.env` with `AGENT_SERVER_IMAGE`, +> rerun `npm run example:acp-docker:env` or remove that override so the example +> does not stay pinned below `compatibility.minimumAgentServer`. + +To pin a newer release or a current main build by hand instead: ```bash AGENT_SERVER_IMAGE=ghcr.io/openhands/agent-server:$(gh api repos/OpenHands/software-agent-sdk/commits/main --jq '.sha[0:7]')-python docker compose up ``` +To bake credentials into the container instead of entering them in Canvas, copy +the env template first: `cp .env.example .env` (optional — see [§3](#3-onboard-with-credentials)). + ## 2. Point Canvas at it ```bash diff --git a/examples/acp-docker/docker-compose.yml b/examples/acp-docker/docker-compose.yml index aa6ac30601..8e14a7d2b6 100644 --- a/examples/acp-docker/docker-compose.yml +++ b/examples/acp-docker/docker-compose.yml @@ -19,17 +19,12 @@ services: agent-server: - # Pin to a software-agent-sdk release that includes the containerized ACP - # merges (#1020 file-secret materialisation, #3490 npx→pinned-binary, #3492 - # per-conversation data-dir) AND #3510 (ACP cold-start off the event loop), - # which is REQUIRED: Canvas now delivers every ACP credential as a loopback - # LookupSecret, and only #3510 resolves it without self-deadlocking the - # conversation ("Failed to start ACP server: timed out"). #3510 first ships - # in v1.25.0, so 1.25.0-python is the minimum. Override with a newer release - # or a post-#3510 main sha: - # AGENT_SERVER_IMAGE=ghcr.io/openhands/agent-server:-python docker compose up - # gh api repos/OpenHands/software-agent-sdk/commits/main --jq '.sha[0:7]' - image: ${AGENT_SERVER_IMAGE:-ghcr.io/openhands/agent-server:1.25.0-python} + # Default `latest-python` is always >= the version Canvas requires. To pin a + # reproducible image (driven by config/defaults.json) or override per-run: + # npm run example:acp-docker:env # writes AGENT_SERVER_IMAGE to .env + # (Rationale — the compatibility floor and the #3510 LookupSecret fix — is in + # README.md §1 and the env-sync test.) + image: ${AGENT_SERVER_IMAGE:-ghcr.io/openhands/agent-server:latest-python} container_name: oh-acp ports: # host:container — Canvas points VITE_BACKEND_BASE_URL at http://localhost:8010. diff --git a/package.json b/package.json index 9665e9f644..a54d945d00 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,8 @@ "check-translation-completeness": "node scripts/check-translation-completeness.cjs", "build:app": "npm run make-i18n && react-router build", "build:lib": "npm run make-i18n && react-router typegen && cross-env BUILD_LIB=true VITE_APP_ENV=production vite build && tsc -p tsconfig.lib.json", - "build:docker": "node scripts/docker-build.mjs" + "build:docker": "node scripts/docker-build.mjs", + "example:acp-docker:env": "node scripts/gen-acp-docker-env.mjs" }, "lint-staged": { "src/**/*.{ts,tsx,js}": [ diff --git a/scripts/gen-acp-docker-env.mjs b/scripts/gen-acp-docker-env.mjs new file mode 100644 index 0000000000..de1ecb0c85 --- /dev/null +++ b/scripts/gen-acp-docker-env.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +/** + * Generate examples/acp-docker/.env from the single source of truth. + * + * Reads the version pins in config/defaults.json and writes the + * `AGENT_SERVER_IMAGE=` line into examples/acp-docker/.env, pinning the + * example to the exact `versions.agentServer` release. This keeps the + * reproducible quickstart path on the SoT version instead of a hardcoded + * tag that silently drifts below the Canvas compatibility floor + * (compatibility.minimumAgentServer) and renders "Disconnected". + * + * The no-config `docker compose up` path uses the compose fallback + * (`latest-python`, always >= the floor); running this script first pins the + * example to the reproducible SoT version instead. + * + * Idempotent: re-running upserts the AGENT_SERVER_IMAGE line and leaves any + * other lines in an existing .env untouched. + * + * Usage: + * node scripts/gen-acp-docker-env.mjs # or: npm run example:acp-docker:env + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(__dirname, ".."); + +/** + * @param {{ images: { agentServer: string }, versions: { agentServer: string } }} config + * @returns {string} e.g. "ghcr.io/openhands/agent-server:1.28.1-python" + */ +export function computeAgentServerImage(config) { + return `${config.images.agentServer}:${config.versions.agentServer}-python`; +} + +/** + * @param {{ images: { agentServer: string }, versions: { agentServer: string } }} config + * @returns {string} the `AGENT_SERVER_IMAGE=` line + */ +export function renderEnvLine(config) { + return `AGENT_SERVER_IMAGE=${computeAgentServerImage(config)}`; +} + +/** + * Upsert the AGENT_SERVER_IMAGE line into an existing .env body, preserving + * every other line. Appends the line if absent. + * @param {string} existing prior .env contents ("" if the file is absent) + * @param {string} line the `AGENT_SERVER_IMAGE=...` line to set + * @returns {string} the updated .env contents + */ +export function upsertEnvLine(existing, line) { + const eq = line.indexOf("="); + if (eq <= 0) { + // A keyless line ("", "novalue", "=value") would make `key` empty and + // match every line — refuse rather than silently rewrite the whole file. + throw new Error( + `upsertEnvLine: expected a "KEY=value" line, got "${line}"`, + ); + } + const key = line.slice(0, eq + 1); // "AGENT_SERVER_IMAGE=" + const lines = existing.length ? existing.replace(/\n+$/, "").split("\n") : []; + let replaced = false; + const next = lines.map((l) => { + if (l.startsWith(key)) { + replaced = true; + return line; + } + return l; + }); + if (!replaced) next.push(line); + return next.join("\n") + "\n"; +} + +function loadConfig() { + return JSON.parse( + readFileSync(join(projectRoot, "config", "defaults.json"), "utf-8"), + ); +} + +function main() { + const config = loadConfig(); + const line = renderEnvLine(config); + const envPath = join(projectRoot, "examples", "acp-docker", ".env"); + + let existing = ""; + try { + existing = readFileSync(envPath, "utf-8"); + } catch { + // no .env yet — create it + } + + const updated = upsertEnvLine(existing, line); + writeFileSync(envPath, updated); + console.log(`Wrote ${line} to examples/acp-docker/.env`); +} + +// Run main() only when invoked as a CLI. process.argv[1] is undefined in some +// ESM contexts (e.g. `node --input-type=module -e "import(...)"`), so guard it +// before pathToFileURL — otherwise importing this module for its exports throws +// ERR_INVALID_ARG_TYPE. +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main(); +}