From d8d6feec6e382d8ad7433ef63526d47a991c9bda Mon Sep 17 00:00:00 2001 From: Som Date: Fri, 14 Aug 2026 08:21:32 +0000 Subject: [PATCH 1/6] fix(cli): require SPRITES_TOKEN when qm check's Fly default matches boot time qm check resolved SANDBOX_BACKEND for a Fly target from a table that never derived the implicit "sprites" default sandboxCoreEnv actually applies at boot when sandbox.app is set and sandbox.backend is left unset. A Fly deployment relying on that implicit default could pass qm check and qm secrets push without SPRITES_TOKEN ever being required, then fail to boot (#423). Mirror sandboxCoreEnv's default in TARGET_ENV_DEFAULTS.fly and cross-reference both sites so they don't drift apart again. Several test fixtures across the CLI suite had the same implicit-default shape and were relying on the bug to avoid provisioning SPRITES_TOKEN; updated them to supply it now that it's correctly required. --- cli/src/config.ts | 2 ++ cli/src/target-env-defaults.ts | 9 ++++++++- cli/test/fly-sandbox.test.ts | 4 ++++ cli/test/fly-timing.test.ts | 2 +- cli/test/fly-up.test.ts | 1 + cli/test/secrets.test.ts | 36 ++++++++++++++++++++++++++++++++++ 6 files changed, 52 insertions(+), 2 deletions(-) diff --git a/cli/src/config.ts b/cli/src/config.ts index 55cf346e..4c59ada2 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -231,6 +231,8 @@ export function sandboxCoreEnv( if (violation) throw new CliError(violation.message, { clause: violation.clause }); env.FLY_SANDBOX_APP_NAME = sb.app; env.FLY_BASE_IMAGE = sb.image; + // Mirrored by TARGET_ENV_DEFAULTS.fly in target-env-defaults.ts, which needs the + // same default so `qm check` requires SPRITES_TOKEN when this implies "sprites". const backend = sb.backend ?? (config.target === "fly" ? "sprites" : undefined); if (backend) env.SANDBOX_BACKEND = backend; } diff --git a/cli/src/target-env-defaults.ts b/cli/src/target-env-defaults.ts index fd53379d..14fd1296 100644 --- a/cli/src/target-env-defaults.ts +++ b/cli/src/target-env-defaults.ts @@ -17,7 +17,14 @@ const AWS_RENDER_ENV_DEFAULTS: Readonly = { docker: () => undefined, - fly: (_config, service, name) => FLY_TEMPLATE_ENV_DEFAULTS[service]?.[name], + fly: (config, service, name) => { + // Mirrors sandboxCoreEnv's SANDBOX_BACKEND default (config.ts:234) so `qm check` + // requires the same secrets a Fly deployment actually needs at boot time (#423). + if (service === "core" && name === "SANDBOX_BACKEND") { + return config.sandbox?.backend ?? (config.sandbox?.app ? "sprites" : undefined); + } + return FLY_TEMPLATE_ENV_DEFAULTS[service]?.[name]; + }, aws: (config, service, name) => { const rendered = AWS_RENDER_ENV_DEFAULTS[service]?.[name]; if (rendered === undefined) return undefined; diff --git a/cli/test/fly-sandbox.test.ts b/cli/test/fly-sandbox.test.ts index 104c71ec..0367f20c 100644 --- a/cli/test/fly-sandbox.test.ts +++ b/cli/test/fly-sandbox.test.ts @@ -265,6 +265,7 @@ test("fly secrets push stages a dual-role secret under BOTH names on the core ap "PORTAL_IDENTITY_SECRET=identity", `SKILL_SIGNING_SECRET=${"skill-signing".repeat(3)}`, "FLY_SANDBOX_API_TOKEN=f", + "SPRITES_TOKEN=spr", "PUBLIC_API_URL=https://core.example.test", "SLACK_BOT_TOKEN=xoxb", "SLACK_APP_TOKEN=xapp", @@ -342,6 +343,7 @@ test("fly secrets push warns that staged secrets are not live when machines are "PORTAL_IDENTITY_SECRET=portal-identity-secret-that-is-long-enough", `SKILL_SIGNING_SECRET=${"skill-signing".repeat(3)}`, "FLY_SANDBOX_API_TOKEN=fly", + "SPRITES_TOKEN=spr", ].join("\n"), ); const fake = fakeFly( @@ -398,6 +400,7 @@ test("fly secrets push stays quiet about staging when no machines are running", "PORTAL_IDENTITY_SECRET=portal-identity-secret-that-is-long-enough", `SKILL_SIGNING_SECRET=${"skill-signing".repeat(3)}`, "FLY_SANDBOX_API_TOKEN=fly", + "SPRITES_TOKEN=spr", ].join("\n"), ); const fake = fakeFly( @@ -450,6 +453,7 @@ test("fly secrets push removes the disabled Fly app publisher token", async () = "PORTAL_IDENTITY_SECRET=portal-identity-secret-that-is-long-enough", `SKILL_SIGNING_SECRET=${"skill-signing".repeat(3)}`, "FLY_SANDBOX_API_TOKEN=fly", + "SPRITES_TOKEN=spr", ].join("\n"), ); const fake = fakeFly( diff --git a/cli/test/fly-timing.test.ts b/cli/test/fly-timing.test.ts index 24888ffc..3434edc3 100644 --- a/cli/test/fly-timing.test.ts +++ b/cli/test/fly-timing.test.ts @@ -24,7 +24,7 @@ if (args[0] === "apps" && args[1] === "create") { } else if (args[0] === "secrets" && args[1] === "set") { console.log("staged"); } else if (args[0] === "secrets" && args[1] === "list") { - console.log("ADMIN_GRANTS\\nANTHROPIC_API_KEY\\nAWS_ACCESS_KEY_ID\\nAWS_ENDPOINT_URL_S3\\nAWS_SECRET_ACCESS_KEY\\nCAPABILITY_SECRET\\nCONNECTOR_SECRET_KEY\\nCORE_SIGNING_SECRET\\nPORTAL_IDENTITY_SECRET\\nSKILL_SIGNING_SECRET\\nFLY_API_TOKEN\\nPUBLIC_API_URL\\n" + (process.env.FAKE_FLY_FRESH_PG ? "" : "DATABASE_URL\\n") + "SLACK_BOT_TOKEN\\nSLACK_APP_TOKEN"); + console.log("ADMIN_GRANTS\\nANTHROPIC_API_KEY\\nAWS_ACCESS_KEY_ID\\nAWS_ENDPOINT_URL_S3\\nAWS_SECRET_ACCESS_KEY\\nCAPABILITY_SECRET\\nCONNECTOR_SECRET_KEY\\nCORE_SIGNING_SECRET\\nPORTAL_IDENTITY_SECRET\\nSKILL_SIGNING_SECRET\\nSPRITES_TOKEN\\nFLY_API_TOKEN\\nPUBLIC_API_URL\\n" + (process.env.FAKE_FLY_FRESH_PG ? "" : "DATABASE_URL\\n") + "SLACK_BOT_TOKEN\\nSLACK_APP_TOKEN"); } else if (args[0] === "mpg" && args[1] === "list") { console.log(process.env.FAKE_FLY_FRESH_PG ? "" : "pg-1 test-pg"); } else if (args[0] === "mpg" && args[1] === "create") { diff --git a/cli/test/fly-up.test.ts b/cli/test/fly-up.test.ts index 135e771e..1a5f476a 100644 --- a/cli/test/fly-up.test.ts +++ b/cli/test/fly-up.test.ts @@ -87,6 +87,7 @@ else if (a[0] === "secrets" && a[1] === "list") { "PORTAL_IDENTITY_SECRET", "SECURITY_SCREEN_PROXY_TOKEN", "SKILL_SIGNING_SECRET", + "SPRITES_TOKEN", ] .map((name) => `${name} digest`) .join("\n"), diff --git a/cli/test/secrets.test.ts b/cli/test/secrets.test.ts index c10f8990..ccf11d93 100644 --- a/cli/test/secrets.test.ts +++ b/cli/test/secrets.test.ts @@ -192,6 +192,42 @@ test("the sprites token is a catalog secret when the sandbox backend is sprites" ); }); +test("a Fly deployment relying on the implicit sprites default is required to configure SPRITES_TOKEN (#423)", () => { + const implicitBackend = makeConfig({ + target: "fly", + sandbox: { + app: "acme-sb", + image: "registry.fly.io/acme-sb@sha256:4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d", + }, + }); + assert.ok( + secretByName(implicitBackend, "SPRITES_TOKEN").required, + "qm check must require SPRITES_TOKEN for a Fly deployment that boots with the implicit sprites default", + ); + + const explicitBackend = makeConfig({ + target: "fly", + sandbox: { + app: "acme-sb", + backend: "sprites", + image: "registry.fly.io/acme-sb@sha256:5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e", + }, + }); + assert.ok(secretByName(explicitBackend, "SPRITES_TOKEN").required, "an explicit sandbox.backend keeps requiring it"); + + const noSandboxApp = makeConfig({ target: "fly" }); + assert.ok( + !computedSecrets(noSandboxApp).some((secret) => secret.name === "SPRITES_TOKEN"), + "a Fly target with no sandbox.app configured has no sandbox backend to require a token for", + ); + + const awsImplicitBackend = makeConfig({ target: "aws", sandbox: { app: "acme-sb" } }); + assert.ok( + !computedSecrets(awsImplicitBackend).some((secret) => secret.name === "SPRITES_TOKEN"), + "AWS's own implicit SANDBOX_BACKEND default resolves to \"aws\", never \"sprites\", so it must stay unaffected", + ); +}); + test("naming a base model provider makes that provider's key a required deployment secret", () => { for (const [provider, key] of [ ["anthropic", "ANTHROPIC_API_KEY"], From 5483219552a99cbbfa4f7093fa263fbc5d5cc967 Mon Sep 17 00:00:00 2001 From: Som Date: Fri, 14 Aug 2026 08:27:02 +0000 Subject: [PATCH 2/6] refactor(cli): extract shared flyImplicitSandboxBackend helper Replace the mirrored SANDBOX_BACKEND default expression (previously kept in sync only by a pair of cross-reference comments) with one exported function both sandboxCoreEnv and TARGET_ENV_DEFAULTS.fly call, so the boot-time and check-time defaults can no longer drift apart the way #423 required. --- cli/src/config.ts | 11 ++++++++--- cli/src/target-env-defaults.ts | 8 ++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/cli/src/config.ts b/cli/src/config.ts index 4c59ada2..38a33504 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -217,6 +217,13 @@ export function sandboxImagePinErrors(config: QmConfig): Array<{ clause: string; ]; } +// Single source of truth for Fly's implicit SANDBOX_BACKEND default, so the actual +// boot-time env (sandboxCoreEnv) and qm check's secret-requirement resolution +// (TARGET_ENV_DEFAULTS.fly in target-env-defaults.ts) can't drift apart again (#423). +export function flyImplicitSandboxBackend(config: QmConfig): string | undefined { + return config.sandbox?.backend ?? (config.target === "fly" && config.sandbox?.app ? "sprites" : undefined); +} + export function sandboxCoreEnv( config: QmConfig, lookup?: (name: string) => string | undefined, @@ -231,9 +238,7 @@ export function sandboxCoreEnv( if (violation) throw new CliError(violation.message, { clause: violation.clause }); env.FLY_SANDBOX_APP_NAME = sb.app; env.FLY_BASE_IMAGE = sb.image; - // Mirrored by TARGET_ENV_DEFAULTS.fly in target-env-defaults.ts, which needs the - // same default so `qm check` requires SPRITES_TOKEN when this implies "sprites". - const backend = sb.backend ?? (config.target === "fly" ? "sprites" : undefined); + const backend = flyImplicitSandboxBackend(config); if (backend) env.SANDBOX_BACKEND = backend; } for (const [k, v] of Object.entries(sb.env ?? {})) env[`FLY_RESIDENT_ENV_${k}`] = v; diff --git a/cli/src/target-env-defaults.ts b/cli/src/target-env-defaults.ts index 14fd1296..d8cc2d93 100644 --- a/cli/src/target-env-defaults.ts +++ b/cli/src/target-env-defaults.ts @@ -1,4 +1,4 @@ -import type { QmConfig } from "./config.ts"; +import { flyImplicitSandboxBackend, type QmConfig } from "./config.ts"; import type { Target } from "./providers.ts"; /** @@ -18,11 +18,7 @@ const AWS_RENDER_ENV_DEFAULTS: Readonly = { docker: () => undefined, fly: (config, service, name) => { - // Mirrors sandboxCoreEnv's SANDBOX_BACKEND default (config.ts:234) so `qm check` - // requires the same secrets a Fly deployment actually needs at boot time (#423). - if (service === "core" && name === "SANDBOX_BACKEND") { - return config.sandbox?.backend ?? (config.sandbox?.app ? "sprites" : undefined); - } + if (service === "core" && name === "SANDBOX_BACKEND") return flyImplicitSandboxBackend(config); return FLY_TEMPLATE_ENV_DEFAULTS[service]?.[name]; }, aws: (config, service, name) => { From 910c9643e2bac4093530f7dc9ec974aaaa3ccf37 Mon Sep 17 00:00:00 2001 From: Som Date: Fri, 14 Aug 2026 08:41:40 +0000 Subject: [PATCH 3/6] fix(review): apply review findings Remove explanatory comment above flyImplicitSandboxBackend to comply with this repo's zero-comments standard (AGENTS.md). --- cli/src/config.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/cli/src/config.ts b/cli/src/config.ts index 38a33504..d5beba2c 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -217,9 +217,6 @@ export function sandboxImagePinErrors(config: QmConfig): Array<{ clause: string; ]; } -// Single source of truth for Fly's implicit SANDBOX_BACKEND default, so the actual -// boot-time env (sandboxCoreEnv) and qm check's secret-requirement resolution -// (TARGET_ENV_DEFAULTS.fly in target-env-defaults.ts) can't drift apart again (#423). export function flyImplicitSandboxBackend(config: QmConfig): string | undefined { return config.sandbox?.backend ?? (config.target === "fly" && config.sandbox?.app ? "sprites" : undefined); } From d8d1a254c7d94cdf40bbf8bdc56cd5706f503084 Mon Sep 17 00:00:00 2001 From: Som Date: Fri, 14 Aug 2026 08:47:56 +0000 Subject: [PATCH 4/6] chore(cli): bump version to 0.1.10 Required by CI: this PR changes cli/src, which ships in @yc-software/qm. --- cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/package.json b/cli/package.json index cf24c1d6..b764c1b9 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@yc-software/qm", - "version": "0.1.9", + "version": "0.1.10", "license": "MIT", "description": "Control-plane CLI for portable QM deployments on Docker, Fly, and AWS.", "type": "module", From 7c85bf4813fa774d294ebcec7fd8d8ee6bb44f27 Mon Sep 17 00:00:00 2001 From: Som Date: Fri, 14 Aug 2026 08:52:23 +0000 Subject: [PATCH 5/6] style(cli): run prettier on the new secrets.test.ts assertion Required by CI's formatting check. --- cli/test/secrets.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/test/secrets.test.ts b/cli/test/secrets.test.ts index ccf11d93..b2776343 100644 --- a/cli/test/secrets.test.ts +++ b/cli/test/secrets.test.ts @@ -224,7 +224,7 @@ test("a Fly deployment relying on the implicit sprites default is required to co const awsImplicitBackend = makeConfig({ target: "aws", sandbox: { app: "acme-sb" } }); assert.ok( !computedSecrets(awsImplicitBackend).some((secret) => secret.name === "SPRITES_TOKEN"), - "AWS's own implicit SANDBOX_BACKEND default resolves to \"aws\", never \"sprites\", so it must stay unaffected", + 'AWS\'s own implicit SANDBOX_BACKEND default resolves to "aws", never "sprites", so it must stay unaffected', ); }); From 2a5e7caea2085a26345177d2e94947916a3a0c58 Mon Sep 17 00:00:00 2001 From: Som Date: Fri, 14 Aug 2026 09:15:17 +0000 Subject: [PATCH 6/6] fix(cli): stage SPRITES_TOKEN in the e2e fly plan fixture The e2e "plan (fly) reports secrets: ok when every required secret is staged" test builds its config via flyConfig(), which (through writeConfig()'s default template) always sets sandbox.app with no explicit sandbox.backend -- the same implicit-sprites-default shape this PR's fix now correctly requires SPRITES_TOKEN for. ALL_SECRETS never included it, so the fake fly backend never reported it staged and qm plan correctly flagged it missing, breaking the test. This fixture was missed earlier because `npm test` doesn't run the separate `test:e2e` suite. Confirmed via CI logs and by re-running the affected test locally. --- cli/test/e2e/fly.e2e.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/test/e2e/fly.e2e.test.ts b/cli/test/e2e/fly.e2e.test.ts index 69adf138..32f8cf94 100644 --- a/cli/test/e2e/fly.e2e.test.ts +++ b/cli/test/e2e/fly.e2e.test.ts @@ -14,6 +14,7 @@ const ALL_SECRETS = [ "CORE_SIGNING_SECRET", "PORTAL_IDENTITY_SECRET", "SKILL_SIGNING_SECRET", + "SPRITES_TOKEN", "PUBLIC_API_URL", "FLY_API_TOKEN", "SLACK_BOT_TOKEN",