diff --git a/cli/README.md b/cli/README.md index 99bb94af6..2c044a442 100644 --- a/cli/README.md +++ b/cli/README.md @@ -63,6 +63,12 @@ computed secret names, tools, skills, and plugins without network access; `up`, `sandbox build` run the same checks first. `doctor` verifies external prerequisites read-only. `plan` renders the deployment; AWS mutation requires `up --yes`. +For a single-host Docker deployment, `sandbox.backend: "local"` runs each agent +computer in its own container. `qm up` builds the local runtime from the CLI's +pinned sandbox base, mounts the host Docker socket into trusted core, and connects +core to each sandbox's private network. An explicit `sandbox.image` uses that +runnable local image instead. + On AWS, `up` snapshots the RDS instance under the deploy lease before its first mutation, names the snapshot after the deployment manifest it precedes, and records it in that manifest. `rollback` restores code and configuration only, diff --git a/cli/package-lock.json b/cli/package-lock.json index 471eabca4..9db291b19 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@yc-software/qm", - "version": "0.1.7", + "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@yc-software/qm", - "version": "0.1.7", + "version": "0.1.8", "license": "MIT", "bin": { "qm": "dist/bin/qm.js" diff --git a/cli/package.json b/cli/package.json index 588271b14..2bb3a343b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@yc-software/qm", - "version": "0.1.7", + "version": "0.1.8", "license": "MIT", "description": "Control-plane CLI for portable QM deployments on Docker, Fly, and AWS.", "type": "module", diff --git a/cli/src/backends/docker.ts b/cli/src/backends/docker.ts index 81f1147b5..f8bec67b1 100644 --- a/cli/src/backends/docker.ts +++ b/cli/src/backends/docker.ts @@ -1,7 +1,7 @@ import { httpDeploymentLayerTransport, type DeploymentLayerTransport } from "../deployment-layer.ts"; import { randomBytes } from "node:crypto"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { CliError, bold, die, dim, errMessage, header, note, ok, step, warn } from "../log.ts"; @@ -18,7 +18,7 @@ import { tailString, which, } from "../util.ts"; -import { manifestRef } from "../manifest.ts"; +import { manifestRef, sandboxBaseRef } from "../manifest.ts"; import { brokerWiring, ordered, @@ -31,7 +31,7 @@ import { type LogOpts, type ServiceName, } from "../services.ts"; -import { dockerBasePort, sandboxCoreEnv, securityScreenEnv, type QmConfig } from "../config.ts"; +import { dockerBasePort, localSandboxActive, sandboxCoreEnv, securityScreenEnv, type QmConfig } from "../config.ts"; import { discoverPlugins, type ResolvedPlugin } from "../plugins.ts"; import { computedSecrets, runtimeSecretNames, secretsForService } from "../secrets.ts"; import { readDeploymentState, withDeploymentLock, writeDeploymentState, type DeploymentState } from "../state.ts"; @@ -66,6 +66,69 @@ const dockerPrefix = (config: QmConfig): string => `qm-${safe(config.orgId)}`; const cname = (ctx: DockerCtx, name: string): string => `${ctx.prefix}-${name}`; const pgVolume = (ctx: DockerCtx): string => `${ctx.prefix}-pgdata`; +const localSandboxImage = (config: QmConfig): string => + config.sandbox?.image ?? `${dockerPrefix(config).toLowerCase()}-sandbox-local:latest`; + +function localAgentSource(): Buffer { + const source = new URL("../../templates/aws/microvm-agent/agent.mjs", import.meta.url); + const packaged = new URL("../../../templates/aws/microvm-agent/agent.mjs", import.meta.url); + return readFileSync(existsSync(source) ? source : packaged); +} + +function ensureLocalSandboxImage(config: QmConfig): string { + const image = localSandboxImage(config); + if (config.sandbox?.image) return image; + const base = sandboxBaseRef(); + try { + const labeled = capture("docker", [ + "image", + "inspect", + "-f", + '{{index .Config.Labels "qm.local-sandbox-base"}}', + image, + ]).trim(); + if (labeled === base) return image; + } catch { + // Build the local wrapper when it is absent or stale. + } + const dir = mkdtempSync(join(tmpdir(), "qm-local-sandbox-")); + try { + writeFileSync(join(dir, "agent.mjs"), localAgentSource()); + writeFileSync( + join(dir, "Dockerfile"), + `ARG BASE\nFROM \${BASE}\nCOPY agent.mjs /opt/qm/agent.mjs\nENV HOME=/root\nWORKDIR /root\nEXPOSE 8080\nCMD ["node", "/opt/qm/agent.mjs"]\n`, + ); + dockerInherit([ + "build", + "--build-arg", + `BASE=${base}`, + "--label", + `qm.local-sandbox-base=${base}`, + "-t", + image, + dir, + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + return image; +} + +function hostDockerSocket(): { path: string; gid?: string } { + const configured = process.env.DOCKER_HOST?.trim(); + if (configured && !configured.startsWith("unix://")) { + throw new CliError('sandbox.backend "local" requires a Unix Docker socket'); + } + const path = configured?.slice("unix://".length) || "/var/run/docker.sock"; + let gid: string | undefined; + try { + gid = capture("stat", ["-c", "%g", path]).trim() || undefined; + } catch { + throw new CliError(`sandbox.backend "local" cannot read the Docker socket at ${path}`); + } + return { path, ...(gid ? { gid } : {}) }; +} + function requireDocker(): void { if (!which("docker")) die("docker not found on PATH (the docker target needs a running Docker daemon)."); try { @@ -290,6 +353,10 @@ export function dockerServiceEnv(config: QmConfig, service: ServiceName): Record CORE_API_URL: "http://core:8080", ...orgEnv(service, config.orgId, config.publicUrl, config.services.includes("portal"), brandEnvOf(config)), }; + if (service === "core" && localSandboxActive(config)) { + out.DOCKER_HOST = "unix:///var/run/docker.sock"; + out.QM_CORE_CONTAINER = `${dockerPrefix(config)}-core`; + } if (service === "portal") { if (config.services.includes("web-ui")) out.WEB_UI_UPSTREAM = "http://web-ui:8080"; if (config.services.includes("admin")) out.ADMIN_UPSTREAM = "http://admin:8080"; @@ -328,6 +395,10 @@ function serviceEnv(ctx: DockerCtx, service: ServiceName): Record { function buildCtx( config: QmConfig, configDir: string, - opts: { sandboxDir?: string; buildFrom: boolean; buildFromPath?: string; envFile?: string }, + opts: { + sandboxDir?: string; + buildFrom: boolean; + buildFromPath?: string; + envFile?: string; + localSandboxImage?: string; + }, ): DockerCtx { const prefix = dockerPrefix(config); const envFile = opts.envFile ? resolve(opts.envFile) : join(configDir, ".env"); @@ -493,7 +575,10 @@ function buildCtx( if (signingSecret) ctx.signingSecret = signingSecret; const lookup = (name: string): string | undefined => deploymentSecretValue(name, readEnvValue(ctx.envFile, name)); const sb = sandboxCoreEnv(config, lookup); - ctx.sandboxEnv = sb.env; + ctx.sandboxEnv = { + ...sb.env, + ...(opts.localSandboxImage ? { LOCAL_SANDBOX_IMAGE: opts.localSandboxImage } : {}), + }; ctx.missingSandboxSecrets = sb.missingSecrets; if (opts.buildFrom) ctx.repoRoot = resolveBuildRepoRoot(opts.buildFromPath, runnableServices(config.services)); return ctx; @@ -528,11 +613,13 @@ export async function dockerUp( opts: { sandboxDir?: string; buildFrom?: boolean; buildFromPath?: string; envFile?: string; dryRun?: boolean } = {}, ): Promise { if (!opts.dryRun) requireDocker(); + const resolvedLocalImage = localSandboxActive(config) ? localSandboxImage(config) : undefined; const ctx = buildCtx(config, configDir, { sandboxDir: opts.sandboxDir, buildFrom: opts.buildFrom ?? false, buildFromPath: opts.buildFromPath, envFile: opts.envFile, + ...(resolvedLocalImage ? { localSandboxImage: resolvedLocalImage } : {}), }); const plugins = discoverPlugins(configDir, config).plugins; @@ -550,6 +637,7 @@ export async function dockerUp( if (opts.dryRun) { ctx.databaseUrl = ensurePostgres(ctx, true); step(`network: ${ctx.network}`); + if (resolvedLocalImage) step(`sandbox: local image ${resolvedLocalImage}`); for (const def of ordered(runnableServices(config.services))) { const ports = def.docker.hostPortOffset !== undefined ? ` (host :${baseHostPort(ctx) + def.docker.hostPortOffset})` : ""; @@ -582,6 +670,7 @@ export async function dockerUp( ); } + if (localSandboxActive(config)) ensureLocalSandboxImage(config); ensureNetwork(ctx); ctx.databaseUrl = ensurePostgres(ctx, false); if (!externalDatabaseUrl(ctx)) await waitPostgres(ctx); diff --git a/cli/src/backends/doctor.ts b/cli/src/backends/doctor.ts index 37d1a204f..77916c14b 100644 --- a/cli/src/backends/doctor.ts +++ b/cli/src/backends/doctor.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { MODEL_PROVIDER_KEYS, + localSandboxActive, mockHarnessWarning, validatePortalTrust, type ModelProvider, @@ -143,7 +144,9 @@ export async function doctorCommon( ); step("required local secret values: ok"); } - if (config.target === "aws") { + if (localSandboxActive(config)) { + step("local Docker sandbox: configured"); + } else if (config.target === "aws") { step("AWS Lambda MicroVM sandbox: configured"); } else if (config.sandbox?.app) { requireFlyAuth(); diff --git a/cli/src/commands/check.ts b/cli/src/commands/check.ts index c1b23cff6..a6f6574db 100644 --- a/cli/src/commands/check.ts +++ b/cli/src/commands/check.ts @@ -5,7 +5,7 @@ import { readEnvFile } from "../util.ts"; import { CliError, errMessage, header, note, ok, step, warn } from "../log.ts"; import { validateSandboxLayer, type SandboxValidation } from "../sandbox-layer.ts"; import { discoverPlugins, type ResolvedPlugin } from "../plugins.ts"; -import { mockHarnessWarning, sandboxPinPending, type QmConfig } from "../config.ts"; +import { localSandboxActive, mockHarnessWarning, sandboxPinPending, type QmConfig } from "../config.ts"; import { computedSecrets, runtimeSecretNames, type ComputedSecret } from "../secrets.ts"; import { isVirtualService, runnableServices } from "../services.ts"; import { serviceEnvironment } from "../backends/aws.ts"; @@ -30,7 +30,7 @@ export function runChecks( const configError = (message: string, clause = "config.v1"): void => void configErrors.push({ clause, message }); const provider = hostingProvider(config.target); configErrors.push(...provider.validateConfig(config, plugins)); - if (provider.requiresSandboxApp && !config.sandbox?.app?.trim()) { + if (provider.requiresSandboxApp && !localSandboxActive(config) && !config.sandbox?.app?.trim()) { configError("contract sandbox.app: a Fly agent-computer app is required for docker and fly targets"); } for (const skill of config.skills) { diff --git a/cli/src/config.ts b/cli/src/config.ts index dc946ee0b..769d6a506 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -45,7 +45,7 @@ export interface PluginEntry { } export interface SandboxConfig { - backend?: "sprites" | "aws"; + backend?: "local" | "sprites" | "aws"; app?: string; image?: string; baseImage?: string; @@ -205,10 +205,14 @@ export const isDigestPinned = (ref: string): boolean => /@sha256:[0-9a-f]{64}$/. const SANDBOX_PIN_PENDING = `"sandbox.app" is set but no sandbox layer image is pinned; run \`qm sandbox publish\` to build and record the digest-pinned "sandbox.image" agents boot from`; +export const localSandboxActive = (config: QmConfig): boolean => + config.target === "docker" && config.sandbox?.backend === "local"; + export const sandboxPinPending = (config: QmConfig): boolean => - config.target !== "aws" && Boolean(config.sandbox?.app && !config.sandbox.image); + config.target !== "aws" && !localSandboxActive(config) && Boolean(config.sandbox?.app && !config.sandbox.image); export function sandboxImagePinErrors(config: QmConfig): Array<{ clause: string; message: string }> { + if (localSandboxActive(config)) return []; const sb = config.sandbox; if (!sb?.app || !sb.image || isDigestPinned(sb.image)) return []; return [ @@ -227,6 +231,11 @@ export function sandboxCoreEnv( const missingSecrets: string[] = []; const sb = config.sandbox; if (!sb) return { env, missingSecrets }; + if (localSandboxActive(config)) { + env.SANDBOX_BACKEND = "local"; + if (sb.image) env.LOCAL_SANDBOX_IMAGE = sb.image; + return { env, missingSecrets }; + } if (sb.app) { if (!sb.image) throw new CliError(SANDBOX_PIN_PENDING, { clause: "config.v1" }); const violation = sandboxImagePinErrors(config)[0]; @@ -1326,9 +1335,9 @@ function validateSandbox(raw: unknown, path: string, target: Target): SandboxCon }; const out: SandboxConfig = {}; if (o["backend"] !== undefined) { - if (o["backend"] !== "sprites" && o["backend"] !== "aws") { + if (o["backend"] !== "local" && o["backend"] !== "sprites" && o["backend"] !== "aws") { throw new CliError( - `${path}: "sandbox.backend" must be "sprites" (Fly Sprites, booting the operator-published layer image from the Fly app in "sandbox.app") or "aws" (Lambda MicroVM sandboxes)`, + `${path}: "sandbox.backend" must be "local" (Docker containers on the deployment host), "sprites" (Fly Sprites), or "aws" (Lambda MicroVM sandboxes)`, ); } out.backend = o["backend"]; @@ -1370,6 +1379,14 @@ function validateSandbox(raw: unknown, path: string, target: Target): SandboxCon const label = out.backend === "aws" ? " (Lambda MicroVM sandboxes)" : ""; throw new CliError(`${path}: "sandbox.backend": ${JSON.stringify(out.backend)}${label} requires target ${targets}`); } + if (out.backend === "local") { + const stray = (["app", "baseImage", "env", "secretEnv"] as const).filter((key) => out[key] !== undefined); + if (stray.length) { + throw new CliError( + `${path}: "sandbox.backend": "local" ignores ${stray.map((key) => `"sandbox.${key}"`).join(", ")} — remove them; use "sandbox.image" for the runnable local sandbox image`, + ); + } + } if (out.backend === "aws") { const stray = (["app", "image", "baseImage", "env", "secretEnv"] as const).filter((key) => out[key] !== undefined); if (stray.length) { @@ -1378,8 +1395,8 @@ function validateSandbox(raw: unknown, path: string, target: Target): SandboxCon ); } } - if (out.image && !out.app) { - throw new CliError(`${path}: "sandbox.image" requires "sandbox.app" (the app the microVMs run in)`); + if (out.image && !out.app && out.backend !== "local") { + throw new CliError(`${path}: "sandbox.image" requires "sandbox.app" unless "sandbox.backend" is "local"`); } if (out.backend === "sprites" && !out.app) { throw new CliError( diff --git a/cli/src/providers.ts b/cli/src/providers.ts index 73b02a771..dc478dfc7 100644 --- a/cli/src/providers.ts +++ b/cli/src/providers.ts @@ -5,7 +5,7 @@ export type Target = (typeof HOSTING_PROVIDER_IDS)[number]; export const isTarget = (value: unknown): value is Target => typeof value === "string" && (HOSTING_PROVIDER_IDS as readonly string[]).includes(value); -export type SandboxBackendId = "sprites" | "aws"; +export type SandboxBackendId = "local" | "sprites" | "aws"; export interface SandboxBackendPolicy { /** Sandbox backends this hosting target can run. */ @@ -16,7 +16,7 @@ export interface SandboxBackendPolicy { /** Keyed by hosting target so adding a target forces a sandbox-backend decision. */ export const SANDBOX_BACKEND_POLICY: Record = { - docker: { allowed: ["sprites"], requireExplicit: false }, + docker: { allowed: ["local", "sprites"], requireExplicit: false }, fly: { allowed: ["sprites"], requireExplicit: false }, aws: { allowed: ["sprites", "aws"], requireExplicit: true }, }; diff --git a/cli/test/auth-broker.test.ts b/cli/test/auth-broker.test.ts index a0fc69e55..843c08629 100644 --- a/cli/test/auth-broker.test.ts +++ b/cli/test/auth-broker.test.ts @@ -134,6 +134,18 @@ test("docker and AWS wire the broker with parity", () => { assert.equal(serviceEnvironment(aws, "auth").PORT, "8080"); }); +test("docker local wires the host daemon coordinates only into core", () => { + const local = configWith( + configText().replace( + '"plugins": [],', + '"sandbox": { "backend": "local", "image": "qm-sandbox-local:latest" }, "plugins": [],', + ), + ); + assert.equal(dockerServiceEnv(local, "core").DOCKER_HOST, "unix:///var/run/docker.sock"); + assert.equal(dockerServiceEnv(local, "core").QM_CORE_CONTAINER, "qm-acme-core"); + assert.equal(dockerServiceEnv(local, "portal").DOCKER_HOST, undefined); +}); + test("the broker's generated secrets reach both sides under the right names", () => { const config = brokerConfig(); const secrets = computedSecrets(config); diff --git a/cli/test/check.test.ts b/cli/test/check.test.ts index 5c1d504f8..3d866a8e9 100644 --- a/cli/test/check.test.ts +++ b/cli/test/check.test.ts @@ -178,6 +178,15 @@ test("a bare deployment (no sandbox/, no plugins) passes", () => { } }); +test("docker with sandbox.backend local passes without a Fly sandbox app", () => { + const d = deployment(() => {}, { sandbox: { backend: "local", image: "qm-sandbox-local:latest" } }); + try { + assert.doesNotThrow(() => check(d)); + } finally { + rmSync(d.dir, { recursive: true, force: true }); + } +}); + test("AWS requires exact ECS/ECR coordinates for discovered plugins", () => { const plugin = { name: "linear", image: "ghcr.io/acme/linear:1" }; const aws = { diff --git a/cli/test/config.test.ts b/cli/test/config.test.ts index 2ed4f8d22..a670aec44 100644 --- a/cli/test/config.test.ts +++ b/cli/test/config.test.ts @@ -858,8 +858,14 @@ test("sandbox shape errors: object, app non-empty string, env string-map, secret { sandbox: { env: { "1BAD": "x" } }, rx: /"sandbox.env" key .* is not a valid env var name/ }, { sandbox: { secretEnv: "X" }, rx: /"sandbox.secretEnv" must be an array of strings/ }, { sandbox: { secretEnv: ["1BAD"] }, rx: /not a valid env var name/ }, - { sandbox: { backend: "k8s", app: "acme-sandboxes" }, rx: /"sandbox.backend" must be "sprites".*or "aws"/ }, - { sandbox: { backend: "fly", app: "acme-sandboxes" }, rx: /"sandbox.backend" must be "sprites".*or "aws"/ }, + { + sandbox: { backend: "k8s", app: "acme-sandboxes" }, + rx: /"sandbox.backend" must be "local".*"sprites".*or "aws"/, + }, + { + sandbox: { backend: "fly", app: "acme-sandboxes" }, + rx: /"sandbox.backend" must be "local".*"sprites".*or "aws"/, + }, { sandbox: { backend: "sprites" }, rx: /"sandbox.backend": "sprites" requires "sandbox.app"/ }, { sandbox: { backend: "aws", app: "acme-sandboxes" }, @@ -873,6 +879,27 @@ test("sandbox shape errors: object, app non-empty string, env string-map, secret } }); +test("docker accepts an explicit local sandbox image without Fly coordinates", () => { + withConfig({ sandbox: { backend: "local", image: "qm-sandbox-local:latest" } }, ({ path }) => { + const { config } = loadConfigAt(path); + assert.deepEqual(sandboxCoreEnv(config), { + env: { SANDBOX_BACKEND: "local", LOCAL_SANDBOX_IMAGE: "qm-sandbox-local:latest" }, + missingSecrets: [], + }); + assert.equal(sandboxPinPending(config), false); + assert.deepEqual(sandboxImagePinErrors(config), []); + }); +}); + +test("local sandbox config is docker-only and rejects unused Fly settings", () => { + withConfig({ target: "fly", sandbox: { backend: "local" } }, ({ path }) => { + assert.throws(() => loadConfigAt(path), /"sandbox.backend": "local" requires target "docker"/); + }); + withConfig({ sandbox: { backend: "local", app: "acme-sandboxes" } }, ({ path }) => { + assert.throws(() => loadConfigAt(path), /"sandbox.backend": "local" ignores "sandbox.app"/); + }); +}); + test("aws target makes the sandbox substrate explicit: backend required with a sandbox block, sprites needs app, aws forbids fly-image settings", () => { const aws = { accountId: "123456789012", diff --git a/cli/test/e2e/docker-lifecycle.e2e.test.ts b/cli/test/e2e/docker-lifecycle.e2e.test.ts index e26f416b1..c01b8036b 100644 --- a/cli/test/e2e/docker-lifecycle.e2e.test.ts +++ b/cli/test/e2e/docker-lifecycle.e2e.test.ts @@ -62,6 +62,7 @@ test( botName: "straylight", orgName: "Straylight Industries", env: { core: { HARNESS: "mock" } }, + sandbox: { backend: "local", image: "qm-sandbox-local:latest" }, }); standInPlugin(dep, "widget"); @@ -82,6 +83,22 @@ test( } }); + await t.test("local sandbox mode gives only core the host Docker socket", () => { + const names = deploymentContainers(org); + const core = suffix(names, "core")!; + const portal = suffix(names, "portal")!; + const inspect = (container: string) => + JSON.parse(execFileSync("docker", ["inspect", container], { encoding: "utf8" }))[0] as { + Config: { Env: string[] }; + Mounts: Array<{ Source: string; Destination: string }>; + }; + const coreInfo = inspect(core); + assert.ok(coreInfo.Config.Env.includes("DOCKER_HOST=unix:///var/run/docker.sock")); + assert.ok(coreInfo.Config.Env.includes(`QM_CORE_CONTAINER=${core}`)); + assert.ok(coreInfo.Mounts.some((mount) => mount.Destination === "/var/run/docker.sock")); + assert.ok(!inspect(portal).Mounts.some((mount) => mount.Destination === "/var/run/docker.sock")); + }); + await t.test("computed secrets from the deployment ./.env reach the core container", () => { const core = suffix(deploymentContainers(org), "core")!; const got = execFileSync("docker", ["exec", core, "printenv", "CORE_SIGNING_SECRET"], { diff --git a/cli/test/layer-wiring.test.ts b/cli/test/layer-wiring.test.ts index 5fbac899f..16a716571 100644 --- a/cli/test/layer-wiring.test.ts +++ b/cli/test/layer-wiring.test.ts @@ -59,6 +59,17 @@ async function plan(configDir: string, opts: { sandboxDir?: string } = {}): Prom return lines.join("\n").replace(/\x1b\[[0-9;]*m/g, ""); } +test("local sandbox dry-run derives a deployment-scoped runnable image", async () => { + const dir = makeDeployment({ sandbox: { backend: "local" } }); + try { + const out = await plan(dir); + assert.match(out, /sandbox: local image qm-wiretest-sandbox-local:latest/); + assert.match(out, /LOCAL_SANDBOX_IMAGE/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("the deployment's sandbox/ skills + tools wire into the core via DEPLOYMENT_LAYER", async () => { const dir = makeDeployment({}, sandboxLayer); try { diff --git a/deploy/core/Dockerfile b/deploy/core/Dockerfile index 9be91f94a..df5d0aa68 100644 --- a/deploy/core/Dockerfile +++ b/deploy/core/Dockerfile @@ -1,6 +1,6 @@ FROM node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd -RUN apk add --no-cache ca-certificates curl git git-daemon +RUN apk add --no-cache ca-certificates curl git git-daemon docker-cli WORKDIR /app diff --git a/src/config.ts b/src/config.ts index 50efe23e8..29ec33cf8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -231,6 +231,7 @@ function awsSandboxEnv(env: NodeJS.ProcessEnv): AwsSandboxEnv { ...(numEnvStrict("AWS_SANDBOX_SNAPSHOT_INTERVAL_MS", env.AWS_SANDBOX_SNAPSHOT_INTERVAL_MS) !== undefined ? { snapshotIntervalMs: numEnvStrict("AWS_SANDBOX_SNAPSHOT_INTERVAL_MS", env.AWS_SANDBOX_SNAPSHOT_INTERVAL_MS) } : {}), + ...(env.QM_CORE_CONTAINER ? { coreContainer: env.QM_CORE_CONTAINER } : {}), ...(numEnvStrict("SANDBOX_TIMEOUT_SEC", env.SANDBOX_TIMEOUT_SEC) !== undefined ? { defaultTimeoutSec: numEnvStrict("SANDBOX_TIMEOUT_SEC", env.SANDBOX_TIMEOUT_SEC) } : {}), @@ -251,6 +252,7 @@ interface LocalSandboxEnv { dockerBin?: string; cpus?: number; memoryMb?: number; + coreContainer?: string; defaultTimeoutSec?: number; } diff --git a/src/sandbox/local-sandbox.ts b/src/sandbox/local-sandbox.ts index ce8c97b4f..988e25e36 100644 --- a/src/sandbox/local-sandbox.ts +++ b/src/sandbox/local-sandbox.ts @@ -43,6 +43,7 @@ export interface LocalSandboxOptions { dockerBin?: string; cpus?: number; memoryMb?: number; + coreContainer?: string; defaultTimeoutSec?: number; homeDir?: string; repoRoot?: string; @@ -112,18 +113,20 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox preflightDone = undefined; throw new Error("SANDBOX_BACKEND=local requires a running Docker daemon (is Docker Desktop running?)"); } - const img = await dexec([ + const img = await dexec(["image", "inspect", "-f", "{{.Id}}", image]); + if (img.code !== 0) { + preflightDone = undefined; + throw new Error(`local sandbox image ${image} not found — ${BUILD_HINT}`); + } + const imageId = img.stdout.trim(); + const label = await dexec([ "image", "inspect", "-f", - `{{.Id}} {{if .Config.Labels}}{{index .Config.Labels "${FINGERPRINT_LABEL}"}}{{end}}`, + `{{if .Config.Labels}}{{index .Config.Labels "${FINGERPRINT_LABEL}"}}{{end}}`, image, ]); - if (img.code !== 0) { - preflightDone = undefined; - throw new Error(`local sandbox image ${image} not found — ${BUILD_HINT}`); - } - const [imageId = "", labeled = ""] = img.stdout.trim().split(/\s+/); + const labeled = label.code === 0 ? label.stdout.trim() : ""; if (!staleWarned) { const want = await computeSandboxImageFingerprint(opts.repoRoot ?? process.cwd()); if (want && labeled && labeled !== want) { @@ -165,9 +168,11 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox timeoutMs?: number, signal?: AbortSignal, ): Promise<{ status: number; text: string }> { - const port = await resolvePort(name); + const base = opts.coreContainer + ? `http://${name}:${AGENT_PORT}${path}` + : `http://127.0.0.1:${await resolvePort(name)}${path}`; const signals = [AbortSignal.timeout(timeoutMs ?? 30_000), ...(signal ? [signal] : [])]; - const res = await fetchImpl(`http://127.0.0.1:${port}${path}`, { + const res = await fetchImpl(base, { method: body === undefined ? "GET" : "POST", ...(body === undefined ? {} : { body: JSON.stringify(body), headers: { "content-type": "application/json" } }), signal: AbortSignal.any(signals), @@ -195,6 +200,7 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox portByName.delete(name); const r = await dexec(["start", name]); if (r.code !== 0) throw new Error(`docker start ${name} failed: ${r.stderr.trim()}`); + await connectCore(await ensureNetwork(name)); await waitDaemon(name); } @@ -236,6 +242,21 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox return net; } + async function connectCore(net: string): Promise { + if (!opts.coreContainer) return; + const r = await dexec(["network", "connect", net, opts.coreContainer]); + if (r.code !== 0 && !/already (?:exists|connected)/i.test(r.stderr)) { + throw new Error(`docker network connect ${net} ${opts.coreContainer} failed: ${r.stderr.trim()}`); + } + } + + async function disconnectCore(net: string): Promise { + if (!opts.coreContainer) return; + await dexec(["network", "disconnect", net, opts.coreContainer]).catch( + swallowAs("local-sandbox: network disconnect", undefined), + ); + } + async function runContainer(name: string, scope: string | undefined, withVolume: boolean): Promise { const net = await ensureNetwork(name); const args = [ @@ -253,8 +274,7 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox "--network", net, ...(withVolume && scope ? ["-v", `${localVolumeName(scope)}:${homeDir}`] : []), - "-p", - `127.0.0.1:0:${AGENT_PORT}`, + ...(opts.coreContainer ? [] : ["-p", `127.0.0.1:0:${AGENT_PORT}`]), "--add-host=host.docker.internal:host-gateway", ...(opts.cpus ? ["--cpus", String(opts.cpus)] : []), ...(opts.memoryMb ? ["--memory", `${opts.memoryMb}m`] : []), @@ -263,6 +283,7 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox const r = await dexec(args, 120_000); if (r.code !== 0) throw new Error(`docker run ${name} failed: ${r.stderr.trim()}`); portByName.delete(name); + await connectCore(net); await waitDaemon(name); } @@ -274,6 +295,7 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox const state = await containerState(name); if (state && state.imageId === imageId) { if (!state.running) await startContainer(name); + else await connectCore(await ensureNetwork(name)); activeByContainer.set(name, (activeByContainer.get(name) ?? 0) + 1); return { name, coldStart: false }; } @@ -298,6 +320,7 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox const state = await containerState(name); if (state) { if (!state.running) await startContainer(name); + else await connectCore(await ensureNetwork(name)); activeByContainer.set(name, (activeByContainer.get(name) ?? 0) + 1); return { name, coldStart: false }; } @@ -321,7 +344,7 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox processSessions: true, egressEnforcement: "none", spec: { - os: `Debian 12 (bookworm), glibc — local Docker container on a ${arch()} host (dev only)`, + os: `Debian 12 (bookworm), glibc — local Docker container on a ${arch()} host`, runtimes: ["Node 24", "Python 3 (venv on PATH — `pip install` just works)"], tools: ["git", "curl", "wget", "jq", "unzip", "gnupg", "python3", "gh", "aws (CLI v2)"], notInstalled: ["gcloud", "kubectl", "flyctl", "glab"], @@ -456,6 +479,7 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox for (const [k, name] of scratchByKey) if (name === handle.id) scratchByKey.delete(k); if (tdOpts?.destroy) await dexec(["rm", "-f", handle.id]); else await dexec(["rm", "-f", handle.id]).catch(swallowAs("local-sandbox: scratch rm", undefined)); + await disconnectCore(localNetworkName(handle.id)); await dexec(["network", "rm", localNetworkName(handle.id)]).catch( swallowAs("local-sandbox: scratch network rm", undefined), ); @@ -467,6 +491,7 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox if (tdOpts?.destroy) { await dexec(["rm", "-f", handle.id]).catch(swallowAs("local-sandbox: destroy rm", undefined)); + await disconnectCore(localNetworkName(handle.id)); await dexec(["network", "rm", localNetworkName(handle.id)]).catch( swallowAs("local-sandbox: destroy network rm", undefined), ); diff --git a/test/local-sandbox.test.ts b/test/local-sandbox.test.ts index 2e443f586..47dc81c33 100644 --- a/test/local-sandbox.test.ts +++ b/test/local-sandbox.test.ts @@ -83,6 +83,14 @@ test("a stopped Docker daemon fails provision with the actionable message", asyn ); }); +test("a label-inspection error does not misreport an existing image as missing", async () => { + const fake = installFakeDocker(daemonPort); + fake.labelInspectFails = true; + const sb = makeSandbox(fake); + const handle = await sb.provision(rw(scopeId("personal", "attested-image"))); + await sb.teardown(handle, { destroy: true }); +}); + test("a missing sandbox image fails provision with the build hint", async () => { const fake = installFakeDocker(daemonPort); fake.imageMissing = true; @@ -267,3 +275,42 @@ test("concurrent teardown and provision for one scope serialize (no stop of a fr await sb.teardown(h2); assert.equal(fake.containers.get(h2.id)!.running, false); }); + +test("a replacement core reattaches to an already-running sandbox", async () => { + const fake = installFakeDocker(daemonPort); + const fetchImpl: typeof fetch = (input) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.endsWith("/health")) return Promise.resolve(new Response("", { status: 200 })); + return Promise.resolve(new Response(JSON.stringify({ code: 0, stdout: "", stderr: "", timedOut: false }))); + }; + const sb = makeSandbox(fake, { coreContainer: "qm-test-core", fetchImpl }); + const layers = rw(scopeId("personal", "U39")); + const first = await sb.provision(layers); + const connection = `${localNetworkName(first.id)}|qm-test-core`; + assert.equal(fake.connections.has(connection), true); + fake.connections.delete(connection); + const second = await sb.provision(layers); + assert.equal(second.id, first.id); + assert.equal(fake.connections.has(connection), true); + await sb.teardown(first); + await sb.teardown(second, { destroy: true }); +}); + +test("containerized core joins each sandbox network and reaches the daemon by container name", async () => { + const fake = installFakeDocker(daemonPort); + const seen: string[] = []; + const fetchImpl: typeof fetch = (input) => { + const url = typeof input === "string" ? input : input.toString(); + seen.push(url); + if (url.endsWith("/health")) return Promise.resolve(new Response("", { status: 200 })); + return Promise.resolve(new Response(JSON.stringify({ code: 0, stdout: "", stderr: "", timedOut: false }))); + }; + const sb = makeSandbox(fake, { coreContainer: "qm-test-core", fetchImpl }); + const h = await sb.provision(rw(scopeId("personal", "U40"))); + const args = fake.containers.get(h.id)!.args; + assert.equal(args.includes("-p"), false); + assert.equal(fake.connections.has(`${localNetworkName(h.id)}|qm-test-core`), true); + assert.ok(seen.includes(`http://${h.id}:8080/health`)); + await sb.teardown(h, { destroy: true }); + assert.equal(fake.connections.has(`${localNetworkName(h.id)}|qm-test-core`), false); +}); diff --git a/test/support/fake-docker.ts b/test/support/fake-docker.ts index 15ea7276a..d456a5be6 100644 --- a/test/support/fake-docker.ts +++ b/test/support/fake-docker.ts @@ -6,6 +6,7 @@ export interface FakeContainer { running: boolean; labels: Record; volume?: string; + args: string[]; } export interface FakeDocker { @@ -13,26 +14,31 @@ export interface FakeDocker { containers: Map; volumes: Set; networks: Set; + connections: Set; runCount: number; daemonDown: boolean; imageMissing: boolean; imageId: string; imageFingerprint: string; + labelInspectFails: boolean; } export function installFakeDocker(daemonPort: number): FakeDocker { const containers = new Map(); const volumes = new Set(); const networks = new Set(); + const connections = new Set(); const self: FakeDocker = { containers, volumes, networks, + connections, runCount: 0, daemonDown: false, imageMissing: false, imageId: "sha256:image-v1", imageFingerprint: "", + labelInspectFails: false, dockerExec: async (args) => exec(args), }; @@ -40,7 +46,7 @@ export function installFakeDocker(daemonPort: number): FakeDocker { const fail = (stderr: string) => ({ code: 1, stdout: "", stderr }); function parseRun(args: string[]): FakeContainer { - const c: FakeContainer = { name: "", imageId: self.imageId, running: true, labels: {} }; + const c: FakeContainer = { name: "", imageId: self.imageId, running: true, labels: {}, args }; for (let i = 0; i < args.length; i++) { const a = args[i]!; if (a === "--name") c.name = args[++i]!; @@ -61,7 +67,9 @@ export function installFakeDocker(daemonPort: number): FakeDocker { return ok("Docker version fake"); case "image": { if (self.imageMissing) return fail("Error: No such image"); - return ok(`${self.imageId} ${self.imageFingerprint}`); + if (rest.includes("{{.Id}}")) return ok(self.imageId); + if (self.labelInspectFails) return fail('map has no entry for key "Labels"'); + return ok(self.imageFingerprint); } case "inspect": { const name = rest[rest.length - 1]!; @@ -78,6 +86,15 @@ export function installFakeDocker(daemonPort: number): FakeDocker { return ok(name); } if (sub === "rm") return networks.delete(name) ? ok(name) : fail(`Error: No such network: ${name}`); + if (sub === "connect" || sub === "disconnect") { + const container = rest[2]!; + const key = `${name}|${container}`; + if (sub === "connect") { + if (connections.has(key)) return fail("endpoint already exists"); + connections.add(key); + } else connections.delete(key); + return ok(key); + } return fail(`unknown network subcommand ${sub}`); } case "volume": {