Skip to content

feat(sandbox): user-provided git submodule credentials per virtual MCP#4885

Merged
guitavano merged 3 commits into
mainfrom
guitavano/init-zee-submodule
Jul 23, 2026
Merged

feat(sandbox): user-provided git submodule credentials per virtual MCP#4885
guitavano merged 3 commits into
mainfrom
guitavano/init-zee-submodule

Conversation

@guitavano

@guitavano guitavano commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Private git submodules live in repositories the main clone's per-repo GitHub App token can't reach (a different repo/org), so today a sandbox clone leaves them empty and the dev server fails with module not found. This lets a user supply a per-host PAT (stored as a vault secret) on the virtual MCP; Studio resolves it on every SANDBOX_START and hands the token to the daemon on a git-only channel so git submodule update can authenticate.

How it works

UI (Sandbox card)  →  metadata.runtime.submoduleCredentials [{host, secretId}]
  →  start.ts resolves secretId in the vault → [{host, token}]
  →  runner.ensure → buildConfigPayload → git.repository.submoduleCredentials
  →  daemon config-store (deep-merge)
  →  clone.ts: write .git-credentials (0o600) + insteadOf SSH→HTTPS
     → git submodule update --init --recursive --depth 1 → delete the file
  • The token never enters the env bag the dev server sees, never appears in argv, and is never persisted into the repo's .git/config. It lives only in a short-lived credentials file (mode 0o600, next to the no-op askpass, outside the repo) read by credential.helper=store, then deleted.
  • insteadOf rewrites (which carry no token) turn git@host: / ssh://git@host/ submodule URLs into HTTPS so the store credential applies — covering SSH-form submodules like git@github.com:ZeeDog/zee.git.
  • Opt-in and best-effort: no-op without credentials or a .gitmodules; a fetch failure warns and leaves the working tree intact rather than failing the whole clone.

UI

A "Submodule credentials" editor in the virtual MCP Sandbox card (below Environment variables), reusing the existing secret picker + create-secret dialog. Each row = { host, secret }. Incomplete rows are stripped from the autosave payload.

Testing

  • bun run check (all workspaces) ✓ · bun run lint 0 errors ✓ · knip clean ✓ · bun run fmt
  • New unit tests: clone.test.ts (+7 — host validation, dedupe, token percent-encoding, insteadOf/helper argv, no-token-in-argv), helpers.test.ts (+3 — defensive metadata reader).
  • Existing suites green: sandbox setup + start.test.ts 101/101, config-store/payload 47/47.
  • Not exercised end-to-end here (requires a real sandbox + a PAT with access to a private submodule repo). To test the Zee case: create a secret with a PAT for ZeeDog/zee, add github.com → that secret in the virtual MCP UI, then start the sandbox.

Notes for reviewers

  1. PAT persistence on recovery. submoduleCredentials rides through ensureOpts.repo (preserved by withFreshCloneUrl), so autonomous recovery re-clones with submodules. Cost: the long-lived PAT is persisted in sandbox_runner_state alongside the cloneUrl token (which is short-lived ~1h by design). If we'd rather not persist it, we can strip it and accept that recovery drops submodules until the next SANDBOX_START — happy to change.
  2. Username x-access-token is correct for GitHub (the target case). GitLab accepts it; Bitbucket may need a different username — extensible later.

Out of scope (agreed)

Native SSH deploy keys · active token rotation · per-host username.

🤖 Generated with Claude Code


Summary by cubic

Adds per-host submodule PATs to virtual MCPs so sandboxes can fetch private submodules during clone. Tokens are resolved at start, forwarded to the daemon over a git-only channel, and never exposed to the app or config APIs.

  • New Features

    • Submodule credentials editor in the Sandbox card; each row maps a host to a secret. Incomplete rows are stripped before autosave. Shared SecretPicker extracted for consistency.
    • Secrets resolved on SANDBOX_START to {host, token}; missing or inaccessible secrets are skipped (best-effort). Forwarded through the link daemon/provider so linked desktop sandboxes receive them.
    • Daemon writes a short-lived 0600 .git-credentials file, applies SSH→HTTPS insteadOf rewrites, runs a shallow recursive git submodule update, then deletes the file. Tokens are percent-encoded, never placed in env/argv, and never persisted in repo config.
    • Host validation centralized via SUBMODULE_HOST_RE in @decocms/mesh-sdk and enforced in the schema/UI; invalid hosts are skipped and hosts are deduped. submoduleCredentials added to SDK and provider types and forwarded in the config payload.
  • Bug Fixes

    • Redacts resolved PATs from both GET and PUT/POST /_sandbox/config responses; tokens remain only in the daemon’s in-memory store.
    • Enforces best-effort: wraps credentials-file write/spawn errors and always deletes the temp file; no submodules or failures leave the clone intact.
    • Adds tests for redaction (read/echo), payload pass-through, helpers validation, submodule integration (invalid-host skip, no .gitmodules no-op), and the credentials-file write failure catch.

