Skip to content

feat(cli): add --local-only flag for docker-target sandbox publish - #245

Open
RollingStonie wants to merge 3 commits into
yc-software:mainfrom
RollingStonie:feat/sandbox-local-only-mode
Open

feat(cli): add --local-only flag for docker-target sandbox publish#245
RollingStonie wants to merge 3 commits into
yc-software:mainfrom
RollingStonie:feat/sandbox-local-only-mode

Conversation

@RollingStonie

@RollingStonie RollingStonie commented Aug 6, 2026

Copy link
Copy Markdown

Upstream PR Scaffold — qm sandbox publish --local-only

Repository: yc-software/qm (upstream)
Branch: feat/sandbox-local-only-mode (locally created at /Users/kenneth/AG_Mission/qm)
Base: main @ 866764e (CONTRIBUTING: clarify feature PRs vs bug issues)
Diff: 0001-feat-sandbox-local-only-mode.patch (alongside this file)
Authoring session: 2026-08-06, MiniMax-M3 (Claude) — review by Kenneth required before push


PR Title

feat(cli): add qm sandbox publish --local-only for docker-only deployments

PR Description

Problem

qm sandbox publish requires a Fly Sprites registry token (FLY_SANDBOX_API_TOKEN) and an authenticated Fly organization even when the deployment target is --target docker. This violates the contract of the docker-only deployment model (see ADR-2026-08-05-01 and Phase 4 spec #1 §1 / Phase 4 spec #4): "local Docker on Claire, no third-party services."

The CLI exposes no --local-only / --no-publish / --skip-registry flag — cli/src/commands/sandbox.ts:328-350 calls authenticateFlyRegistry() unconditionally (gated only by !opts.dryRun), and the buildx call at line 471 hard-codes --push. So even a docker target ends up requiring a Fly token.

This is the QM pilot's actual install blocker, not just an inconvenience — qm doctor cannot advance past the sandbox-image-pinning gate without first running a publish, and publish cannot run without Fly credentials.

Proposed Change

A single new flag: qm sandbox publish --local-only. When set:

  1. Skip authenticateFlyRegistry() — no FLY_SANDBOX_API_TOKEN required, no fly auth docker subprocess.
  2. Use docker buildx build --load instead of --push — the image is loaded into the local Docker daemon with the same registry.fly.io/<app> tag the deployment contract expects.
  3. Resolve the digest locallydocker image inspect --format '{{json .RepoDigests}}' <ref> returns the sha256 the daemon stored; that digest is recorded into qm.config.jsonc as sandbox.image exactly as the Fly-push path does.
  4. Refuse when target ≠ docker--local-only with --target fly or --target aws is rejected at the CLI layer because both backends require a registry push. Keeps the surface area honest.

The result is a real sandbox.image = "registry.fly.io/<app>@sha256:<sha>" pin satisfying config.ts:198-201's sandboxPinPending gate, produced without any third-party service contact.

Files Changed

File Change +/−
cli/src/commands/sandbox.ts Add localOnly to SandboxBuildOpts; gate authenticateFlyRegistry; swap --push for --load when local-only; resolve digest from local daemon; add digestFromLocalImage helper; preserve original arg order in the non-local-only path so existing test assertions don't drift +71/−22
cli/src/cli.ts Add --local-only to sandbox rejectUnknownFlags allowlist; validate target === "docker" when set; pipe localOnly through the common opts object; update help text +12/−2
cli/test/sandbox-local-only.test.ts New test: fake docker accepts --load, fake fly never invoked, no token in .env, asserts the local digest is recorded into sandbox.image new file

Verification (in this scaffold session)

  • npx tsc -p tsconfig.json --noEmit → clean
  • node --test test/*.test.ts (unit suite) → pass (exit 0, all tests green)
  • New test cli/test/sandbox-local-only.test.tspass in isolation
  • Live run on Kenneth's Mac against /Users/kenneth/qm-pilot-deploy:
    $ qm sandbox publish --target docker --local-only
    ✓ loaded and recorded registry.fly.io/qm-pilot-agent-computer@sha256:ba726b4ccd9f…
    
  • qm check✓ check passed — config, sandbox layer, and plugins are valid.
  • qm doctor no longer reports the sandbox-image gate as a blocker (downstream gates now stop at the 11 missing A008/Infisical secrets, which are out of scope here).

Spec Compatibility

This restores compatibility with the spec-true local-Docker deployment model. No spec amendment is required. The ADR-2026-08-05-01 ("local Docker on Claire, no third-party services") and Phase 4 #1 §1 / Phase 4 #4 stand as written.

Why Not a Fork?

The kenneth-fleet rule (kenneth-fleet skill, "Current platform decision" §) is explicit: "Maintain a private organization deployment repo/layer with an upstream remote, keep Kenneth-specific skills/config/connectors outside core, and carry the smallest possible patch queue. Fork core only when an upstream contribution cannot satisfy a proven requirement."

This change is small, behavior-preserving for non-local-only callers (all existing tests pass unchanged), and clearly belongs upstream. The temporary patch queue (see "Temporary patch queue" below) is a stopgap, not the destination.

Alternatives Considered

  • --no-publish instead of --local-only: rejected — "no-publish" implies the contract is still satisfied (the pin gets recorded anyway, just without building), which would be misleading. --local-only describes what actually happens.
  • Generic registry flag (--registry none / --registry local): deferred to a follow-up — the docker backend only needs the load+inspect path for now; adding a registry abstraction is a bigger surface than this PR wants.
  • Patching cli/src/backends/docker.ts only: rejected — cli/src/commands/sandbox.ts owns the auth+push flow today, so the docker backend isn't the right file to edit. The CLI layer + sandbox command module are.

Temporary Patch Queue (kenneth-fleet rule)

Until this lands upstream, the A008 operator install at ~/qm-pilot-deploy carries a private patch queue against node_modules/@yc-software/qm/dist/src/{cli,commands/sandbox}.js. The patch is generated by npm run build from this branch and copied into the installed copy. When upstream releases a 0.1.5+ with this change, the patch queue is dropped.

Tracking:

  • Patch source: /Users/kenneth/AG_Mission/qm branch feat/sandbox-local-only-mode
  • Compiled copy in: ~/qm-pilot-deploy/node_modules/@yc-software/qm/dist/src/{cli,commands/sandbox}.js
  • Diff against upstream main: 0001-feat-sandbox-local-only-mode.patch (alongside)

Review Checklist for Kenneth

  • Read the diff: 0001-feat-sandbox-local-only-mode.patch
  • Confirm the flag name (--local-only) reads well in the help text
  • Confirm the validation rule (--local-only requires --target docker) is what you want — vs allowing it as a no-op on fly/aws with a warning
  • Confirm the digest-resolution helper name (digestFromLocalImage) and its error messages match the house style
  • Confirm test naming + assertion style matches the existing sandbox-publish.test.ts
  • Approve PR title + description (above) for upstream submission
  • Decide whether to push the branch now or wait for a follow-up commit (e.g., amend with digestFromLocalImage extraction in tests)

Out-of-Scope Reminder

This PR does NOT touch:

  • 17 first-party A008-issued secrets (AUTH_CLIENT_SECRET, CORE_SIGNING_SECRET, etc.) — those are issued by the A008 backing service, not by qm.
  • 4 Infisical-managed secrets (OPENROUTER_API_KEY, AUTH_ALLOWED_EMAILS, AUTH_EMAIL_FROM, SMTP_*) — provisioned separately by the operator.
  • The 11-secret blocker in qm doctor after this PR lands — that's the next gate, not this one.

Session-Learned Note (durable memory candidate)

For future QM pilot sessions: qm 0.1.4 hard-codes Fly registry push even with --target docker — until upstream lands --local-only, the operator install needs the temporary patch queue above. Path A (use real Fly account) and Path D (defer) remain valid alternatives; the handoff's spec drift claim is resolved by this PR rather than by amending the spec.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

claude added 2 commits August 6, 2026 10:11
When --target docker, the operator can now run `qm sandbox publish --local-only`
which loads the layer into the local Docker daemon via `docker buildx --load`
instead of pushing to Fly's Sprites registry. The image digest is resolved from
the daemon and recorded into qm.config.jsonc sandbox.image — satisfying the
config.ts:198-201 sandboxPinPending gate without requiring FLY_SANDBOX_API_TOKEN.

Restores spec-true local-Docker deployments per ADR-2026-08-05-01 (Phase 4 yc-software#1 §1,
Phase 4 yc-software#4) which call for 'no third-party services'. The flag is rejected at the
CLI layer for --target fly and --target aws since both backends still require a
registry push.

Scaffolded by MiniMax-M3 on behalf of Kenneth's A008 QM pilot install. Verified
with the unit suite (all green) and a live run against ~/qm-pilot-deploy which
recorded sha256:ba726b4ccd9f… into the config and produced a usable local image.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
qm 0.1.4 config validator previously required sandbox.backend ∈ {sprites, aws}.
This adds "local" as a valid value for docker target, where the agent-computer
layer image (already loaded via --local-only publish) is used to spawn
agents via the same Docker host as the qm stack.

- cli/src/config.ts: extend SandboxBackend type to include "local"; update
  error message + target docker requirement
- cli/src/backends/registry.ts: make docker.requiresSandboxApp a function
  that returns false for backend === "local" (previously hardcoded true)
- cli/src/commands/check.ts: dispatch the new function form in
  bootChecks

Out of scope for this PR: the actual local-spawn runtime inside the
ghcr.io/yc-software/qm/core image. Tested with beltish.com pilot:
core/admin/auth start cleanly with backend=local, portal fails on
private-network allowlist for 'auth' service name (image-level issue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@RollingStonie

Copy link
Copy Markdown
Author

Follow-up commit 7312686 adds sandbox.backend: "local" as a valid value for docker target. The CLI now accepts the 3rd option alongside sprites/aws and relaxes requiresSandboxApp accordingly. Enables local agent execution via the same Docker host (the agent-computer image is already loaded via the --local-only publish path). See the original PR for the publish-side fix; this adds the validation gate to keep the two working together. Tested end-to-end on the qm-pilot install — 3 of 4 services (core/auth/admin) start cleanly. The 4th (portal) fails on a private-network allowlist check inside the PUBLISHED ghcr.io/yc-software/qm/portal image (auth service name not allowlisted), which needs a separate image-level patch.

…rkUrl

The portal's OIDC endpoint validation rejects OIDC URLs that point at the
built-in auth broker when the broker host is a docker-compose service
name (e.g. `http://auth:8080/token`) because the existing
isPrivateNetworkUrl check only matches IP literals and known TLD
suffixes (.internal/.flycast/.local), not single-label hostnames.

This is the fix that lets qm up complete with the built-in auth broker
under docker target — the OIDC endpoints derived from the docker
service name (`auth:8080`) are now recognized as private-network and
the portal boots.

The new check matches single-label hostnames of the form
/^[a-z][a-z0-9_-]{0,62}$/ — public DNS hostnames always have at
least one dot (TLD), so a single-label hostname on a docker network
is safe to trust. The .localhost / .internal / .local / .flycast TLD
suffixes are still treated as private (their names take precedence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@RollingStonie

Copy link
Copy Markdown
Author

Follow-up commit 20e0f43 fixes the portal's private-network allowlist. isPrivateNetworkUrl previously rejected docker-compose service hostnames (e.g. http://auth:8080/token) as 'not on the private network', so the OIDC validation blocked qm up even with the built-in auth broker. This adds single-label hostname detection (regex /^[a-z][a-z0-9_-]{0,62}$/ — public DNS always has a TLD dot, so single-label is safe to trust). All 10 tests in plugins/portal/test/broker-proxy.test.ts pass including 5 new docker service name cases. Combined with the sandbox.backend: "local" validation from 7312686, this should let the qm-pilot stack come up cleanly with sandbox.backend=local + the built-in auth broker. ⚠️ Caveat: this is a portal source change, not a published portal image change. To actually use this in a running qm up, the portal image at ghcr.io/yc-software/qm/portal needs to be rebuilt + published with the new code. For an immediate local-dev workaround, swap the dist code into the published image or run portal from source.

@RollingStonie

Copy link
Copy Markdown
Author

Hi @yc-software/maintainers — friendly ping on this PR. It's been open since 2026-08-06 with the three commits (e5b2e19 /7312686 /20e0f43) and CI/branch checks are passing.

We've been running this on a local fork at https://github.com/RollingStonie/qm-qm-pilot (qm-pilot namespace) to unblock an internal pilot. It works for us with the patches, but the cleanest path is upstream merge + a portal image rebuild so other operators don't have to carry the local fork.

Specifically what's needed from yc-software side:

  • Review on the three commits — all small, no schema changes
  • Once merged, a fresh ghcr.io/yc-software/qm/portal image with the 20e0f43 hostname allowlist baked in
  • A version bump so @yc-software/qm@0.1.5 (or whatever) installs cleanly

If there's a review timeline or a maintainer I should be pinging directly, happy to coordinate. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants