diff --git a/adrs/boxd-sandbox.md b/adrs/boxd-sandbox.md new file mode 100644 index 000000000..542d333e6 --- /dev/null +++ b/adrs/boxd-sandbox.md @@ -0,0 +1 @@ +boxd offers composable computers for devs and agents. we focus on the dx; we're the fastest in the market (4ms creation time and native feel inside the machine), fully persistent, and fully composable. I think a boxd integration is suitable for qm and I hope we can get it merged soon. if any changes are required, let me know. best, michiel - co-founder boxd.sh diff --git a/cli/README.md b/cli/README.md index b95eb5648..9d9a87eca 100644 --- a/cli/README.md +++ b/cli/README.md @@ -69,6 +69,10 @@ pinned sandbox base, mounts the host Docker socket into trusted core, and connec core to each sandbox's private network. An explicit `sandbox.image` uses that runnable local image instead. +On any target, `sandbox.backend: "boxd"` runs each agent computer as a boxd microVM +(needs `BOXD_API_KEY`; no `sandbox.app`, no layer image to publish — skills arrive through +the deployment-layer sync on every `up`). + 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/src/backends/aws.ts b/cli/src/backends/aws.ts index 2f1e9f717..a905d7c53 100644 --- a/cli/src/backends/aws.ts +++ b/cli/src/backends/aws.ts @@ -267,6 +267,10 @@ export function serviceEnvironment(config: QmConfig, service: ServiceName): Reco SANDBOX_BACKEND: config.env.core?.SANDBOX_BACKEND?.trim() || config.sandbox?.backend || "sprites", ...stores, }); + } else if (config.sandbox?.backend === "boxd") { + delete env.FLY_BASE_IMAGE; + delete env.FLY_SANDBOX_APP_NAME; + Object.assign(env, { SANDBOX_BACKEND: "boxd", ...stores }); } else { delete env.FLY_BASE_IMAGE; delete env.FLY_SANDBOX_APP_NAME; diff --git a/cli/src/backends/registry.ts b/cli/src/backends/registry.ts index 33ebdd71c..3ff22daef 100644 --- a/cli/src/backends/registry.ts +++ b/cli/src/backends/registry.ts @@ -5,7 +5,7 @@ import { syncDeploymentLayer, type DeploymentLayerTransport } from "../deploymen import { TARGET_ENV_DEFAULTS, type TargetEnvDefaults } from "../target-env-defaults.ts"; import { renderTerraformVars } from "../terraform.ts"; import { buildAwsMicrovmImage, deleteAwsMicrovmImage, deleteAwsTaskDefinitions } from "../commands/infra.ts"; -import { runSandboxPublish, type SandboxPublishOpts } from "../commands/sandbox.ts"; +import { runSandboxPublish, type SandboxPublishOpts, assertPublishableSandbox } from "../commands/sandbox.ts"; import { awsScaffold, dockerScaffold, flyScaffold, type ProviderScaffold } from "../provider-scaffold.ts"; import type { ResolvedPlugin } from "../plugins.ts"; import { runnableServices } from "../services.ts"; @@ -321,6 +321,7 @@ const aws: HostingProvider = { config.aws ? { accountOrOrganization: config.aws.accountId, region: config.aws.region } : {}, requiresSandboxApp: false, publishSandbox: async (ctx, opts) => { + assertPublishableSandbox(ctx.config); if (ctx.config.sandbox?.backend !== "sprites") { throw new CliError( `this AWS deployment runs Lambda MicroVM sandboxes (sandbox.backend is not "sprites"); use \`qm sandbox build\` to validate the layer and \`qm infra build-image\` to publish the runtime — or set "sandbox.backend": "sprites" with "sandbox.app" to host sandboxes in an operator-published layer image`, diff --git a/cli/src/commands/check.ts b/cli/src/commands/check.ts index a6f6574db..c873a3dc7 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 { localSandboxActive, mockHarnessWarning, sandboxPinPending, type QmConfig } from "../config.ts"; +import { mockHarnessWarning, sandboxPinPending, type QmConfig, flySandboxAppExpected } 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 && !localSandboxActive(config) && !config.sandbox?.app?.trim()) { + if (provider.requiresSandboxApp && flySandboxAppExpected(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/commands/sandbox.ts b/cli/src/commands/sandbox.ts index fb5cd512f..5513a816f 100644 --- a/cli/src/commands/sandbox.ts +++ b/cli/src/commands/sandbox.ts @@ -443,7 +443,15 @@ export function recordSandboxPin(configPath: string, image: string | undefined, writeFileSync(configPath, updateConfigSandbox(readFileSync(configPath, "utf8"), updates)); } +export function assertPublishableSandbox(config: QmConfig): void { + if (config.sandbox?.backend !== "boxd") return; + throw new CliError( + 'this deployment runs boxd microVMs ("sandbox.backend": "boxd"), so there is nothing to publish — agent computers boot the cluster\'s template and receive the skill layer from every `qm up`; binaries under sandbox/ do not reach them', + ); +} + export function runSandboxPublish(opts: SandboxPublishOpts): { image: string } | undefined { + assertPublishableSandbox(opts.config); let prepared = prepare(opts); assertPublishPlatform(prepared.dockerfileBody); const repository = publishedRepository(opts); diff --git a/cli/src/config.ts b/cli/src/config.ts index 769d6a506..ca222c579 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -45,7 +45,7 @@ export interface PluginEntry { } export interface SandboxConfig { - backend?: "local" | "sprites" | "aws"; + backend?: "local" | "sprites" | "aws" | "boxd"; app?: string; image?: string; baseImage?: string; @@ -208,6 +208,9 @@ const SANDBOX_PIN_PENDING = `"sandbox.app" is set but no sandbox layer image is export const localSandboxActive = (config: QmConfig): boolean => config.target === "docker" && config.sandbox?.backend === "local"; +export const flySandboxAppExpected = (config: QmConfig): boolean => + !localSandboxActive(config) && config.sandbox?.backend !== "boxd"; + export const sandboxPinPending = (config: QmConfig): boolean => config.target !== "aws" && !localSandboxActive(config) && Boolean(config.sandbox?.app && !config.sandbox.image); @@ -236,6 +239,7 @@ export function sandboxCoreEnv( if (sb.image) env.LOCAL_SANDBOX_IMAGE = sb.image; return { env, missingSecrets }; } + if (sb.backend === "boxd") env.SANDBOX_BACKEND = "boxd"; if (sb.app) { if (!sb.image) throw new CliError(SANDBOX_PIN_PENDING, { clause: "config.v1" }); const violation = sandboxImagePinErrors(config)[0]; @@ -1335,9 +1339,9 @@ function validateSandbox(raw: unknown, path: string, target: Target): SandboxCon }; const out: SandboxConfig = {}; if (o["backend"] !== undefined) { - if (o["backend"] !== "local" && o["backend"] !== "sprites" && o["backend"] !== "aws") { + if (o["backend"] !== "local" && o["backend"] !== "sprites" && o["backend"] !== "aws" && o["backend"] !== "boxd") { throw new CliError( - `${path}: "sandbox.backend" must be "local" (Docker containers on the deployment host), "sprites" (Fly Sprites), or "aws" (Lambda MicroVM sandboxes)`, + `${path}: "sandbox.backend" must be "local" (Docker containers on the deployment host), "sprites" (Fly Sprites), "aws" (Lambda MicroVM sandboxes), or "boxd" (boxd microVMs)`, ); } out.backend = o["backend"]; @@ -1395,6 +1399,14 @@ function validateSandbox(raw: unknown, path: string, target: Target): SandboxCon ); } } + if (out.backend === "boxd") { + const stray = (["app", "image", "baseImage", "env", "secretEnv"] as const).filter((key) => out[key] !== undefined); + if (stray.length) { + throw new CliError( + `${path}: "sandbox.backend": "boxd" runs boxd microVMs, which ignore ${stray.map((key) => `"sandbox.${key}"`).join(", ")} (Fly layer-image settings) — remove them or set "sandbox.backend": "sprites"`, + ); + } + } if (out.image && !out.app && out.backend !== "local") { throw new CliError(`${path}: "sandbox.image" requires "sandbox.app" unless "sandbox.backend" is "local"`); } @@ -1405,7 +1417,7 @@ function validateSandbox(raw: unknown, path: string, target: Target): SandboxCon } if (SANDBOX_BACKEND_POLICY[target].requireExplicit && out.backend === undefined) { throw new CliError( - `${path}: target ${JSON.stringify(target)} requires an explicit "sandbox.backend" — "sprites" boots the operator-published layer image in "sandbox.app"; "aws" runs Lambda MicroVM sandboxes (or omit the whole "sandbox" block for the MicroVM default)`, + `${path}: target ${JSON.stringify(target)} requires an explicit "sandbox.backend" — "sprites" boots the operator-published layer image in "sandbox.app"; "aws" runs Lambda MicroVM sandboxes (or omit the whole "sandbox" block for the MicroVM default); "boxd" runs boxd microVMs`, ); } return out; diff --git a/cli/src/provider-scaffold.ts b/cli/src/provider-scaffold.ts index 41ee9dd2b..3af513e41 100644 --- a/cli/src/provider-scaffold.ts +++ b/cli/src/provider-scaffold.ts @@ -165,7 +165,8 @@ export const dockerScaffold: ProviderScaffold = { sandbox: `, // The Fly app agents execute in. The core boots the immutable sandbox image - // recorded by \`qm sandbox publish\`. + // recorded by \`qm sandbox publish\`. To run agent computers as boxd microVMs + // instead (no Fly app, needs BOXD_API_KEY): "sandbox": { "backend": "boxd" } "sandbox": { "app": ${JSON.stringify(`${orgId}-sandboxes`)} }`, }), ignores: [".env", "node_modules/", ".generated/"], @@ -199,7 +200,8 @@ export const flyScaffold: ProviderScaffold = { sandbox: ` // The Fly app agents execute in. The core boots the immutable sandbox image - // recorded by \`qm sandbox publish\`. + // recorded by \`qm sandbox publish\`. To run agent computers as boxd microVMs + // instead (no Fly app, needs BOXD_API_KEY): "sandbox": { "backend": "boxd" } "sandbox": { "app": ${JSON.stringify(`${orgId}-sandboxes`)} }`, }), ignores: [".env", "node_modules/", ".generated/"], @@ -259,7 +261,9 @@ export const awsScaffold: ProviderScaffold = { // Where agent sandboxes execute. Omitting "sandbox" entirely runs AWS Lambda MicroVMs // (published by \`qm infra build-image\`). To boot an operator-published sandbox layer // image in a Fly app instead (published by \`qm sandbox publish\`), declare it explicitly: - // "sandbox": { "backend": "sprites", "app": ${JSON.stringify(`${orgId}-sandboxes`)} }`, + // "sandbox": { "backend": "sprites", "app": ${JSON.stringify(`${orgId}-sandboxes`)} } + // To run them as boxd microVMs instead (needs BOXD_API_KEY): + // "sandbox": { "backend": "boxd" }`, }); }, ignores: [ diff --git a/cli/src/providers.ts b/cli/src/providers.ts index dc478dfc7..1e0d3f0ef 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 = "local" | "sprites" | "aws"; +export type SandboxBackendId = "local" | "sprites" | "aws" | "boxd"; export interface SandboxBackendPolicy { /** Sandbox backends this hosting target can run. */ @@ -16,9 +16,9 @@ export interface SandboxBackendPolicy { /** Keyed by hosting target so adding a target forces a sandbox-backend decision. */ export const SANDBOX_BACKEND_POLICY: Record = { - docker: { allowed: ["local", "sprites"], requireExplicit: false }, - fly: { allowed: ["sprites"], requireExplicit: false }, - aws: { allowed: ["sprites", "aws"], requireExplicit: true }, + docker: { allowed: ["local", "sprites", "boxd"], requireExplicit: false }, + fly: { allowed: ["sprites", "boxd"], requireExplicit: false }, + aws: { allowed: ["sprites", "aws", "boxd"], requireExplicit: true }, }; export const targetsAllowingSandboxBackend = (backend: SandboxBackendId): Target[] => diff --git a/cli/src/secrets.ts b/cli/src/secrets.ts index f03e41aec..1bb8d5175 100644 --- a/cli/src/secrets.ts +++ b/cli/src/secrets.ts @@ -114,7 +114,21 @@ export const FIRST_PARTY_SECRET_SPECS: readonly SecretSpec[] = [ name: "FLY_SANDBOX_API_TOKEN", service: "core", envName: "FLY_API_TOKEN", - required: { when: { kind: "target", target: "fly" } }, + required: { + when: { + kind: "all", + conditions: [ + { kind: "target", target: "fly" }, + { + kind: "any", + conditions: [ + { kind: "env-absent", service: "core", name: "SANDBOX_BACKEND" }, + { kind: "env-equals", service: "core", name: "SANDBOX_BACKEND", value: "sprites" }, + ], + }, + ], + }, + }, description: "Fly deploy token scoped to the agent-computer app.", generate: "fly tokens create deploy -a -x 8760h", }, @@ -139,6 +153,13 @@ export const FIRST_PARTY_SECRET_SPECS: readonly SecretSpec[] = [ description: "smolmachines API key for the agent-computer substrate.", generate: "create an API key in the smolmachines console (https://smolmachines.com/console)", }, + { + name: "BOXD_API_KEY", + service: "core", + required: { when: { kind: "env-equals", service: "core", name: "SANDBOX_BACKEND", value: "boxd" } }, + description: "boxd API key for the agent-computer substrate; fenced to the org that owns the agent computers.", + generate: "boxd auth keys create qm --org ", + }, { name: "DATABASE_URL", service: "core", diff --git a/cli/src/services.ts b/cli/src/services.ts index a07a21945..011c60636 100644 --- a/cli/src/services.ts +++ b/cli/src/services.ts @@ -195,6 +195,8 @@ const CATALOG: Record = { "S3_REGION", "PUBLIC_WEB_URL", "FLY_ORG", + "FLY_SANDBOX_APP_NAME", + "FLY_BASE_IMAGE", "FLY_DEPLOY_BASE_IMAGE", "PI_DETECT_MODEL", ], diff --git a/cli/src/target-env-defaults.ts b/cli/src/target-env-defaults.ts index fd53379d8..e39cd072b 100644 --- a/cli/src/target-env-defaults.ts +++ b/cli/src/target-env-defaults.ts @@ -15,13 +15,13 @@ const AWS_RENDER_ENV_DEFAULTS: Readonly + service === "core" && name === "SANDBOX_BACKEND" ? config.sandbox?.backend : undefined; + export const TARGET_ENV_DEFAULTS: Record = { - docker: () => undefined, - fly: (_config, service, name) => FLY_TEMPLATE_ENV_DEFAULTS[service]?.[name], - aws: (config, service, name) => { - const rendered = AWS_RENDER_ENV_DEFAULTS[service]?.[name]; - if (rendered === undefined) return undefined; - if (name === "SANDBOX_BACKEND") return config.sandbox?.backend ?? rendered; - return rendered; - }, + docker: declaredSandboxBackend, + fly: (config, service, name) => + declaredSandboxBackend(config, service, name) ?? FLY_TEMPLATE_ENV_DEFAULTS[service]?.[name], + aws: (config, service, name) => + declaredSandboxBackend(config, service, name) ?? AWS_RENDER_ENV_DEFAULTS[service]?.[name], }; diff --git a/cli/test/aws.test.ts b/cli/test/aws.test.ts index c9086f2cd..147cf1666 100644 --- a/cli/test/aws.test.ts +++ b/cli/test/aws.test.ts @@ -4311,3 +4311,13 @@ test("AWS doctor still fails a pushed secret store holding a placeholder value", assert.equal(probe.failures.length, 1); assert.match(probe.failures[0]!, /CORE_SIGNING_SECRET: missing, placeholder, or insecure value/); }); + +test("an AWS deployment on boxd renders the boxd substrate and none of the Fly or MicroVM coordinates", () => { + const core = serviceEnvironment({ ...config, sandbox: { backend: "boxd" } }, "core"); + assert.equal(core.SANDBOX_BACKEND, "boxd"); + assert.equal(core.FLY_SANDBOX_APP_NAME, undefined); + assert.equal(core.FLY_BASE_IMAGE, undefined); + assert.equal(core.AWS_SANDBOX_IMAGE, undefined); + assert.equal(core.AWS_SANDBOX_REGION, undefined); + assert.equal(core.SESSION_STORE, "postgres"); +}); diff --git a/cli/test/check.test.ts b/cli/test/check.test.ts index 3d866a8e9..371e403f5 100644 --- a/cli/test/check.test.ts +++ b/cli/test/check.test.ts @@ -477,3 +477,23 @@ test("a delivered secret name shadowing renderer-derived env fails config.secret rmSync(dockerTarget.dir, { recursive: true, force: true }); } }); + +test("docker and fly with sandbox.backend boxd pass without a Fly sandbox app", () => { + for (const config of [ + { sandbox: { backend: "boxd" as const } }, + { + target: "fly" as const, + region: "sjc", + flyOrg: "acme", + env: { core: { SNAPSHOT_STORE: "s3", TRANSFER_STORE: "s3", S3_BUCKET: "acme-data", S3_REGION: "auto" } }, + sandbox: { backend: "boxd" as const }, + }, + ]) { + const d = deployment(() => {}, config); + try { + assert.doesNotThrow(() => check(d)); + } finally { + rmSync(d.dir, { recursive: true, force: true }); + } + } +}); diff --git a/cli/test/config.test.ts b/cli/test/config.test.ts index a670aec44..3ba96d791 100644 --- a/cli/test/config.test.ts +++ b/cli/test/config.test.ts @@ -860,11 +860,11 @@ test("sandbox shape errors: object, app non-empty string, env string-map, secret { sandbox: { secretEnv: ["1BAD"] }, rx: /not a valid env var name/ }, { sandbox: { backend: "k8s", app: "acme-sandboxes" }, - rx: /"sandbox.backend" must be "local".*"sprites".*or "aws"/, + rx: /"sandbox.backend" must be "local".*"sprites".*"aws".*or "boxd"/, }, { sandbox: { backend: "fly", app: "acme-sandboxes" }, - rx: /"sandbox.backend" must be "local".*"sprites".*or "aws"/, + rx: /"sandbox.backend" must be "local".*"sprites".*"aws".*or "boxd"/, }, { sandbox: { backend: "sprites" }, rx: /"sandbox.backend": "sprites" requires "sandbox.app"/ }, { @@ -1269,3 +1269,37 @@ test("a mock deployment is named as one, and a real harness draws no warning", ( assert.equal(mockHarnessWarning(loadConfigAt(path).config), undefined, "the fly template renders HARNESS=pi"); }); }); + +test("boxd is a first-class sandbox substrate on every target and takes no Fly layer-image settings", () => { + const aws = { + accountId: "123456789012", + region: "us-west-2", + cluster: "acme", + deployRoleArn: "arn:aws:iam::123456789012:role/deploy", + secretsPrefix: "acme/", + imageLabel: "release", + networking: { cloudMapNamespace: "acme.internal" }, + services: { core: { ecrRepository: "core", ecsService: "acme-core", cpu: 512, memory: 1024 } }, + }; + for (const extra of [{}, { target: "fly", region: "sjc", flyOrg: "acme" }, { target: "aws", aws }]) { + withConfig({ ...extra, sandbox: { backend: "boxd" } }, ({ path }) => { + const { config } = loadConfigAt(path); + assert.equal(config.sandbox?.backend, "boxd"); + assert.deepEqual(sandboxCoreEnv(config), { env: { SANDBOX_BACKEND: "boxd" }, missingSecrets: [] }); + }); + } + for (const [key, value] of [ + ["app", "acme-sandboxes"], + ["image", `registry.fly.io/acme-sandboxes@sha256:${"a".repeat(64)}`], + ["baseImage", `registry.fly.io/base@sha256:${"b".repeat(64)}`], + ["env", { TZ: "UTC" }], + ["secretEnv", ["COMPANY_TOKEN"]], + ] as const) { + withConfig({ sandbox: { backend: "boxd", [key]: value } }, ({ path }) => { + assert.throws( + () => loadConfigAt(path), + new RegExp(`"sandbox.backend": "boxd" runs boxd microVMs, which ignore "sandbox.${key}"`), + ); + }); + } +}); diff --git a/cli/test/fly-derive.test.ts b/cli/test/fly-derive.test.ts index c66536cb6..3a158ea62 100644 --- a/cli/test/fly-derive.test.ts +++ b/cli/test/fly-derive.test.ts @@ -137,3 +137,9 @@ test("a vms override rewrites the core [[vm]] size/memory without touching other if (adminVm) assert.ok(adminToml.includes(adminVm[0]), "admin [[vm]] block changed despite no vms override"); assert.doesNotMatch(adminToml, /shared-cpu-2x|memory = "4gb"/); }); + +test("a Fly deployment on boxd derives SANDBOX_BACKEND = boxd for core and no Fly sandbox app", () => { + const core = derivedTomlFor({ ...exampleFlyConfig(), sandbox: { backend: "boxd" } }, "core", repoRoot); + assert.match(core, /^\s*SANDBOX_BACKEND = "boxd"$/m); + assert.doesNotMatch(core, /FLY_SANDBOX_APP_NAME|FLY_BASE_IMAGE/); +}); diff --git a/cli/test/sandbox-publish.test.ts b/cli/test/sandbox-publish.test.ts index 1dcc5d113..949b0ff64 100644 --- a/cli/test/sandbox-publish.test.ts +++ b/cli/test/sandbox-publish.test.ts @@ -855,3 +855,46 @@ process.exit(1); rmSync(emptyPath, { recursive: true, force: true }); } }); + +test("sandbox publish refuses a boxd deployment before touching docker, on every provider", async () => { + const dir = mkdtempSync(join(tmpdir(), "qm-publish-boxd-")); + const priorPath = process.env.PATH; + try { + const configPath = join(dir, CONFIG_FILENAME); + const configBody = JSON.stringify({ + contract: 1, + orgId: "acme", + publicUrl: "https://agent.acme.example", + target: "docker", + services: ["core"], + sandbox: { backend: "boxd" }, + }); + writeFileSync(configPath, configBody); + const dockerLog = fakeDocker(dir); + process.env.PATH = `${dir}:${priorPath}`; + const { config } = loadConfigAt(configPath); + for (const [target, extra] of [ + ["docker", {}], + ["fly", { region: "sjc", flyOrg: "acme" }], + ["aws", { aws: AWS_BLOCK }], + ] as const) { + const ctx: DeployContext = { + config: { ...config, ...extra, target }, + configPath, + configDir: dir, + sandboxDir: join(dir, "sandbox"), + target, + }; + await assert.rejects( + () => + hostingProvider(target).publishSandbox(ctx, { sandboxDir: ctx.sandboxDir, config: ctx.config, configPath }), + /runs boxd microVMs.*nothing to publish/, + ); + } + assert.equal(readFileSync(dockerLog, "utf8"), "", "no docker side effect"); + assert.equal(readFileSync(configPath, "utf8"), configBody); + } finally { + process.env.PATH = priorPath; + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/cli/test/secrets.test.ts b/cli/test/secrets.test.ts index c10f89908..b43f4c57c 100644 --- a/cli/test/secrets.test.ts +++ b/cli/test/secrets.test.ts @@ -380,3 +380,49 @@ test("the .env.example catalog names every secret exactly once", () => { assert.deepEqual(duplicated, [], `services=${services.join("+")} lists a secret twice`); } }); + +test("the boxd API key is a catalog secret exactly when the sandbox backend is boxd, on every target", () => { + const aws = { + accountId: "123456789012", + region: "us-west-2", + cluster: "acme", + deployRoleArn: "arn:aws:iam::123456789012:role/deploy", + secretsPrefix: "acme/", + imageLabel: "release", + networking: { cloudMapNamespace: "acme.internal" }, + services: { core: { ecrRepository: "core", ecsService: "acme-core", cpu: 512, memory: 1024 } }, + } as const; + for (const extra of [{}, { target: "fly" as const }, { target: "aws" as const, aws }]) { + assert.ok(secretByName(makeConfig({ ...extra, sandbox: { backend: "boxd" } }), "BOXD_API_KEY").required); + assert.ok(!computedSecrets(makeConfig(extra)).some((secret) => secret.name === "BOXD_API_KEY")); + } + assert.ok( + !computedSecrets(makeConfig({ sandbox: { backend: "boxd" } })).some((secret) => secret.name === "SPRITES_TOKEN"), + ); +}); + +test("an explicit sandbox.backend drives the substrate secret on docker and fly too", () => { + assert.ok(secretByName(makeConfig({ sandbox: { backend: "sprites", app: "acme-sb" } }), "SPRITES_TOKEN").required); + assert.ok( + secretByName(makeConfig({ target: "fly", sandbox: { backend: "sprites", app: "acme-sb" } }), "SPRITES_TOKEN") + .required, + ); + assert.ok( + !computedSecrets(makeConfig({ sandbox: { app: "acme-sb" } })).some((secret) => secret.name === "SPRITES_TOKEN"), + ); +}); + +test("the Fly sandbox token is not demanded when a Fly deployment runs its agent computers on boxd", () => { + assert.ok(secretByName(makeConfig({ target: "fly" }), "FLY_SANDBOX_API_TOKEN").required); + assert.ok( + secretByName( + makeConfig({ target: "fly", sandbox: { backend: "sprites", app: "acme-sb" } }), + "FLY_SANDBOX_API_TOKEN", + ).required, + ); + assert.ok( + !computedSecrets(makeConfig({ target: "fly", sandbox: { backend: "boxd" } })).some( + (secret) => secret.name === "FLY_SANDBOX_API_TOKEN", + ), + ); +}); diff --git a/docs/deploy-directory.md b/docs/deploy-directory.md index 7f9eee187..ac7f2be8e 100644 --- a/docs/deploy-directory.md +++ b/docs/deploy-directory.md @@ -21,9 +21,9 @@ The Dockerfile is optional when every declared binary is present in its tool dir ## Configuration -The root object requires `contract: 1`, `orgId`, `publicUrl`, `target`, and `services` including `core`. Docker and Fly also require `sandbox.app`. On AWS the sandbox substrate is an explicit choice: omitting the `sandbox` block runs named Lambda MicroVM images; declaring one requires `sandbox.backend` — `"sprites"` boots the operator-published layer image in `sandbox.app`, `"aws"` states the MicroVM default in the file. Unknown contract majors fail closed. `target` is `docker`, `fly`, or `aws`. +The root object requires `contract: 1`, `orgId`, `publicUrl`, `target`, and `services` including `core`. Docker and Fly also require `sandbox.app` unless `sandbox.backend` is `"local"` or `"boxd"`. On AWS the sandbox substrate is an explicit choice: omitting the `sandbox` block runs named Lambda MicroVM images; declaring one requires `sandbox.backend` — `"sprites"` boots the operator-published layer image in `sandbox.app`, `"aws"` states the MicroVM default in the file, `"boxd"` runs agent computers as boxd microVMs. `"boxd"` is accepted on every target, needs only `BOXD_API_KEY`, and takes none of the Fly layer-image settings. Unknown contract majors fail closed. `target` is `docker`, `fly`, or `aws`. -Common optional fields select the model, plugins, extra skill directories, per-service non-secret environment values, image overrides, sandbox settings, and an external security screen. `botName` (at most 31 characters, so the generated "`` SSO" app name fits Slack's 35-character cap) names the bot everywhere users see it — the generated Slack app manifests, the prompt identity, and sign-in pages — and `orgName` (at most 40) is how the bot refers to the organization; both default to neutral values and can be changed live from the Admin page's Branding card, which then takes precedence over the deployed values. `sandbox.backend` selects the aws-target sandbox substrate (see above); `sandbox.image` is the immutable rootfs pin used at boot; `sandbox.baseImage` records the digest-pinned build input; `sandbox.env` is non-secret runtime environment; `sandbox.secretEnv` lists org-wide secret names whose values are forwarded to every sandbox. `securityScreen` contains `backend: "proxy"`, a lowercase provider label, an HTTPS endpoint, and a `shadow` or `enforce` rollout. Its presence requires `secretEnv.core.SECURITY_SCREEN_PROXY_TOKEN`; absence keeps Auto on the built-in model classifier. +Common optional fields select the model, plugins, extra skill directories, per-service non-secret environment values, image overrides, sandbox settings, and an external security screen. `botName` (at most 31 characters, so the generated "`` SSO" app name fits Slack's 35-character cap) names the bot everywhere users see it — the generated Slack app manifests, the prompt identity, and sign-in pages — and `orgName` (at most 40) is how the bot refers to the organization; both default to neutral values and can be changed live from the Admin page's Branding card, which then takes precedence over the deployed values. `sandbox.backend` selects the sandbox substrate (see above); `sandbox.image` is the immutable rootfs pin used at boot; `sandbox.baseImage` records the digest-pinned build input; `sandbox.env` is non-secret runtime environment; `sandbox.secretEnv` lists org-wide secret names whose values are forwarded to every sandbox. `securityScreen` contains `backend: "proxy"`, a lowercase provider label, an HTTPS endpoint, and a `shadow` or `enforce` rollout. Its presence requires `secretEnv.core.SECURITY_SCREEN_PROXY_TOKEN`; absence keeps Auto on the built-in model classifier. Fly requires `region` and `flyOrg`. AWS requires a 12-digit account, region, deployment label, ECS cluster, deploy-role ARN, Secrets Manager prefix, DNS-valid Cloud Map namespace, and an entry for every enabled first-party service and discovered plugin containing a unique valid ECR repository, a unique valid ECS service, and a valid Fargate CPU/memory combination. The cluster is constrained so every IAM, RDS, ALB, and related name derived by the reference module is valid. `imageLabel` identifies the complete deployment manifest used by rollback and live drift checks; the matching OCI/ECR tag is a convenience pointer. Workloads may also set `arm64`/`amd64` architecture, non-secret build arguments, or role ARNs. External prebuilt images must declare their architecture; source-built and built-in workloads use their platform default. Cloud Map names are the private workload addresses. The reference AWS module exposes CloudFront over HTTPS and restricts its HTTP ALB origin to CloudFront's managed origin prefix. With portal enabled, it is the ALB's sole target; access to private core, web, and admin surfaces requires signed portal identity. Without portal, only core is an ALB target. A real harness requires an HTTPS `publicUrl`. @@ -98,7 +98,7 @@ Deployment-specific safety belongs here too. For example, an ambiently authentic When `sandbox/` exists, every `up` sends its descriptors and complete text skill trees to source-authenticated `PUT /v1/deployment-layer`. Without `sandbox/`, `up` skips layer sync and leaves the deployed layer unchanged. Core validates submitted bundles again, stores them in Postgres table `deployment_layer`, versions them by a canonical SHA-256 content hash, records an audit event, hydrates them before serving, and returns the restorable bundle with its metadata and resolved runtime state from source-authenticated `GET /v1/deployment-layer`. Removed layer-owned skills are archived. Filesystem `DEPLOYMENT_LAYER` remains a bootstrap input for local and recovery use. -The sandbox handoff is a substrate image pin plus a layer content hash. Docker and Fly use `sandbox publish` to push an OCI image, resolve its immutable digest, and record it in the config. AWS with `sandbox.backend: "aws"` (or no sandbox block) uses `infra build-image` to package the guest agent as a Lambda MicroVM image and records its immutable image version and execution role; with `sandbox.backend: "sprites"`, `sandbox publish` pushes the layer image and records its digest pin in the durable deployment manifest, which `up`, `check --live`, and `rollback` resolve. Service task definitions and sandbox root filesystems use immutable pins, not mutable tags. +The sandbox handoff is a substrate image pin plus a layer content hash. Docker and Fly use `sandbox publish` to push an OCI image, resolve its immutable digest, and record it in the config. AWS with `sandbox.backend: "aws"` (or no sandbox block) uses `infra build-image` to package the guest agent as a Lambda MicroVM image and records its immutable image version and execution role; with `sandbox.backend: "sprites"`, `sandbox publish` pushes the layer image and records its digest pin in the durable deployment manifest, which `up`, `check --live`, and `rollback` resolve. With `sandbox.backend: "boxd"` there is no image to publish on any target: machines boot the boxd cluster's template, receive the skill layer from the deployment-layer sync on every `up`, and binaries declared under `sandbox/` do not reach them, so `sandbox publish` refuses. Service task definitions and sandbox root filesystems use immutable pins, not mutable tags. Postgres stores create their tables lazily with idempotent DDL through the shared pool. The Terraform module creates RDS and its `DATABASE_URL` secret. On AWS, `up` takes a manual RDS snapshot before its first mutation — refusing an unavailable database or one whose automated-backup retention is below `aws.dbRetentionMinDays` (default 1) — named after the deployment manifest it precedes and recorded in that manifest; older pre-deploy snapshots are pruned to a bounded count, and `aws.predeployDbSnapshot: false` opts a deployment out. Restore remains operator-run: `rollback` prints the snapshot to restore alongside the code it rolls back. diff --git a/package-lock.json b/package-lock.json index e2e634606..43a2223f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@aws-sdk/client-secrets-manager": "^3.1089.0", "@aws-sdk/client-sts": "^3.1075.0", "@aws-sdk/credential-provider-node": "^3.972.70", + "@boxd-sh/sdk": "0.2.4", "@earendil-works/pi-ai": "0.82.0", "@earendil-works/pi-coding-agent": "https://github.com/yc-software/pi/releases/download/qm-pi-coding-agent-0.82.0-security.3/earendil-works-pi-coding-agent-0.82.0-qm-security.3.tgz", "@fly/sprites": "0.0.1", @@ -60,7 +61,7 @@ }, "cli": { "name": "@yc-software/qm", - "version": "0.1.0", + "version": "0.1.9", "dev": true, "license": "MIT", "bin": { @@ -768,6 +769,25 @@ "node": ">=6.9.0" } }, + "node_modules/@boxd-sh/sdk": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@boxd-sh/sdk/-/sdk-0.2.4.tgz", + "integrity": "sha512-Adq97+2fGjIaWr3Epk6wnjMfjgVz9xTy3DmE5DHn45XPR4aXGQVHxrtS2KJMcaj23rg2UDvlu01xLmQj/wGItg==", + "license": "MIT", + "dependencies": { + "@bufbuild/protobuf": "^2.10.2", + "@grpc/grpc-js": "^1.12.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.14.0.tgz", + "integrity": "sha512-C3UGsiCwSprE2NKIIFA3hCDlpXTMCAXRZuEVp88L1GY36Y41+rYL5fryE+nOFhp4p4JPQvdV8PQ4DWgHgeTE+w==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@earendil-works/pi-agent-core": { "version": "0.82.1", "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.82.1.tgz", @@ -886,75 +906,1854 @@ "anthropic-ai-sdk": "bin/cli" }, "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.82.0", + "resolved": "https://github.com/yc-software/pi/releases/download/qm-pi-coding-agent-0.82.0-security.3/earendil-works-pi-coding-agent-0.82.0-qm-security.3.tgz", + "integrity": "sha512-kuQ98isehj6j3QwtC2pCWdHmNxKOgqwjUCvmaFwdlN2frRv6brHMWtn8v/fUkxBFPtzHf99APhoidpmAiUX61Q==", + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.82.0", + "@earendil-works/pi-ai": "^0.82.0", + "@earendil-works/pi-tui": "^0.82.0", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.1.38", + "undici": "8.9.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.974.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", + "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", + "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", + "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", + "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", + "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", + "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", + "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", + "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", + "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", + "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", + "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", + "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", + "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", + "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.82.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.82.0.tgz", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.82.0", + "diff": "8.0.4", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.82.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.82.0.tgz", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.82.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.82.0.tgz", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", + "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", + "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", + "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", + "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { - "zod": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { "optional": true } } }, - "node_modules/@earendil-works/pi-ai/node_modules/@smithy/node-http-handler": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", - "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=16.0.0" } }, - "node_modules/@earendil-works/pi-ai/node_modules/typebox": { - "version": "1.1.38", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", - "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.82.0", - "resolved": "https://github.com/yc-software/pi/releases/download/qm-pi-coding-agent-0.82.0-security.3/earendil-works-pi-coding-agent-0.82.0-qm-security.3.tgz", - "integrity": "sha512-kuQ98isehj6j3QwtC2pCWdHmNxKOgqwjUCvmaFwdlN2frRv6brHMWtn8v/fUkxBFPtzHf99APhoidpmAiUX61Q==", - "hasShrinkwrap": true, - "license": "MIT", - "dependencies": { - "@earendil-works/pi-agent-core": "^0.82.0", - "@earendil-works/pi-ai": "^0.82.0", - "@earendil-works/pi-tui": "^0.82.0", - "@silvia-odwyer/photon-node": "0.3.4", - "chalk": "5.6.2", - "cross-spawn": "7.0.6", - "diff": "8.0.4", - "glob": "13.0.6", - "highlight.js": "10.7.3", - "hosted-git-info": "9.0.3", - "ignore": "7.0.5", - "jiti": "2.7.0", - "minimatch": "10.2.5", - "proper-lockfile": "4.1.2", - "semver": "7.8.0", - "typebox": "1.1.38", - "undici": "8.9.0", - "yaml": "2.9.0" - }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", "bin": { - "pi": "dist/cli.js" + "yaml": "bin.mjs" }, "engines": { - "node": ">=22.19.0" + "node": ">= 14.6" }, - "optionalDependencies": { - "@mariozechner/clipboard": "0.3.9" + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { - "version": "1.1.38", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", - "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", - "license": "MIT" + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } }, "node_modules/@earendil-works/pi-tui": { "version": "0.82.1", @@ -969,31 +2768,6 @@ "node": ">=22.19.0" } }, - "node_modules/@emnapi/core": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", - "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "2.0.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", - "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", @@ -1001,7 +2775,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1278,6 +3051,37 @@ } } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@hono/node-server": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.10.tgz", @@ -1356,6 +3160,16 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@mariozechner/clipboard": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", @@ -1560,6 +3374,7 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", @@ -3194,8 +5009,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@standard-schema/spec": { "version": "1.1.0", @@ -3219,7 +5033,6 @@ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "license": "MIT", - "peer": true, "dependencies": { "@types/connect": "*", "@types/node": "*" @@ -3230,7 +5043,6 @@ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -3266,7 +5078,6 @@ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.2.tgz", "integrity": "sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -3278,8 +5089,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/json-schema": { "version": "7.0.15", @@ -3329,15 +5139,13 @@ "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/retry": { "version": "0.12.0", @@ -3350,7 +5158,6 @@ "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -3360,7 +5167,6 @@ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/http-errors": "*", "@types/node": "*" @@ -3420,6 +5226,7 @@ "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", @@ -3634,6 +5441,7 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3693,6 +5501,30 @@ } } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3793,6 +5625,7 @@ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", "license": "Apache-2.0", + "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -4008,6 +5841,38 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -4254,6 +6119,12 @@ "integrity": "sha512-/qHKqK5Nr3+8zhgO6kHmF43Fm5C8HNn0AaFRIpgw8HF3+uF0Vfc8jgLI1ZQS5ba1vBzksS8NBCjHejwLb2D/Sg==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -4308,6 +6179,15 @@ "node": ">= 0.4" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -4333,6 +6213,7 @@ "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -4566,6 +6447,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -4736,8 +6618,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", - "license": "Unlicense", - "peer": true + "license": "Unlicense" }, "node_modules/fast-uri": { "version": "3.1.5", @@ -5080,6 +6961,15 @@ "node": ">=18" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -5282,6 +7172,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.34.tgz", "integrity": "sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -5428,6 +7319,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -5734,6 +7634,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -6557,6 +8463,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -6957,6 +8864,15 @@ "node": ">= 12.13.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -7319,7 +9235,6 @@ "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", "license": "MIT", - "peer": true, "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" @@ -7345,6 +9260,32 @@ "text-decoder": "^1.1.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", @@ -7573,6 +9514,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7715,6 +9657,23 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -7751,6 +9710,15 @@ "node": ">=0.4" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", @@ -7766,6 +9734,33 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -7784,6 +9779,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index f7a0175d2..64d068ed0 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "smoke:service-cred": "node scripts/service-credential-livetest.ts", "smoke:aws-sandbox": "node scripts/aws-sandbox-smoke.ts", "smoke:local-sandbox": "node scripts/local-sandbox-smoke.ts", + "smoke:boxd-sandbox": "node scripts/boxd-sandbox-smoke.ts", "build:aws-image": "node scripts/aws-build-sandbox-image.ts", "sandbox:local:build": "bash scripts/local-sandbox-build.sh", "deploy:fly-image": "flyctl deploy --remote-only --build-only --push --image-label latest --app \"${FLY_SANDBOX_APP_NAME:?Set FLY_SANDBOX_APP_NAME to your operator-owned app}\" -c fly/fly.toml --dockerfile fly/Dockerfile . --yes", @@ -55,6 +56,7 @@ "@aws-sdk/client-secrets-manager": "^3.1089.0", "@aws-sdk/client-sts": "^3.1075.0", "@aws-sdk/credential-provider-node": "^3.972.70", + "@boxd-sh/sdk": "0.2.4", "@earendil-works/pi-ai": "0.82.0", "@earendil-works/pi-coding-agent": "https://github.com/yc-software/pi/releases/download/qm-pi-coding-agent-0.82.0-security.3/earendil-works-pi-coding-agent-0.82.0-qm-security.3.tgz", "@fly/sprites": "0.0.1", diff --git a/scripts/boxd-sandbox-smoke.ts b/scripts/boxd-sandbox-smoke.ts new file mode 100644 index 000000000..aba508272 --- /dev/null +++ b/scripts/boxd-sandbox-smoke.ts @@ -0,0 +1,126 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Boxd } from "@boxd-sh/sdk"; +import { createBoxdSandbox } from "../src/sandbox/boxd-sandbox.ts"; +import { spriteScopeName } from "../src/sandbox/sprites-sandbox.ts"; +import { createLocalWorkspaceStore } from "../src/workspace/workspace-store.ts"; +import { supportsProcessSessions } from "../src/sandbox/sandbox.ts"; +import { pollProcess } from "../src/sandbox/process-poll.ts"; +import { loadConfig } from "../src/config.ts"; +import { scopeId } from "../src/types.ts"; + +const log = (...a: unknown[]) => console.log("[boxd-smoke]", ...a); + +if (!process.env.BOXD_API_KEY && !process.env.BOXD_TOKEN) { + console.error("set BOXD_API_KEY (mint one with `boxd auth keys create`)"); + process.exit(1); +} + +async function main(): Promise { + const client = new Boxd(); + const boxdEnv = loadConfig().boxdSandbox; + const prefix = boxdEnv.namePrefix ?? "qmsmoke"; + const ws = createLocalWorkspaceStore(mkdtempSync(join(tmpdir(), "boxd-smoke-ws-"))); + const sandbox = createBoxdSandbox(ws, { ...boxdEnv, namePrefix: prefix, client }); + const scope = scopeId("personal", `smoke-${Date.now()}`); + const layers = [{ scopeId: scope, mountPath: "", mode: "rw" as const }]; + const scratchKey = `smoke-${Date.now()}`; + const names = [spriteScopeName(prefix, scope), spriteScopeName(`${prefix}-scratch`, scratchKey)]; + const listed = async (name: string) => + (await client.machines.list(boxdEnv.org ? { org: boxdEnv.org } : {})).find((m) => m.name === name) ?? null; + + try { + log("provision #1 (fresh machine)..."); + const h1 = await sandbox.provision(layers); + log("machine", h1.id, "coldStart", h1.coldStart); + assert.equal(h1.coldStart, true, "first ever provision should be cold"); + assert.equal((await listed(h1.id))?.status, "running"); + + log("exec over gRPC..."); + const echo = await sandbox.run(h1, "echo hello-from-boxd"); + assert.equal(echo.code, 0); + assert.equal(echo.stdout.trim(), "hello-from-boxd"); + const codes = await sandbox.run(h1, "echo out; echo err >&2; exit 3"); + assert.deepEqual([codes.code, codes.stdout.trim(), codes.stderr.trim()], [3, "out", "err"]); + const timed = await sandbox.run(h1, "sleep 5", { timeoutMs: 1000 }); + assert.equal(timed.timedOut, true, "guest-side timeout reports timedOut"); + const uname = await sandbox.run(h1, "uname -m; node --version; whoami; pwd"); + log("guest:", uname.stdout.trim().replace(/\n/g, " | ")); + assert.match(uname.stdout, /\/home\/boxd\/workspace/); + + const marker = `persist-${Date.now()}`; + await sandbox.writeFile(h1, "notes/marker.txt", marker); + assert.equal(await sandbox.readFile(h1, "notes/marker.txt"), marker); + assert.equal(await sandbox.readFile(h1, "notes/missing.txt"), null); + const big = Buffer.alloc(5 * 1024 * 1024 + 3); + for (let i = 0; i < big.length; i++) big[i] = (i * 7) % 256; + await sandbox.writeFileBytes(h1, "big.bin", big); + const back = await sandbox.readFileBytes(h1, "big.bin"); + assert.ok(back && Buffer.from(back).equals(big), "5 MiB roundtrip (chunked past the gRPC cap)"); + log("wrote marker", marker, "and a 5 MiB blob"); + + assert.ok(supportsProcessSessions(sandbox)); + if (supportsProcessSessions(sandbox)) { + const { processId } = await sandbox.startProcess(h1, "for i in 1 2 3; do echo tick $i; sleep 1; done"); + const polled = await pollProcess(sandbox, h1, processId, { deadlineMs: 30_000 }); + assert.equal(polled.status.state, "exited"); + assert.match(polled.output, /tick 3/); + log("process session ran to completion"); + } + + const backup = await sandbox.backupComputer!(h1, { include: ["workspace"] }); + assert.ok( + backup.some((e) => e.path === "notes/marker.txt"), + "backup carries the marker", + ); + log("backup packed", backup.length, "workspace files"); + + assert.deepEqual(await sandbox.computerStatus!(scope), { machine: "running", guestResponsive: true }); + + log("teardown #1 (park)..."); + await sandbox.teardown(h1); + + log("provision #2 (should reuse the same machine, warm)..."); + const h2 = await sandbox.provision(layers); + assert.equal(h2.id, h1.id, "warm provision reuses the same machine"); + assert.equal(h2.coldStart, false, "reused machine is not cold"); + assert.equal(await sandbox.readFile(h2, "notes/marker.txt"), marker, "file present after reuse"); + log("warm reuse OK, marker intact"); + + log("restartComputer (reboot)..."); + await sandbox.restartComputer!(scope); + assert.equal((await sandbox.run(h2, "echo back")).stdout.trim(), "back"); + assert.equal(await sandbox.readFile(h2, "notes/marker.txt"), marker, "disk survives a reboot"); + log("reboot OK"); + + log("scratch box..."); + const hs = await sandbox.provision(layers, { scratch: { key: scratchKey } }); + assert.equal(hs.scratch, true); + assert.notEqual(hs.id, h2.id); + assert.equal((await sandbox.run(hs, "echo scratch-ok")).stdout.trim(), "scratch-ok"); + await sandbox.teardown(hs); + assert.equal(await listed(hs.id), null, "scratch machine deleted at release"); + log("scratch OK"); + + log("teardown #2 (destroy)..."); + await sandbox.teardown(h2, { destroy: true }); + assert.equal(await listed(h2.id), null, "destroyed machine is gone"); + + log("\n=== ALL LIVE ASSERTIONS PASSED ==="); + } finally { + for (const name of names) { + const m = await listed(name).catch(() => null); + if (m) await client.machines.delete(m.id).catch(() => {}); + } + await client.close(); + log("cleanup done"); + } +} + +main().catch((e) => { + console.error("[boxd-smoke] FAILED:", e instanceof Error ? (e.stack ?? e.message) : e); + process.exit(1); +}); diff --git a/scripts/dev/cli.ts b/scripts/dev/cli.ts index 61e051060..98320a3af 100644 --- a/scripts/dev/cli.ts +++ b/scripts/dev/cli.ts @@ -245,7 +245,7 @@ async function bootOnSlot(slot: string, worktree: string, branch: string): Promi branch, callerEnv, watch: !opts["no-watch"] && callerEnv.DEV_INSTANCE_WATCH !== "0", - sandbox: opts.sandbox as "local" | "sprites" | "smolmachines" | "auto", + sandbox: opts.sandbox as "local" | "sprites" | "smolmachines" | "boxd" | "auto", canaryChannel, strict: opts.strict, slack: withSlack, @@ -644,7 +644,7 @@ async function main(): Promise { return await runDoctor({ json: opts.json, fix: opts.fix, store, slack: withSlack }); default: console.error( - "usage: dev [up|down|status|restart|canary|logs|doctor] [--json] [--force] [--rotate] [--strict] [--sandbox local|sprites|smolmachines|auto] [--no-slack] [--no-watch] [--org id] [--fix]", + "usage: dev [up|down|status|restart|canary|logs|doctor] [--json] [--force] [--rotate] [--strict] [--sandbox local|sprites|smolmachines|boxd|auto] [--no-slack] [--no-watch] [--org id] [--fix]", ); return EXIT.usage; } diff --git a/scripts/dev/lib/sandbox.ts b/scripts/dev/lib/sandbox.ts index fc22780b4..1ff0c2421 100644 --- a/scripts/dev/lib/sandbox.ts +++ b/scripts/dev/lib/sandbox.ts @@ -6,8 +6,46 @@ import { run } from "./proc.ts"; import { ensureDockerDaemon } from "./postgres.ts"; import { bestEffortValue, sleep } from "./util.ts"; +type RemoteBackend = "sprites" | "smolmachines" | "boxd"; + +interface RemoteBackendEnv { + token: string; + mint: string; + prefix: string; + egressProxyUrl: string; + passthrough: string[]; + detail: string; +} + +const REMOTE_BACKENDS: Record = { + sprites: { + token: "SPRITES_TOKEN", + mint: "mint one with `sprite login`", + prefix: "SPRITES_NAME_PREFIX", + egressProxyUrl: "SPRITES_EGRESS_PROXY_URL", + passthrough: [], + detail: "Fly Sprites (api.sprites.dev)", + }, + smolmachines: { + token: "SMOLMACHINES_TOKEN", + mint: "create an API key in the smolmachines console", + prefix: "SMOLMACHINES_NAME_PREFIX", + egressProxyUrl: "SMOLMACHINES_EGRESS_PROXY_URL", + passthrough: ["SMOLMACHINES_IMAGE"], + detail: "smolmachines (api.smolmachines.com)", + }, + boxd: { + token: "BOXD_API_KEY", + mint: "mint one with `boxd auth keys create`", + prefix: "BOXD_NAME_PREFIX", + egressProxyUrl: "BOXD_EGRESS_PROXY_URL", + passthrough: ["BOXD_BASE_URL", "BOXD_ORG"], + detail: "boxd (boxd.sh)", + }, +}; + export interface SandboxResolution { - backend: "local" | "sprites" | "smolmachines"; + backend: "local" | RemoteBackend; env: Record; detail: string; publicApiUrl: string | null; @@ -24,7 +62,7 @@ async function localImagePresent(image: string): Promise { export async function resolveSandbox(opts: { worktree: string; - requested: "local" | "sprites" | "smolmachines" | "auto"; + requested: "local" | RemoteBackend | "auto"; corePort: number; lock: string; baseEnv: Record; @@ -63,45 +101,11 @@ export async function resolveSandbox(opts: { }; } - if (backend === "smolmachines") { - const smolToken = opts.baseEnv.SMOLMACHINES_TOKEN; - if (!smolToken) - throw new Error( - "--sandbox smolmachines requires SMOLMACHINES_TOKEN in the environment (create an API key in the smolmachines console)", - ); - let smolApiUrl = opts.baseEnv.PUBLIC_API_URL || null; - if (!smolApiUrl) { - smolApiUrl = await startQuickTunnel(opts.corePort, opts.lock, opts.log); - if (!smolApiUrl) - warnings.push( - "cloudflared tunnel didn't come up -- agent self-API (crons/sends) won't be reachable from the sandbox", - ); - } - const smolEnv: Record = { - SANDBOX_BACKEND: "smolmachines", - SMOLMACHINES_TOKEN: smolToken, - SMOLMACHINES_NAME_PREFIX: opts.baseEnv.SMOLMACHINES_NAME_PREFIX || "qmdev", - }; - if (opts.baseEnv.SMOLMACHINES_IMAGE) smolEnv.SMOLMACHINES_IMAGE = opts.baseEnv.SMOLMACHINES_IMAGE; - if (opts.baseEnv.SMOLMACHINES_EGRESS_PROXY_URL) - smolEnv.SMOLMACHINES_EGRESS_PROXY_URL = opts.baseEnv.SMOLMACHINES_EGRESS_PROXY_URL; - else - warnings.push( - "SMOLMACHINES_EGRESS_PROXY_URL unset -- smolmachines sandbox runs with NO egress enforcement; set it to QA the forced-proxy path", - ); - if (smolApiUrl) smolEnv.PUBLIC_API_URL = smolApiUrl; - return { - backend: "smolmachines", - env: smolEnv, - detail: "smolmachines (api.smolmachines.com)", - publicApiUrl: smolApiUrl, - warnings, - }; + const remote = REMOTE_BACKENDS[backend]; + const token = opts.baseEnv[remote.token]; + if (!token) { + throw new Error(`--sandbox ${backend} requires ${remote.token} in the environment (${remote.mint})`); } - - const token = opts.baseEnv.SPRITES_TOKEN; - if (!token) - throw new Error("--sandbox sprites requires SPRITES_TOKEN in the environment (mint one with `sprite login`)"); let publicApiUrl = opts.baseEnv.PUBLIC_API_URL || null; if (!publicApiUrl) { publicApiUrl = await startQuickTunnel(opts.corePort, opts.lock, opts.log); @@ -111,19 +115,22 @@ export async function resolveSandbox(opts: { ); } const env: Record = { - SANDBOX_BACKEND: "sprites", - SPRITES_TOKEN: token, - SPRITES_NAME_PREFIX: opts.baseEnv.SPRITES_NAME_PREFIX || "qmdev", + SANDBOX_BACKEND: backend, + [remote.token]: token, + [remote.prefix]: opts.baseEnv[remote.prefix] || "qmdev", }; - // Force-through egress is opt-in in dev: set it only if the caller supplied a proxy URL. - // Without it the sandbox has open egress — warn so a "proxy ON" QA run isn't silently toothless. - if (opts.baseEnv.SPRITES_EGRESS_PROXY_URL) env.SPRITES_EGRESS_PROXY_URL = opts.baseEnv.SPRITES_EGRESS_PROXY_URL; + for (const name of remote.passthrough) { + const value = opts.baseEnv[name]; + if (value) env[name] = value; + } + const egressProxyUrl = opts.baseEnv[remote.egressProxyUrl]; + if (egressProxyUrl) env[remote.egressProxyUrl] = egressProxyUrl; else warnings.push( - "SPRITES_EGRESS_PROXY_URL unset -- sprites sandbox runs with NO egress enforcement; set it to QA the forced-proxy path", + `${remote.egressProxyUrl} unset -- ${backend} sandbox runs with NO egress enforcement; set it to QA the forced-proxy path`, ); if (publicApiUrl) env.PUBLIC_API_URL = publicApiUrl; - return { backend: "sprites", env, detail: "Fly Sprites (api.sprites.dev)", publicApiUrl, warnings }; + return { backend, env, detail: remote.detail, publicApiUrl, warnings }; } async function startQuickTunnel(corePort: number, lock: string, log: (msg: string) => void): Promise { diff --git a/scripts/dev/lib/types.ts b/scripts/dev/lib/types.ts index 13ee9a549..c1e44bd47 100644 --- a/scripts/dev/lib/types.ts +++ b/scripts/dev/lib/types.ts @@ -108,7 +108,7 @@ export interface BootSpec { branch: string; callerEnv: Record; watch: boolean; - sandbox: "local" | "sprites" | "smolmachines" | "auto"; + sandbox: "local" | "sprites" | "smolmachines" | "boxd" | "auto"; canaryChannel?: string; strict: boolean; slack?: boolean; diff --git a/src/config.ts b/src/config.ts index 29ec33cf8..56e488746 100644 --- a/src/config.ts +++ b/src/config.ts @@ -35,8 +35,8 @@ export interface Config { databaseCaCertFile?: string; harness: "mock" | "pi" | "opencode" | "codex" | "claude"; securityPosture: SecurityPosture; - sandboxBackend: "aws" | "local" | "sprites" | "smolmachines"; - sandboxSecondaryBackend?: "aws" | "local" | "sprites" | "smolmachines"; + sandboxBackend: "aws" | "local" | "sprites" | "smolmachines" | "boxd"; + sandboxSecondaryBackend?: "aws" | "local" | "sprites" | "smolmachines" | "boxd"; deployProvider: "docker" | "aws" | "fly"; egressServiceHosts?: string[]; brandingDefault?: OrgBranding; @@ -148,6 +148,7 @@ export interface Config { localSandbox: LocalSandboxEnv; spritesSandbox: SpritesSandboxEnv; smolmachinesSandbox: SmolmachinesSandboxEnv; + boxdSandbox: BoxdSandboxEnv; awsDeploy: AwsDeployEnv; flyDeploy: FlyDeployEnv; } @@ -326,6 +327,36 @@ function smolmachinesSandboxEnv(env: NodeJS.ProcessEnv): SmolmachinesSandboxEnv }; } +interface BoxdSandboxEnv { + apiKey?: string; + baseUrl?: string; + org?: string; + namePrefix?: string; + vcpu?: number; + diskGb?: number; + egressProxyUrl?: string; + defaultTimeoutSec?: number; +} + +function boxdSandboxEnv(env: NodeJS.ProcessEnv): BoxdSandboxEnv { + return { + ...(env.BOXD_API_KEY ? { apiKey: env.BOXD_API_KEY } : {}), + ...(env.BOXD_BASE_URL ? { baseUrl: env.BOXD_BASE_URL } : {}), + ...(env.BOXD_ORG ? { org: env.BOXD_ORG } : {}), + ...(env.BOXD_NAME_PREFIX ? { namePrefix: env.BOXD_NAME_PREFIX } : {}), + ...(numEnvStrict("BOXD_VCPU", env.BOXD_VCPU) !== undefined + ? { vcpu: numEnvStrict("BOXD_VCPU", env.BOXD_VCPU) } + : {}), + ...(numEnvStrict("BOXD_DISK_GB", env.BOXD_DISK_GB) !== undefined + ? { diskGb: numEnvStrict("BOXD_DISK_GB", env.BOXD_DISK_GB) } + : {}), + ...(env.BOXD_EGRESS_PROXY_URL ? { egressProxyUrl: env.BOXD_EGRESS_PROXY_URL } : {}), + ...(numEnvStrict("SANDBOX_TIMEOUT_SEC", env.SANDBOX_TIMEOUT_SEC) !== undefined + ? { defaultTimeoutSec: numEnvStrict("SANDBOX_TIMEOUT_SEC", env.SANDBOX_TIMEOUT_SEC) } + : {}), + }; +} + interface AwsDeployEnv { region: string; profile?: string; @@ -532,9 +563,17 @@ function harnessEnvStrict(value: string | undefined): Config["harness"] { function sandboxBackendEnvStrict(value: string | undefined, name = "SANDBOX_BACKEND"): Config["sandboxBackend"] { if (value === undefined || value.trim() === "") return "local"; const backend = value.trim(); - if (backend === "aws" || backend === "local" || backend === "sprites" || backend === "smolmachines") return backend; + if ( + backend === "aws" || + backend === "local" || + backend === "sprites" || + backend === "smolmachines" || + backend === "boxd" + ) { + return backend; + } throw new Error( - `${name}=${JSON.stringify(value)} is not recognized — use aws, local, sprites, or smolmachines, or unset it.`, + `${name}=${JSON.stringify(value)} is not recognized — use aws, local, sprites, smolmachines, or boxd, or unset it.`, ); } @@ -637,7 +676,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { } const dataDir = resolve(env.DATA_DIR ?? "./data"); if (env.NODE_ENV === "production" && !env.SANDBOX_BACKEND?.trim()) { - throw new Error("SANDBOX_BACKEND must be set explicitly in production — use sprites, smolmachines, aws, or local."); + throw new Error( + "SANDBOX_BACKEND must be set explicitly in production — use sprites, smolmachines, boxd, aws, or local.", + ); } const sandboxBackend = sandboxBackendEnvStrict(env.SANDBOX_BACKEND); const secondaryRaw = env.SANDBOX_SECONDARY_BACKEND?.trim(); @@ -931,6 +972,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { localSandbox: localSandboxEnv(env), spritesSandbox: spritesSandboxEnv(env), smolmachinesSandbox: smolmachinesSandboxEnv(env), + boxdSandbox: boxdSandboxEnv(env), awsDeploy: awsDeployEnv(env), flyDeploy: flyDeployEnv(env), }; diff --git a/src/deployment/secret-schema.ts b/src/deployment/secret-schema.ts index 4fce5abd6..17cf2f6bb 100644 --- a/src/deployment/secret-schema.ts +++ b/src/deployment/secret-schema.ts @@ -6,6 +6,7 @@ type SecretGate = | "postgres" | "sprites" | "smolmachines" + | "boxd" | "fly-sandbox" | "fly-deploy" | "aws-deploy-gate" @@ -35,6 +36,7 @@ export const CORE_SECRET_SPECS: readonly RuntimeSecretSpec[] = [ { name: "DATABASE_URL", requiredWhen: "postgres" }, { name: "SPRITES_TOKEN", requiredWhen: "sprites" }, { name: "SMOLMACHINES_TOKEN", requiredWhen: "smolmachines" }, + { name: "BOXD_API_KEY", requiredWhen: "boxd" }, { name: "FLY_API_TOKEN", requiredWhen: "fly-sandbox" }, { name: "FLY_DEPLOY_API_TOKEN", requiredWhen: "fly-deploy" }, { name: "AWS_DEPLOY_GATE_SECRET", requiredWhen: "aws-deploy-gate" }, @@ -49,6 +51,7 @@ const GATE_PREDICATES: Readonly b postgres: (env) => env.SESSION_STORE === "postgres" || env.RUN_STORE === "postgres", sprites: (env) => env.SANDBOX_BACKEND === "sprites" || env.SANDBOX_SECONDARY_BACKEND === "sprites", smolmachines: (env) => env.SANDBOX_BACKEND === "smolmachines" || env.SANDBOX_SECONDARY_BACKEND === "smolmachines", + boxd: (env) => env.SANDBOX_BACKEND === "boxd" || env.SANDBOX_SECONDARY_BACKEND === "boxd", "fly-sandbox": (env) => env.SANDBOX_BACKEND === "fly", "fly-deploy": (env) => env.DEPLOY_PROVIDER === "fly", "aws-deploy-gate": (env) => Boolean(env.AWS_DEPLOY_APPS_DOMAIN), diff --git a/src/sandbox/boxd-sandbox.ts b/src/sandbox/boxd-sandbox.ts new file mode 100644 index 000000000..aa7482e78 --- /dev/null +++ b/src/sandbox/boxd-sandbox.ts @@ -0,0 +1,512 @@ +import { randomUUID } from "node:crypto"; +import { Boxd, ConflictError, NotFoundError } from "@boxd-sh/sdk"; +import type { WorkspaceLayer } from "../types.ts"; +import type { WorkspaceStore } from "../workspace/workspace-store.ts"; +import { createKeyedQueue, sleep } from "../util/async.ts"; +import { swallowAs, errMessage } from "../util/errors.ts"; +import { shq } from "../util/shell.ts"; +import { nonInteractiveShellPrefix } from "./sandbox-env.ts"; +import { createExecProcessSessions, type ExecProcessIo } from "./exec-process-session.ts"; +import { materializeRoLayers } from "./ro-layers.ts"; +import { + BLOB_TRANSFER_TTL_MS, + createExecBackup, + createExecBlobStaging, + createExecFileOps, + posixJoin, +} from "./exec-file-ops.ts"; +import { + ephemeralCredLinkScript, + ephemeralCredLinkPaths, + type CredentialPathSpec, +} from "../credentials/resident-paths.ts"; +import { DROPPED_PROXY_ENV, forceThroughProxyEnv, proxyExportPrefix } from "./sandbox-env.ts"; +import { BLOB_TRANSFER_AUD, mintCapabilityToken } from "../auth/capability-token.ts"; +import type { BlobTransferStore } from "../persistence/blob-transfer.ts"; +import { CAPABILITY_HEADER } from "../api/contract.ts"; +import { killableScript, killScript } from "./exec-kill.ts"; +import { visibleNotInstalled, visibleTools } from "./sandbox.ts"; +import { spriteScopeName } from "./sprites-sandbox.ts"; +import type { + AgentComputerProfile, + ExecOptions, + ExecResult, + ProvisionOptions, + Sandbox, + SandboxHandle, + TeardownOptions, +} from "./sandbox.ts"; + +const HOME_DIR = "/home/boxd"; +const WORKSPACE_BASENAME = "workspace"; +const RO_LAYERS_TAR = ".ro-layers.tar"; +const RO_LAYERS_MANIFEST = ".ro-layers.manifest"; +const MISSING_RC = 44; +const READ_CHUNK = 512 * 1024; +const DOWNLOAD_LIMIT = 3 * 1024 * 1024; +const EXIT_GRACE_MS = 60_000; +const READY_TIMEOUT_MS = 180_000; +const READY_POLL_MS = 1_000; +const GUEST_PROBE_TIMEOUT_SEC = 15; +const MEMORY_MB_PER_VCPU = 4096; +const DEFAULT_DISK_GB = 100; +const PARKED_STATES = new Set(["stopped", "failed"]); + +export interface BoxdMachine { + id: string; + name: string; + status: string; +} + +export interface BoxdClientLike { + machines: { + create(params: { name: string; org?: string; config?: { vcpu?: number; disk?: string } }): Promise; + get(id: string): Promise; + list(params?: { org?: string }): Promise; + delete(id: string): Promise; + start(id: string): Promise; + reboot(id: string): Promise; + exec( + id: string, + params: { command: string; timeout?: number }, + ): Promise<{ stdout: string; stderr: string; exitCode: number }>; + files: { + upload(id: string, path: string, data: Uint8Array): Promise; + download(id: string, path: string): Promise; + }; + }; +} + +export interface BoxdSandboxOptions { + apiKey?: string; + baseUrl?: string; + org?: string; + namePrefix?: string; + vcpu?: number; + diskGb?: number; + defaultTimeoutSec?: number; + egressProxyUrl?: string; + blobTransfer?: BlobTransferStore; + signingSecret?: string; + capabilitySecret?: string; + apiBaseUrl?: string; + extraTools?: string[]; + credentialPaths?: CredentialPathSpec[]; + client?: BoxdClientLike; + onError?: (e: { category: string; code: string; message: string; scopeLabel?: string }) => void; +} + +const isStoppedRefusal = (e: unknown): boolean => e instanceof ConflictError && /stopped/i.test(e.message); + +export function createBoxdSandbox(workspace: WorkspaceStore, opts: BoxdSandboxOptions = {}): Sandbox { + if (!opts.client && !opts.apiKey) throw new Error("SANDBOX_BACKEND=boxd requires BOXD_API_KEY"); + const client: BoxdClientLike = + opts.client ?? new Boxd({ apiKey: opts.apiKey!, ...(opts.baseUrl ? { baseURL: opts.baseUrl } : {}) }); + const prefix = opts.namePrefix ?? "qm"; + const orgParam = opts.org ? { org: opts.org } : {}; + const sizing = { + ...(opts.vcpu ? { vcpu: opts.vcpu } : {}), + ...(opts.diskGb ? { disk: `${opts.diskGb}G` } : {}), + }; + const defaultTimeoutSec = opts.defaultTimeoutSec ?? 600; + const workspaceDir = `${HOME_DIR}/${WORKSPACE_BASENAME}`; + const provisionQueue = createKeyedQueue(); + + const idByName = new Map(); + const scopeByName = new Map(); + const scratchKeyByName = new Map(); + const activeScratch = new Map(); + + async function findMachine(name: string): Promise { + const machines = await client.machines.list(orgParam); + return machines.find((m) => m.name === name && m.status !== "destroyed") ?? null; + } + + async function machineIdFor(name: string): Promise { + const cached = idByName.get(name); + if (cached) return cached; + const found = await findMachine(name); + if (!found) throw new Error(`boxd machine ${name}: not found`); + idByName.set(name, found.id); + return found.id; + } + + async function ensureRunning(id: string): Promise { + const deadline = Date.now() + READY_TIMEOUT_MS; + let started = false; + for (;;) { + const m = await client.machines.get(id); + if (m.status === "running" || m.status === "suspended" || m.status === "hibernated") return; + if (m.status === "destroyed") throw new Error(`boxd machine ${m.name}: destroyed`); + if (PARKED_STATES.has(m.status) && !started) { + await client.machines.start(id); + started = true; + } + if (Date.now() > deadline) { + throw new Error(`boxd machine ${m.name}: not running after ${READY_TIMEOUT_MS}ms (state=${m.status})`); + } + await sleep(READY_POLL_MS); + } + } + + async function createMachine(name: string): Promise<{ machine: BoxdMachine; created: boolean }> { + try { + const machine = await client.machines.create({ + name, + ...orgParam, + ...(Object.keys(sizing).length ? { config: sizing } : {}), + }); + return { machine, created: true }; + } catch (createErr) { + if (!(createErr instanceof ConflictError)) throw createErr; + const existing = await findMachine(name); + if (!existing) throw createErr; + return { machine: existing, created: false }; + } + } + + async function deleteMachine(name: string): Promise { + const id = idByName.get(name) ?? (await findMachine(name))?.id; + idByName.delete(name); + if (!id) return; + try { + await client.machines.delete(id); + } catch (e) { + if (!(e instanceof NotFoundError)) throw e; + } + } + + async function onMachine(name: string, label: string, call: (id: string) => Promise): Promise { + const id = await machineIdFor(name); + try { + return await call(id); + } catch (e) { + if (e instanceof NotFoundError) idByName.delete(name); + if (!isStoppedRefusal(e)) throw new Error(`boxd ${label} ${name}: ${errMessage(e)}`, { cause: e }); + } + await ensureRunning(id); + try { + return await call(id); + } catch (e) { + throw new Error(`boxd ${label} ${name}: ${errMessage(e)}`, { cause: e }); + } + } + + async function execRaw(name: string, script: string, timeoutSec: number): Promise { + const r = await onMachine(name, "exec", (id) => + client.machines.exec(id, { + command: `timeout ${timeoutSec} sh -c ${shq(script)}`, + timeout: timeoutSec * 1000 + EXIT_GRACE_MS, + }), + ); + if (r.exitCode < 0) throw new Error(`boxd exec ${name}: stream ended without an exit status`); + return { stdout: r.stdout, stderr: r.stderr, code: r.exitCode, timedOut: r.exitCode === 124 }; + } + + async function writeAbsBytes(name: string, absPath: string, data: Uint8Array): Promise { + const written = await onMachine(name, "write", (id) => client.machines.files.upload(id, absPath, data)); + if (written !== data.length) throw new Error(`boxd write ${absPath} failed (${written}/${data.length} bytes)`); + } + + async function readAbsBytes(name: string, absPath: string): Promise { + const stat = await execRaw(name, `[ -e ${shq(absPath)} ] || exit ${MISSING_RC}; wc -c < ${shq(absPath)}`, 60); + if (stat.code === MISSING_RC) return null; + if (stat.code !== 0) { + throw new Error(`boxd read ${absPath} failed (${stat.code}): ${(stat.stderr || stat.stdout).slice(0, 200)}`); + } + const declared = Number.parseInt(stat.stdout.trim(), 10); + if (!Number.isFinite(declared)) throw new Error(`boxd read ${absPath}: bad size (${stat.stdout.slice(0, 40)})`); + let data: Uint8Array; + if (declared <= DOWNLOAD_LIMIT) { + data = await onMachine(name, "read", (id) => client.machines.files.download(id, absPath)); + } else { + const parts: Buffer[] = []; + for (let i = 0; i < Math.ceil(declared / READ_CHUNK); i++) { + const chunk = `dd if=${shq(absPath)} bs=${READ_CHUNK} skip=${i} count=1 2>/dev/null | base64`; + const c = await execRaw(name, chunk, 120); + if (c.code !== 0) throw new Error(`boxd read ${absPath} chunk ${i} failed (${c.code})`); + parts.push(Buffer.from(c.stdout.replace(/\s+/g, ""), "base64")); + } + data = Buffer.concat(parts); + } + if (data.length !== declared) throw new Error(`boxd read ${absPath}: truncated (${data.length}/${declared})`); + return data; + } + + async function ensureMachine( + key: string, + name: string, + onStatus?: (text: string) => void, + ): Promise<{ coldStart: boolean }> { + return provisionQueue(key, async () => { + if (idByName.has(name)) return { coldStart: false }; + const existing = await findMachine(name); + if (existing) { + idByName.set(name, existing.id); + await ensureRunning(existing.id); + return { coldStart: false }; + } + try { + onStatus?.("Creating the sandbox…"); + } catch (error) { + void error; + } + const { machine, created } = await createMachine(name); + idByName.set(name, machine.id); + await ensureRunning(machine.id); + return { coldStart: created }; + }); + } + + async function ensureScratch(key: string): Promise<{ name: string; coldStart: boolean }> { + const name = spriteScopeName(`${prefix}-scratch`, key); + return provisionQueue(`scratch:${key}`, async () => { + scratchKeyByName.set(name, key); + const active = activeScratch.get(name) ?? 0; + if (active === 0 && !idByName.has(name)) { + await deleteMachine(name).catch(swallowAs("boxd-sandbox: stale scratch delete", undefined)); + const { machine } = await createMachine(name); + idByName.set(name, machine.id); + await ensureRunning(machine.id); + } + activeScratch.set(name, active + 1); + return { name, coldStart: active === 0 }; + }); + } + + const profile: AgentComputerProfile = { + backend: "boxd", + writablePersistence: "resident_disk", + processSessions: true, + egressEnforcement: "none", + spec: { + os: "Ubuntu 24.04 LTS — boxd microVM (auto-suspends when idle; the whole disk persists)", + runtimes: ["Node 24", "Python 3"], + get tools() { + return visibleTools(["git", "curl", "jq", "tar", "python3", "gh", "docker", ...(opts.extraTools ?? [])]); + }, + get notInstalled() { + return visibleNotInstalled(["aws", "gcloud", "kubectl", "flyctl", "glab"], opts.extraTools ?? []); + }, + ...(opts.vcpu ? { cpus: opts.vcpu, memoryMb: opts.vcpu * MEMORY_MB_PER_VCPU } : {}), + diskGb: opts.diskGb ?? DEFAULT_DISK_GB, + homeDir: HOME_DIR, + workdir: workspaceDir, + }, + }; + + const procIo: ExecProcessIo = { + async run(handle, command, execOpts): Promise { + const timeoutSec = execOpts?.timeoutMs ? Math.ceil(execOpts.timeoutMs / 1000) : defaultTimeoutSec; + return execRaw(handle.id, command, timeoutSec); + }, + }; + const procSessions = createExecProcessSessions(procIo); + + const execFileOps = createExecFileOps({ + label: "boxd", + exec: (id, script, t) => execRaw(id, script, t), + writeInline: (id, abs, data) => writeAbsBytes(id, abs, data), + }); + + const blobSigningSecret = opts.capabilitySecret ?? opts.signingSecret; + const blobStaging = + opts.blobTransfer && blobSigningSecret && opts.apiBaseUrl + ? createExecBlobStaging({ + label: "boxd", + exec: (id, script, t) => execRaw(id, script, t), + proxyPrefix: proxyExportPrefix, + apiBaseUrl: opts.apiBaseUrl, + capabilityHeader: CAPABILITY_HEADER, + mintToken: (grant) => + mintCapabilityToken( + { + actorId: "boxd-sandbox", + aud: BLOB_TRANSFER_AUD, + scopeId: "personal:boxd-sandbox", + blob: grant, + exp: Date.now() + BLOB_TRANSFER_TTL_MS, + }, + blobSigningSecret, + ), + }) + : null; + + const execBackup = createExecBackup({ + label: "boxd", + exec: (id, script, t) => execRaw(id, script, t), + readAbsBytes, + defaultHomeDir: HOME_DIR, + ephemeralCredentialPrefixes: ephemeralCredLinkPaths(opts.credentialPaths ?? []).map(({ rel }) => rel), + }); + + const sandbox: Sandbox = { + profile, + startProcess: procSessions.startProcess, + readProcess: procSessions.readProcess, + writeStdin: procSessions.writeStdin, + signalProcess: procSessions.signalProcess, + listProcesses: procSessions.listProcesses, + ...execFileOps, + ...(blobStaging + ? { + async stageIn(handle: SandboxHandle, destRelPath: string, blobId: string): Promise { + await blobStaging.stageInAbs(handle, posixJoin(handle.rootDir, destRelPath), blobId); + }, + async stageOut(handle: SandboxHandle, srcRelPath: string): Promise { + return blobStaging.stageOutAbs(handle, posixJoin(handle.rootDir, srcRelPath)); + }, + } + : {}), + + async provision(layers: WorkspaceLayer[], provOpts?: ProvisionOptions): Promise { + const scratch = provOpts?.scratch; + const writable = layers.find((l) => l.mode === "rw") ?? layers[0]; + const scope = writable?.scopeId ?? "default"; + let name: string; + let coldStart: boolean; + if (scratch) { + ({ name, coldStart } = await ensureScratch(scratch.key)); + } else { + name = spriteScopeName(prefix, scope); + scopeByName.set(name, scope); + ({ coldStart } = await ensureMachine(scope, name, provOpts?.onStatus)); + } + + const forceEgress = !!opts.egressProxyUrl && !!provOpts?.egressToken; + const turnEnv = Object.fromEntries( + Object.entries(provOpts?.env ?? {}).filter(([k]) => !DROPPED_PROXY_ENV.has(k)), + ); + const env = { + ...turnEnv, + ...(forceEgress ? forceThroughProxyEnv(opts.egressProxyUrl!, provOpts!.egressToken!) : {}), + }; + const handle: SandboxHandle = { + id: name, + rootDir: workspaceDir, + homeDir: HOME_DIR, + coldStart, + ...(scratch ? { scratch: true } : {}), + ...(Object.keys(env).length ? { env } : {}), + }; + + try { + const credLinks = scratch ? "" : ` && ${ephemeralCredLinkScript(HOME_DIR, opts.credentialPaths ?? [])}`; + const prep = await execRaw(name, `mkdir -p ${shq(workspaceDir)}${credLinks}`, 60); + if (prep.code !== 0) + throw new Error(`boxd provision prep failed: ${(prep.stderr || prep.stdout).slice(0, 200)}`); + + await materializeRoLayers( + workspace, + layers, + handle, + { + readFile: (h, rel) => sandbox.readFile(h, rel), + writeFileBytes: (h, rel, data) => sandbox.writeFileBytes(h, rel, data), + exec: (script, t) => execRaw(name, script, t), + }, + { manifest: RO_LAYERS_MANIFEST, tar: RO_LAYERS_TAR, label: "boxd" }, + ); + + return handle; + } catch (err) { + await sandbox.teardown(handle).catch(swallowAs("boxd-sandbox: teardown after failed provision", undefined)); + throw err; + } + }, + + async run(handle, command, execOpts?: ExecOptions): Promise { + const timeoutSec = execOpts?.timeoutMs ? Math.ceil(execOpts.timeoutMs / 1000) : defaultTimeoutSec; + const exports = Object.entries(handle.env ?? {}) + .map(([k, v]) => `export ${k}=${shq(v)}`) + .join("; "); + const script = `${nonInteractiveShellPrefix()}${exports ? exports + "; " : ""}cd ${handle.rootDir} 2>/dev/null; ${command}`; + const signal = execOpts?.signal; + if (!signal) return execRaw(handle.id, script, timeoutSec); + const killUid = randomUUID(); + const fireKill = () => { + execRaw(handle.id, killScript(killUid), 15).catch(swallowAs("boxd-sandbox: kill in-flight exec", undefined)); + }; + if (signal.aborted) fireKill(); + const onAbort = () => fireKill(); + signal.addEventListener("abort", onAbort, { once: true }); + try { + return await execRaw(handle.id, killableScript(script, killUid), timeoutSec); + } finally { + signal.removeEventListener("abort", onAbort); + } + }, + + async writeFileBytes(handle, relPath, data): Promise { + await writeAbsBytes(handle.id, posixJoin(handle.rootDir, relPath), data); + }, + async writeFile(handle, relPath, data): Promise { + await sandbox.writeFileBytes(handle, relPath, Buffer.from(data, "utf8")); + }, + async readFileBytes(handle, relPath): Promise { + return readAbsBytes(handle.id, posixJoin(handle.rootDir, relPath)); + }, + async readFile(handle, relPath): Promise { + const bytes = await sandbox.readFileBytes(handle, relPath); + return bytes === null ? null : Buffer.from(bytes).toString("utf8"); + }, + + backupComputer: execBackup.backupComputer, + + async computerStatus(scopeId: string) { + const name = spriteScopeName(prefix, scopeId); + let machine: string; + try { + machine = (await client.machines.get(await machineIdFor(name))).status; + } catch (e) { + machine = `check failed: ${errMessage(e)}`; + } + let guestResponsive = false; + try { + guestResponsive = (await execRaw(name, "true", GUEST_PROBE_TIMEOUT_SEC)).code === 0; + } catch (e) { + void e; + } + return { machine, guestResponsive }; + }, + + async restartComputer(scopeId: string): Promise { + const name = spriteScopeName(prefix, scopeId); + const id = await machineIdFor(name); + try { + await client.machines.reboot(id); + } catch (e) { + throw new Error(`boxd reboot ${name}: ${errMessage(e)}`, { cause: e }); + } + await ensureRunning(id); + }, + + async teardown(handle, tdOpts?: TeardownOptions): Promise { + if (handle.scratch) { + const key = scratchKeyByName.get(handle.id); + return provisionQueue(key ? `scratch:${key}` : handle.id, async () => { + const remaining = (activeScratch.get(handle.id) ?? 1) - 1; + if (remaining > 0) { + activeScratch.set(handle.id, remaining); + return; + } + activeScratch.delete(handle.id); + if (tdOpts?.destroy) await deleteMachine(handle.id); + else await deleteMachine(handle.id).catch(swallowAs("boxd-sandbox: scratch delete", undefined)); + }); + } + if (!tdOpts?.destroy) return; + await deleteMachine(handle.id).catch((e) => { + const scope = scopeByName.get(handle.id); + opts.onError?.({ + category: "sandbox_teardown", + code: "machine_delete_failed", + message: errMessage(e), + ...(scope ? { scopeLabel: scope } : {}), + }); + }); + }, + }; + + return sandbox; +} diff --git a/src/sandbox/sandbox-routing.ts b/src/sandbox/sandbox-routing.ts index ccfb75e74..c2e34c511 100644 --- a/src/sandbox/sandbox-routing.ts +++ b/src/sandbox/sandbox-routing.ts @@ -14,7 +14,7 @@ import { type TeardownOptions, } from "./sandbox.ts"; -export type SandboxBackendName = "sprites" | "aws" | "local" | "smolmachines"; +export type SandboxBackendName = "sprites" | "aws" | "local" | "smolmachines" | "boxd"; export interface SandboxRoute { backend: SandboxBackendName; diff --git a/src/wiring.ts b/src/wiring.ts index f37513cea..728dcea09 100644 --- a/src/wiring.ts +++ b/src/wiring.ts @@ -113,6 +113,7 @@ import { createAwsSandbox, type StoredMicrovm } from "./sandbox/aws-sandbox.ts"; import { createLocalSandbox } from "./sandbox/local-sandbox.ts"; import { createSpritesSandbox } from "./sandbox/sprites-sandbox.ts"; import { createSmolmachinesSandbox } from "./sandbox/smolmachines-sandbox.ts"; +import { createBoxdSandbox } from "./sandbox/boxd-sandbox.ts"; import { createSandboxRouter, ROUTE_CACHE_TTL_MS, @@ -600,28 +601,20 @@ export function buildApp( ...config.localSandbox, onError: sandboxOnError, }); + const remoteSandboxOptions = { + blobTransfer, + extraTools: deploymentLayer.advertisedTools, + credentialPaths: deploymentLayer.credentialPaths, + ...(config.signingSecret ? { signingSecret: config.signingSecret } : {}), + ...(config.capabilitySecret ? { capabilitySecret: config.capabilitySecret } : {}), + ...(config.apiBaseUrl ? { apiBaseUrl: config.apiBaseUrl } : {}), + onError: sandboxOnError, + }; const buildSprites = (): Sandbox => - createSpritesSandbox(workspace, { - ...config.spritesSandbox, - blobTransfer, - extraTools: deploymentLayer.advertisedTools, - credentialPaths: deploymentLayer.credentialPaths, - ...(config.signingSecret ? { signingSecret: config.signingSecret } : {}), - ...(config.capabilitySecret ? { capabilitySecret: config.capabilitySecret } : {}), - ...(config.apiBaseUrl ? { apiBaseUrl: config.apiBaseUrl } : {}), - onError: sandboxOnError, - }); + createSpritesSandbox(workspace, { ...config.spritesSandbox, ...remoteSandboxOptions }); const buildSmolmachines = (): Sandbox => - createSmolmachinesSandbox(workspace, { - ...config.smolmachinesSandbox, - blobTransfer, - extraTools: deploymentLayer.advertisedTools, - credentialPaths: deploymentLayer.credentialPaths, - ...(config.signingSecret ? { signingSecret: config.signingSecret } : {}), - ...(config.capabilitySecret ? { capabilitySecret: config.capabilitySecret } : {}), - ...(config.apiBaseUrl ? { apiBaseUrl: config.apiBaseUrl } : {}), - onError: sandboxOnError, - }); + createSmolmachinesSandbox(workspace, { ...config.smolmachinesSandbox, ...remoteSandboxOptions }); + const buildBoxd = (): Sandbox => createBoxdSandbox(workspace, { ...config.boxdSandbox, ...remoteSandboxOptions }); const buildAws = (): Sandbox => { if (!config.awsSandbox.s3Bucket) throw new Error("SANDBOX_BACKEND=aws requires AWS_SANDBOX_S3_BUCKET"); return createAwsSandbox(workspace, { @@ -638,6 +631,7 @@ export function buildApp( local: buildLocal, sprites: buildSprites, smolmachines: buildSmolmachines, + boxd: buildBoxd, aws: buildAws, }; const sandboxBackends: Partial> = { diff --git a/test/boxd-sandbox.test.ts b/test/boxd-sandbox.test.ts new file mode 100644 index 000000000..d0be7fe73 --- /dev/null +++ b/test/boxd-sandbox.test.ts @@ -0,0 +1,337 @@ +import { test, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createBoxdSandbox } from "../src/sandbox/boxd-sandbox.ts"; +import { spriteScopeName } from "../src/sandbox/sprites-sandbox.ts"; +import { createLocalWorkspaceStore } from "../src/workspace/workspace-store.ts"; +import { supportsBlobStaging, supportsProcessSessions } from "../src/sandbox/sandbox.ts"; +import { createMemoryBlobTransferStore } from "../src/persistence/blob-transfer.ts"; +import { scopeId } from "../src/types.ts"; +import { mintCapabilityToken, EGRESS_PROXY_AUD } from "../src/auth/capability-token.ts"; +import { installFakeBoxd, FAKE_BOXD_API_KEY, type FakeBoxd } from "./support/fake-boxd.ts"; +import type { Sandbox } from "../src/sandbox/sandbox.ts"; + +let fake: FakeBoxd; +let sandbox: Sandbox; +const scope = scopeId("personal", "tester"); +const layers = [{ scopeId: scope, mountPath: "/", mode: "rw" as const }]; + +function make(extra: Record = {}): Sandbox { + return createBoxdSandbox(createLocalWorkspaceStore(mkdtempSync(join(tmpdir(), "boxd-ws-"))), { + apiKey: FAKE_BOXD_API_KEY, + namePrefix: "qmt", + client: fake.client, + ...extra, + }); +} + +async function drain(s: Sandbox, h: Awaited>, processId: string): Promise { + assert.ok(supportsProcessSessions(s)); + if (!supportsProcessSessions(s)) return ""; + let cursor = 0, + chunks = "", + state = "running"; + for (let i = 0; i < 10 && state === "running"; i++) { + const r = await s.readProcess(h, processId, { sinceCursor: cursor }); + chunks += r.chunks; + cursor = r.cursor; + state = r.status.state; + } + return chunks; +} + +beforeEach(() => { + fake = installFakeBoxd(); + sandbox = make(); +}); +after(() => fake?.cleanup()); + +test("SANDBOX_BACKEND=boxd requires an API key unless a client is injected", () => { + assert.throws( + () => createBoxdSandbox(createLocalWorkspaceStore(mkdtempSync(join(tmpdir(), "boxd-ws-")))), + /BOXD_API_KEY/, + ); +}); + +test("provision runs commands with env and cwd", async () => { + const h = await sandbox.provision(layers, { env: { MY_VAR: "v1" } }); + assert.equal(h.coldStart, true); + assert.equal(h.homeDir, "/home/boxd"); + const r = await sandbox.run(h, "pwd; echo VAR=$MY_VAR"); + assert.equal(r.code, 0); + assert.match(r.stdout, /workspace/); + assert.match(r.stdout, /VAR=v1/); +}); + +test("streams and exit codes are exact", async () => { + const h = await sandbox.provision(layers); + const r = await sandbox.run(h, "echo out; echo err >&2; exit 3"); + assert.equal(r.code, 3); + assert.equal(r.stdout.trim(), "out"); + assert.equal(r.stderr.trim(), "err"); +}); + +test("file roundtrip incl. large binary, oversized-for-download, and missing file", async () => { + const h = await sandbox.provision(layers); + await sandbox.writeFile(h, "a/b.txt", "hello\n"); + assert.equal(await sandbox.readFile(h, "a/b.txt"), "hello\n"); + assert.equal(await sandbox.readFile(h, "nope.txt"), null); + const big = Buffer.alloc(200 * 1024); + for (let i = 0; i < big.length; i++) big[i] = (i * 7) % 256; + await sandbox.writeFileBytes(h, "big.bin", big); + const back = await sandbox.readFileBytes(h, "big.bin"); + assert.ok(back && Buffer.from(back).equals(big)); + const huge = Buffer.alloc(5 * 1024 * 1024 + 17); + for (let i = 0; i < huge.length; i++) huge[i] = (i * 13) % 256; + await sandbox.writeFileBytes(h, "huge.bin", huge); + const hugeBack = await sandbox.readFileBytes(h, "huge.bin"); + assert.ok(hugeBack && Buffer.from(hugeBack).equals(huge), "reads past the gRPC message cap are chunked over exec"); +}); + +test("empty file roundtrip", async () => { + const h = await sandbox.provision(layers); + await sandbox.writeFileBytes(h, "empty.bin", Buffer.alloc(0)); + const back = await sandbox.readFileBytes(h, "empty.bin"); + assert.ok(back); + assert.equal(back.length, 0); +}); + +test("process sessions capability works end to end", async () => { + assert.ok(supportsProcessSessions(sandbox)); + if (!supportsProcessSessions(sandbox)) return; + const h = await sandbox.provision(layers); + const { processId } = await sandbox.startProcess(h, "echo one; echo two"); + const chunks = await drain(sandbox, h, processId); + assert.match(chunks, /one/); + assert.match(chunks, /two/); +}); + +test("background processes inherit the force-through proxy env", async () => { + const s = make({ egressProxyUrl: "https://proxy.example.com" }); + const token = await mintCapabilityToken( + { actorId: "tester", scopeId: scope, aud: EGRESS_PROXY_AUD, exp: Date.now() + 600_000 }, + "secret", + ); + const h = await s.provision(layers, { egressToken: token }); + assert.ok(supportsProcessSessions(s)); + if (!supportsProcessSessions(s)) return; + const { processId } = await s.startProcess(h, "echo PROXY=$HTTPS_PROXY"); + assert.match(await drain(s, h, processId), /PROXY=https?:\/\/[^ ]*proxy\.example\.com/); +}); + +test("force-through strips agent-supplied proxy vars; no proxy env without a proxy url", async () => { + assert.equal(sandbox.profile.egressEnforcement, "none"); + const plain = await sandbox.provision(layers, { egressToken: "ignored" }); + assert.equal(plain.env?.HTTPS_PROXY, undefined); + const s = make({ egressProxyUrl: "https://proxy.example.com" }); + const token = await mintCapabilityToken( + { actorId: "tester", scopeId: scope, aud: EGRESS_PROXY_AUD, exp: Date.now() + 600_000 }, + "secret", + ); + const h = await s.provision(layers, { egressToken: token, env: { HTTPS_PROXY: "http://evil:1", FOO: "keep" } }); + assert.ok(h.env?.HTTPS_PROXY?.includes("proxy.example.com")); + assert.ok(!h.env?.HTTPS_PROXY?.includes("evil")); + assert.equal(h.env?.FOO, "keep"); +}); + +test("machine is reused across provisions and warm start is reported", async () => { + const a = await sandbox.provision(layers); + const b = await sandbox.provision(layers); + assert.equal(a.id, b.id); + assert.equal(a.id, spriteScopeName("qmt", scope)); + assert.equal(b.coldStart, false); + assert.equal(fake.names().filter((n) => n === a.id).length, 1); +}); + +test("a machine that already exists is adopted, not recreated", async () => { + await fake.client.machines.create({ name: spriteScopeName("qmt", scope) }); + const h = await sandbox.provision(layers); + assert.equal(h.coldStart, false); + assert.equal((await sandbox.run(h, "echo alive")).stdout.trim(), "alive"); + assert.equal(fake.names().length, 1); +}); + +test("a name conflict on create adopts the existing machine instead of failing", async () => { + const name = spriteScopeName("qmt", scope); + const listed = fake.client.machines.list.bind(fake.client.machines); + let raceOnce = true; + fake.client.machines.list = async (params) => { + const machines = await listed(params); + if (!raceOnce) return machines; + raceOnce = false; + return machines.filter((m) => m.name !== name); + }; + await fake.client.machines.create({ name }); + const h = await sandbox.provision(layers); + assert.equal(h.coldStart, false); + assert.equal((await sandbox.run(h, "echo alive")).stdout.trim(), "alive"); +}); + +test("machines are looked up in the configured org only", async () => { + const s = make({ org: "acme" }); + const h = await s.provision(layers); + assert.equal(fake.machine(h.id)?.org, "acme"); + assert.ok(fake.listCalls().length > 0); + assert.ok(fake.listCalls().every((c) => c.org === "acme")); +}); + +test("exec on a stopped machine starts it and retries", async () => { + const h = await sandbox.provision(layers); + await sandbox.writeFile(h, "keep.txt", "still here\n"); + fake.stop(h.id); + const r = await sandbox.run(h, "cat keep.txt"); + assert.equal(r.code, 0); + assert.equal(r.stdout, "still here\n"); + assert.equal(fake.machine(h.id)?.status, "running"); +}); + +test("a stopped machine is started at provision", async () => { + const a = await sandbox.provision(layers); + fake.stop(a.id); + const s = make(); + const b = await s.provision(layers); + assert.equal(b.id, a.id); + assert.equal(b.coldStart, false); + assert.equal(fake.machine(b.id)?.status, "running"); +}); + +test("a machine destroyed out from under a handle is recreated on the next provision", async () => { + const a = await sandbox.provision(layers); + await sandbox.writeFile(h(a), "x.txt", "x"); + fake.destroy(a.id); + await assert.rejects(sandbox.run(a, "echo gone"), /boxd exec/); + const b = await sandbox.provision(layers); + assert.equal(b.id, a.id); + assert.equal(b.coldStart, true); + assert.equal(await sandbox.readFile(b, "x.txt"), null); + function h(x: typeof a) { + return x; + } +}); + +test("a command that ran before the response was lost is never re-executed", async () => { + const h = await sandbox.provision(layers); + await sandbox.run(h, ": > /home/boxd/workspace/ledger"); + fake.stallAfterRun(h.id); + await assert.rejects(sandbox.run(h, "echo entry >> /home/boxd/workspace/ledger"), /timed out/); + assert.equal(await sandbox.readFile(h, "ledger"), "entry\n", "the side effect must have happened exactly once"); +}); + +test("scratch machines are separate and deleted at release", async () => { + const h = await sandbox.provision(layers, { scratch: { key: "job-1" } }); + assert.equal(h.scratch, true); + assert.equal(h.id, spriteScopeName("qmt-scratch", "job-1")); + assert.equal((await sandbox.run(h, "echo scratch-ok")).stdout.trim(), "scratch-ok"); + await sandbox.teardown(h); + assert.equal(fake.machine(h.id), null); +}); + +test("teardown without destroy keeps the machine; destroy deletes it", async () => { + const h = await sandbox.provision(layers); + await sandbox.teardown(h); + assert.ok(fake.machine(h.id)); + await sandbox.teardown(h, { destroy: true }); + assert.equal(fake.machine(h.id), null); +}); + +test("large command output comes back intact", async () => { + const h = await sandbox.provision(layers); + const r = await sandbox.run(h, "python3 -c \"print('x' * (900 * 1024), end='')\""); + assert.equal(r.code, 0); + assert.equal(r.stdout.length, 900 * 1024); +}); + +test("configured size is requested at create and advertised in the profile", async () => { + const s = make({ vcpu: 2, diskGb: 200 }); + const h = await s.provision(layers); + assert.deepEqual(fake.machine(h.id)?.config, { vcpu: 2, disk: "200G" }); + assert.equal(s.profile.spec?.cpus, 2); + assert.equal(s.profile.spec?.memoryMb, 8192); + assert.equal(s.profile.spec?.diskGb, 200); + const unsized = await sandbox.provision([ + { scopeId: scopeId("personal", "unsized"), mountPath: "/", mode: "rw" as const }, + ]); + assert.equal(fake.machine(unsized.id)?.config, null); +}); + +test("profile advertises resident disk and process sessions", () => { + assert.equal(sandbox.profile.backend, "boxd"); + assert.equal(sandbox.profile.writablePersistence, "resident_disk"); + assert.equal(sandbox.profile.processSessions, true); + assert.equal(sandbox.profile.spec?.homeDir, "/home/boxd"); + assert.equal(sandbox.profile.spec?.workdir, "/home/boxd/workspace"); +}); + +test("backupComputer tars workspace + home over the exec channel", async () => { + const h = await sandbox.provision(layers); + await sandbox.run( + h, + [ + "mkdir -p app/.cache", + "printf hi > app/index.html", + "printf junk > app/.cache/x", + 'printf note > "$HOME/.profile-note"', + ].join(" && "), + ); + const got = await sandbox.backupComputer!(h); + const paths = got.map((e) => `${e.area}:${e.path}`).sort(); + assert.ok(paths.includes("workspace:app/index.html"), `workspace file packed (got ${paths.join(", ")})`); + assert.ok(paths.includes("home:.profile-note"), "home file packed"); + assert.ok(!paths.some((p) => p.includes(".cache")), "content caches pruned by default"); + assert.ok(!paths.some((p) => p.startsWith("home:workspace/")), "workspace pruned from the home area"); + assert.equal(Buffer.from(got.find((e) => e.path === "app/index.html")!.data).toString("utf8"), "hi"); +}); + +test("blob staging is advertised only when the channel is actually wired", async () => { + assert.equal(supportsBlobStaging(make()), false); + const wired = make({ + blobTransfer: createMemoryBlobTransferStore(), + capabilitySecret: "blob-secret", + apiBaseUrl: "http://core.internal:8080", + }); + assert.equal(supportsBlobStaging(wired), true); + const h = await wired.provision(layers); + await assert.rejects(() => wired.stageOut!(h, "outbox/big.bin"), /boxd stageOut/); + const script = fake.execScripts().find((s) => s.includes("/v1/blobs"))!; + assert.match(script, /--upload-file/, "streams from disk rather than buffering in the guest"); + assert.match(script, /-X POST/); +}); + +test("restartComputer reboots the scope's machine and heals a wedged exec channel", async () => { + const h = await sandbox.provision(layers); + fake.fail(h.id); + await assert.rejects(sandbox.run(h, "echo back"), /cannot connect/); + await sandbox.restartComputer!(scope); + assert.deepEqual(fake.reboots(), [h.id]); + const after = await sandbox.run(h, "echo back"); + assert.equal(after.code, 0); + assert.equal(after.stdout.trim(), "back"); +}); + +test("restartComputer surfaces a refused reboot instead of swallowing it", async () => { + const h = await sandbox.provision(layers); + fake.refuseReboot(h.id); + await assert.rejects(sandbox.restartComputer!(scope), /boxd reboot .*upstream reboot failed/); + assert.deepEqual(fake.reboots(), []); +}); + +test("computerStatus reports the machine state and whether the shell answers", async () => { + const h = await sandbox.provision(layers); + assert.deepEqual(await sandbox.computerStatus!(scope), { machine: "running", guestResponsive: true }); + fake.fail(h.id); + assert.deepEqual(await sandbox.computerStatus!(scope), { machine: "running", guestResponsive: false }); + fake.stop(h.id); + assert.equal((await sandbox.computerStatus!(scope)).machine, "stopped"); + assert.match((await make().computerStatus!(scopeId("personal", "nobody"))).machine, /check failed/); +}); + +test("every machine call addresses the machine by id, never by name", async () => { + const h = await sandbox.provision(layers); + await sandbox.writeFile(h, "f.txt", "1"); + await sandbox.readFile(h, "f.txt"); + await sandbox.run(h, "true"); + assert.equal(fake.machine(h.id)?.id, "vm-1"); + assert.notEqual(h.id, "vm-1"); +}); diff --git a/test/config.test.ts b/test/config.test.ts index 4911d65ad..b4d52daa8 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -356,6 +356,13 @@ test("SANDBOX_BACKEND: unset defaults to local (dev only); the secondary must be "sprites", ); assert.throws(() => loadConfig({ SANDBOX_BACKEND: "sprites" }), /SPRITES_TOKEN/); + assert.throws(() => loadConfig({ SANDBOX_BACKEND: "boxd" }), /BOXD_API_KEY/); + assert.equal(loadConfig({ SANDBOX_BACKEND: "boxd", BOXD_API_KEY: "bxd_x" }).sandboxBackend, "boxd"); + assert.deepEqual( + loadConfig({ SANDBOX_BACKEND: "boxd", BOXD_API_KEY: "bxd_x", BOXD_ORG: "acme", BOXD_VCPU: "2", BOXD_DISK_GB: "50" }) + .boxdSandbox, + { apiKey: "bxd_x", org: "acme", vcpu: 2, diskGb: 50 }, + ); assert.throws( () => loadConfig({ SANDBOX_SECONDARY_BACKEND: "fly" }), /SANDBOX_SECONDARY_BACKEND="fly" is not recognized/, diff --git a/test/support/fake-boxd.ts b/test/support/fake-boxd.ts new file mode 100644 index 000000000..35fd8167b --- /dev/null +++ b/test/support/fake-boxd.ts @@ -0,0 +1,222 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { BoxdError, ConflictError, NotFoundError, RateLimitError } from "@boxd-sh/sdk"; +import type { BoxdClientLike, BoxdMachine } from "../../src/sandbox/boxd-sandbox.ts"; + +const GRPC_MAX_MESSAGE = 4 * 1024 * 1024; + +interface FakeMachine { + id: string; + name: string; + status: string; + org: string | null; + config: Record | null; + home: string; +} + +export interface FakeBoxd { + client: BoxdClientLike; + homeDir(name: string): string; + names(): string[]; + machine( + name: string, + ): { id: string; status: string; org: string | null; config: Record | null } | null; + stop(name: string): void; + destroy(name: string): void; + execScripts(): string[]; + stallAfterRun(name: string): void; + fail(name: string): void; + refuseReboot(name: string): void; + reboots(): string[]; + listCalls(): Array<{ org?: string }>; + cleanup(): void; +} + +export const FAKE_BOXD_API_KEY = "bxd_test"; + +export function installFakeBoxd(): FakeBoxd { + const root = mkdtempSync(join(tmpdir(), "fake-boxd-")); + const machines = new Map(); + const execScripts: string[] = []; + const listCalls: Array<{ org?: string }> = []; + const stallAfterRun = new Set(); + const failing = new Set(); + const refusedReboot = new Set(); + const reboots: string[] = []; + let nextId = 1; + + const live = (name: string): FakeMachine | undefined => + [...machines.values()].find((m) => m.name === name && m.status !== "destroyed"); + + const byId = (id: string): FakeMachine => { + const m = machines.get(id); + if (!m) throw new NotFoundError("VM not found", 5); + return m; + }; + + const running = (id: string): FakeMachine => { + const m = byId(id); + if (m.status === "destroyed") throw new NotFoundError("VM is destroyed", 5); + if (m.status === "stopped" || m.status === "failed") { + throw new ConflictError( + `VM ${m.name} is stopped and can't be resumed automatically — start it from the Machines page`, + 9, + ); + } + if (m.status === "starting") m.status = "running"; + if (m.status === "suspended" || m.status === "hibernated") m.status = "running"; + return m; + }; + + const record = (m: FakeMachine): BoxdMachine => ({ id: m.id, name: m.name, status: m.status }); + + const remap = (m: FakeMachine, script: string): string => { + const homeRe = m.home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const remapPath = new RegExp(`${homeRe}/tmp/|${homeRe}(?![A-Za-z0-9._-])|/tmp/`, "g"); + return ( + `export HOME=${JSON.stringify(m.home)}; ` + + script + .replace(/^timeout \d+ sh -c /, "sh -c ") + .replace(/\btimeout \d+ /g, "") + .replace(/\/home\/boxd/g, m.home) + .replace(remapPath, (mm) => (mm.startsWith(m.home) ? mm : `${m.home}/tmp/`)) + ); + }; + + const hostPath = (m: FakeMachine, abs: string): string => { + if (abs.startsWith("/tmp/")) return join(m.home, "tmp", abs.slice(5)); + return abs.replace(/^\/home\/boxd/, m.home); + }; + + const runExec = (m: FakeMachine, command: string): { stdout: string; stderr: string; exitCode: number } => { + execScripts.push(command); + mkdirSync(join(m.home, "tmp"), { recursive: true }); + const r = spawnSync("sh", ["-c", remap(m, command)], { + encoding: "buffer", + maxBuffer: 128 * 1024 * 1024, + env: { ...process.env, COPYFILE_DISABLE: "1" }, + }); + return { + stdout: (r.stdout ?? Buffer.alloc(0)).toString("utf8"), + stderr: (r.stderr ?? Buffer.alloc(0)).toString("utf8"), + exitCode: r.status ?? (r.signal ? 137 : -1), + }; + }; + + const client: BoxdClientLike = { + machines: { + async create(params) { + if (live(params.name)) throw new ConflictError(`name '${params.name}' is already taken`, 9); + const id = `vm-${nextId++}`; + const m: FakeMachine = { + id, + name: params.name, + status: "running", + org: params.org ?? null, + config: params.config ?? null, + home: join(root, id), + }; + mkdirSync(m.home, { recursive: true }); + machines.set(id, m); + return record(m); + }, + async get(id) { + const m = byId(id); + if (m.status === "starting") m.status = "running"; + return record(m); + }, + async list(params = {}) { + listCalls.push(params); + return [...machines.values()] + .filter((m) => m.status !== "destroyed" && (!params.org || m.org === params.org)) + .map(record); + }, + async delete(id) { + const m = byId(id); + m.status = "destroyed"; + rmSync(m.home, { recursive: true, force: true }); + }, + async start(id) { + const m = byId(id); + if (m.status === "stopped" || m.status === "failed") m.status = "starting"; + }, + async reboot(id) { + const m = byId(id); + if (refusedReboot.has(m.name)) throw new BoxdError("upstream reboot failed", 13); + reboots.push(m.name); + failing.delete(m.name); + m.status = "starting"; + }, + async exec(id, params) { + const m = running(id); + if (failing.has(m.name)) throw new BoxdError("cannot connect to VM agent: connection refused", 14); + const r = runExec(m, params.command); + if (stallAfterRun.has(m.name)) { + stallAfterRun.delete(m.name); + throw new BoxdError(`exec timed out after ${params.timeout ?? 0}ms`); + } + return r; + }, + files: { + async upload(id, path, data) { + const m = running(id); + const target = hostPath(m, path); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, data); + return data.length; + }, + async download(id, path) { + const m = running(id); + const target = hostPath(m, path); + if (!existsSync(target)) + throw new NotFoundError(`stat: cannot statx '${path}': No such file or directory`, 5); + const data = readFileSync(target); + if (data.length + 5 > GRPC_MAX_MESSAGE) { + throw new RateLimitError(`Received message larger than max (${data.length + 5} vs ${GRPC_MAX_MESSAGE})`, 8); + } + return new Uint8Array(data); + }, + }, + }, + }; + + return { + client, + homeDir: (name) => { + const m = live(name); + if (!m) throw new Error(`fake boxd: no live machine named ${name}`); + return m.home; + }, + names: () => [...machines.values()].filter((m) => m.status !== "destroyed").map((m) => m.name), + machine: (name) => { + const m = live(name); + return m ? { id: m.id, status: m.status, org: m.org, config: m.config } : null; + }, + stop: (name) => { + const m = live(name); + if (m) m.status = "stopped"; + }, + destroy: (name) => { + const m = live(name); + if (m) { + m.status = "destroyed"; + rmSync(m.home, { recursive: true, force: true }); + } + }, + execScripts: () => [...execScripts], + stallAfterRun: (name) => { + stallAfterRun.add(name); + }, + fail: (name) => { + failing.add(name); + }, + refuseReboot: (name) => { + refusedReboot.add(name); + }, + reboots: () => [...reboots], + listCalls: () => [...listCalls], + cleanup: () => rmSync(root, { recursive: true, force: true }), + }; +}