Written for commit c086ee6. Summary will update on new commits.

Review in cubic

guitavano and others added 3 commits July 23, 2026 08:20
Private git submodules live in repos the main clone's per-repo GitHub App
token can't reach, so a sandbox clone leaves them empty. Let users supply a
per-host PAT (stored as a vault secret) on the virtual MCP; Studio resolves
it on every SANDBOX_START and hands the token to the daemon on a git-only
channel so `git submodule update` can authenticate.

- Daemon writes a short-lived .git-credentials file (0o600, outside the repo)
  read by credential.helper=store for the submodule fetch, then deletes it.
  The token never enters the env bag the dev server sees, never appears in
  argv, and is never persisted into the repo's .git/config. `insteadOf`
  rewrites (no token) turn git@host:/ssh:// submodule URLs into HTTPS so the
  store credential applies.
- Opt-in and best-effort: no-op without credentials or a .gitmodules; a fetch
  failure warns and leaves the working tree intact rather than failing clone.
- UI: a "Submodule credentials" editor in the virtual MCP Sandbox card,
  reusing the secret picker + create-secret dialog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Multi-perspective review (correctness, security, scope, architecture,
testing, performance) surfaced 3 blockers + hardening. Fixes:

Blockers
- Security: the resolved PAT was echoed verbatim by GET/PUT /_sandbox/config,
  which is browser-reachable via sandbox-proxy — turning any org-scoped secret
  into a plaintext read for any member with VM-edit + start. Redact
  git.repository.submoduleCredentials from both the read handler and the
  apply echo (daemon keeps them in-memory for the clone), mirroring how env
  values never leave the daemon. Regression tests added.
- user-desktop provider silently dropped the credentials: the link daemon's
  postConfig cherry-picked only cloneUrl+branch. Add submoduleCredentials to
  RepoRef and forward it so the default linked-dev path works.
- Best-effort contract was not enforced against throws: a credentials-file
  write (EACCES/ENOSPC) would reject spawnClone and fail the whole clone.
  Wrap runSubmoduleUpdate in try/catch (warn + continue); rm always runs.

Hardening
- Single-source the host regex: export SUBMODULE_HOST_RE from mesh-sdk, enforce
  it in SubmoduleCredentialSchema, import it in the UI (daemon keeps its copy
  with a sync comment — it can't depend on mesh-sdk).
- Extract shared secret-picker primitives (SecretPickerValue, ScopeIcon,
  SECRET_NAME_RE) into runtime-card/secret-picker.tsx; both field editors use it.
- Tests: build-config-payload pass-through (+ first non-null-repo case) and
  spawnClone submodule integration (best-effort failure, invalid-host skip,
  no-.gitmodules no-op).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rite fails

Verification of the remediation flagged the write/spawn-throw catch branch in
runSubmoduleUpdate as untested (only the non-zero-exit branch was covered). Add
a case that points the credentials-file path at a missing dir so writeFile
throws ENOENT, proving the clone still succeeds and warns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@guitavano
guitavano force-pushed the guitavano/init-zee-submodule branch from 8334b4f to c086ee6 Compare July 23, 2026 11:24
@guitavano
guitavano merged commit 926159d into main Jul 23, 2026
15 checks passed
@guitavano
guitavano deleted the guitavano/init-zee-submodule branch July 23, 2026 12:34
decocms Bot pushed a commit that referenced this pull request Jul 23, 2026
PR: #4885 feat(sandbox): user-provided git submodule credentials per virtual MCP
Bump type: minor

- decocms (apps/mesh/package.json): 4.104.0 -> 4.105.0
- @decocms/mesh-sdk (packages/mesh-sdk/package.json): 1.30.0 -> 1.31.0
- @decocms/sandbox (packages/sandbox/package.json): 1.18.3 -> 1.19.0
- deploy/helm/sandbox-env (chart 0.9.11) (deploy/helm/sandbox-env/values.yaml deploy/helm/sandbox-env/Chart.yaml): image.tag/appVersion -> 1.19.0

Deploy-Scope: both
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