Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions adrs/boxd-sandbox.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions cli/src/backends/aws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion cli/src/backends/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`,
Expand Down
4 changes: 2 additions & 2 deletions cli/src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions cli/src/commands/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 16 additions & 4 deletions cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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"];
Expand Down Expand Up @@ -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"`);
}
Expand All @@ -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;
Expand Down
10 changes: 7 additions & 3 deletions cli/src/provider-scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/"],
Expand Down Expand Up @@ -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/"],
Expand Down Expand Up @@ -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: [
Expand Down
8 changes: 4 additions & 4 deletions cli/src/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<Target, SandboxBackendPolicy> = {
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[] =>
Expand Down
23 changes: 22 additions & 1 deletion cli/src/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sandbox-app> -x 8760h",
},
Expand All @@ -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 <org>",
},
{
name: "DATABASE_URL",
service: "core",
Expand Down
2 changes: 2 additions & 0 deletions cli/src/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ const CATALOG: Record<ServiceName, ServiceDef> = {
"S3_REGION",
"PUBLIC_WEB_URL",
"FLY_ORG",
"FLY_SANDBOX_APP_NAME",
"FLY_BASE_IMAGE",
"FLY_DEPLOY_BASE_IMAGE",
"PI_DETECT_MODEL",
],
Expand Down
16 changes: 8 additions & 8 deletions cli/src/target-env-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ const AWS_RENDER_ENV_DEFAULTS: Readonly<Record<string, Readonly<Record<string, s
core: { SANDBOX_BACKEND: "aws" },
};

const declaredSandboxBackend: TargetEnvDefaults = (config, service, name) =>
service === "core" && name === "SANDBOX_BACKEND" ? config.sandbox?.backend : undefined;

export const TARGET_ENV_DEFAULTS: Record<Target, TargetEnvDefaults> = {
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],
};
10 changes: 10 additions & 0 deletions cli/test/aws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
20 changes: 20 additions & 0 deletions cli/test/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
});
38 changes: 36 additions & 2 deletions cli/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"/ },
{
Expand Down Expand Up @@ -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}"`),
);
});
}
});
6 changes: 6 additions & 0 deletions cli/test/fly-derive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
Loading