Add coder() isolated sandbox provider#495
Open
ThomasK33 wants to merge 5 commits intomattpocock:mainfrom
Open
Conversation
|
@ThomasK33 is attempting to deploy a commit to the Matt Pocock's projects Team on Vercel. A member of the Team first needs to authorize it. |
- `src/sandboxes/coder.ts`: new isolated provider for Coder workspaces via the `coder` CLI. Discriminated `CoderOptions` union (template vs workspace). Required `onClose: "delete" | "stop" | "leave"`. Polls for connected Coder workspace agents after create. Streams file copy through OpenSSH `ProxyCommand=coder ssh --stdio` so we never mutate the user's SSH config. - Vitest behavior + type-level tests in `src/sandboxes/coder.test.ts`. - `@ai-hero/sandcastle/sandboxes/coder` package export. - `.sandcastle/test-coder.ts` dogfood script and `npm run test-coder`. - `CONTEXT.md`: defines `Coder workspace` and `Coder workspace agent`. - `README.md`: provider table row and Coder subsection. - ADR 0009 (CLI strategy) and ADR 0010 (required `onClose`). - Changeset (`patch`). --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `anthropic:claude-opus-4-7` • Thinking: `max`_
Surfaced by downstream dogfood (coder/agent-tty triage flow against
dev.coder.com), both with tests + ADR notes.
1. Coder prebuild claim race
`coder list -o json` can briefly report a new prebuild claim's agent
as `connected` while the prior agent is still shutting down. The
first `coder ssh` lands on the disconnecting agent and fails with
`error: agent is shutting down` ~3/3 times against a template
preset that has `desired_prebuild_instances >= 1`. Add a
`waitForSshReady` probe that runs `coder ssh -- printf ready` until
the round-trip succeeds (60s budget, 2s interval) before the first
real `coder ssh`. Use `endsWith("ready")` rather than equality
because `coder ssh` may prepend release-candidate banner lines.
2. coder ssh does not propagate stdin EOF
`coder ssh <ws> -- <cmd>` writes the calling shell's stdin into the
remote process's stdin but never closes it. `claude --print -p -`
then hangs forever waiting for input the host already sent. The
same pipeline through OpenSSH with `ProxyCommand=coder ssh --stdio`
exits cleanly, so route stdin-bearing `exec()` calls through
`runOpenSsh` (the same transport copyFileIn/copyFileOut already
use). Inline env vars as a `KEY='value' sh -c '<cmd>'` shell prefix
because OpenSSH has no `--env` flag. Non-stdin `exec()` keeps the
existing direct `coder ssh` path so we minimise blast radius.
Tests (vitest):
- `executes non-stdin commands through coder ssh ...` — locks in the
unchanged path.
- `routes stdin-bearing exec through OpenSSH ProxyCommand to propagate
EOF` — verifies the spawn target is `ssh` with the expected
`ProxyCommand` and hostname when `stdin` is set.
- `retries SSH readiness probe when first attempt fails (prebuild
claim race)` — first `printf ready` fails with `agent is shutting
down`, second succeeds, `create()` resolves.
Existing ssh mocks updated with a small `isSshReadinessProbe` helper
so they return `"ready"` on the new probe.
Full suite (1027 passed) and typecheck pass.
---
_Generated with [`mux`](https://github.com/coder/mux) • Model: `anthropic:claude-opus-4-7` • Thinking: `max`_
Code-simplifier pass on the recently-added Coder provider helpers. No behaviour change; 12 coder tests + full suite (1027 tests) green, typecheck clean. - runChildProcess(binary, args, options) is now the single source of truth for spawning `coder` / `ssh` and collecting stdout/stderr. `runCoder` and `runOpenSsh` are thin wrappers. Eliminates a ~60-line copy of the spawn/pipe/close lifecycle that landed in b69f536. - displayCommand(binary, args) replaces the binary-specific displayOpenSshCommand helper. - assertEnvKey(key) replaces the duplicated `assertNonEmptyString + includes("=")` validation block in buildSshArgs and buildEnvPrefix. - exec() hoists `onStdoutLine = opts?.onLine` once and drops the `...(opts.onLine === undefined ? {} : { onStdoutLine: opts.onLine })` spread on both branches. The project doesn't enable exactOptionalPropertyTypes, so a plain `onStdoutLine` field works here. Comment near the OpenSSH branch is also tightened to point at the canonical EOF/transport explanation in runChildProcess. Net: -48 lines, identical observable behaviour. The two transports remain explicit at the call site (runCoder vs runOpenSsh) so the intent of each branch stays clear. --- _Generated with [\`mux\`](https://github.com/coder/mux) • Model: \`anthropic:claude-opus-4-7\` • Thinking: \`max\`_
Five comment blocks on the new Coder provider drifted into AI-style "essay justifying every choice" territory. Trimmed to match the project's existing comment density (vercel.ts, podman.ts, docker.ts all keep block comments to ~1-2 lines): - runChildProcess JSDoc: 9 lines → 5 lines, references upstream issue coder/coder#24861 instead of restating the bug. - waitForSshReady JSDoc: 11 lines → 1 line that points at the SSH_READY_POLL_* constants comment which already explains the prebuild race. - exec() stdin-routing comment: 4 lines → 1 line, drops the "minimise blast radius" meta-commentary. - interactiveExec PTY comment: 3 lines → 2 lines. - coder.test.ts isSshReadinessProbe JSDoc: 5 lines → 1 line. No code change. 12 coder tests + typecheck still green. --- _Generated with [\`mux\`](https://github.com/coder/mux) • Model: \`anthropic:claude-opus-4-7\` • Thinking: \`max\`_
Forwards `--use-parameter-defaults` to `coder create` when set, which auto-accepts template parameter defaults instead of dropping the user into an interactive prompt. Mirrors the env-var equivalent `CODER_WORKSPACE_USE_PARAMETER_DEFAULTS=true` that the dogfood script already uses. - New `useParameterDefaults?: boolean` field on `CoderCreateFromTemplateOptions` (create-mode only — attach mode doesn't run `coder create`). - `createCoderWorkspace` pushes the flag when truthy; absent flag keeps default interactive behaviour. - TDD: vitest now has a focused test that runs the provider twice (with and without the option) and asserts the create argv contains / does not contain `--use-parameter-defaults`. - Smoke test gains the field for compile-time coverage. - README example shows the option. Full suite: 1028 tests pass; typecheck clean. --- _Generated with [\`mux\`](https://github.com/coder/mux) • Model: \`anthropic:claude-opus-4-7\` • Thinking: \`max\`_
a9e73cd to
3ad3518
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hey Matt,
Really cool project, thanks for kicking it off.
I've added a sandbox provider for coder/coder.
Let me know if you're interested in getting this merged in-tree or if this should live outside of Sandcastle and be published separately.
Agent PR body and implementation plan/prompt on clear context below.
Summary
Adds a built-in
coder()isolated sandbox provider that integrates Sandcastle with Coder workspaces.The provider can either:
parameters,parameterFile,preset,templateVersion,organization, andworkspaceName); orname,owner/name, or UUID (auto-starting it if it is stopped).What's included
src/sandboxes/coder.ts. CLI-only implementation that shells out tocoderfor everything (no new npm peer dep). DiscriminatedCoderOptionsunion (templatexorworkspace). RequiredonClose: "delete" | "stop" | "leave"— Coder workspaces are persistent, so we make the user choose explicitly. Pollscoder list -o jsonfor connected Coder workspace agents because they sometimes appear aftercoder createreturns. Streams file copy through OpenSSHProxyCommand=coder ssh --stdio …so transfer goes via the Coder tunnel without mutating the user's~/.ssh/config.src/sandboxes/coder.test.ts— vitest behavior + type-level (@ts-expect-error) tests for requiredonCloseand mutually exclusivetemplate/workspace.@ai-hero/sandcastle/sandboxes/coder.CONTEXT.mddefinesCoder workspaceandCoder workspace agent(qualified to avoid colliding with Sandcastle's own "agent"); ADR 0009 (CLI-only strategy) and ADR 0010 (requiredonClose).patch..sandcastle/test-coder.ts(npm run test-coder) launches three workspaces from one template —stop,delete,leave— and writes a JSON report under.sandcastle/logs/.Validation
Automated:
Dogfooded end-to-end against
https://dev.coder.comusing thecoder/ "Write Coder on Coder" template with thePittsburghpreset:A separate marker run (
sc-marker-e33f9a-leave) created/home/coder/sandcastle-markerinside the Coder workspace, verified viacoder ssh tk/sc-marker-e33f9a-leave.dev -- sh -c 'ls -l /home/coder':Reports are checked in under
.sandcastle/logs/coder-dogfood-*.jsonfor reviewers.Notes
coderCLI onPATH. A singlecoder whoami -o jsonpreflight on eachcreate()doubles as binary check, auth check, and diagnostic context capture.interactiveExecforwards caller-provided stdio descriptors directly socoder sshcan do its normal TTY auto-detection — there is no--force-ttyflag in the verified CLI.last_used_atfresh while commands run.📋 Implementation Plan
Plan: add a
coder()sandbox providerContext and goal
Add a built-in Sandcastle sandbox provider that integrates with Coder workspaces. Users should be able to either:
In Sandcastle terminology this is an isolated sandbox provider: the Coder workspace has its own filesystem, so Sandcastle must sync code into it and copy files back out. Be careful with terminology:
Key design decisions
Provider category
Implement
coder()as anIsolatedSandboxProvider, exported from@ai-hero/sandcastle/sandboxes/coder.Rationale: a Coder workspace is remote/persistent and cannot bind-mount the host worktree.
Implementation strategy: use the
coderCLIUse the
coderCLI for all provider operations rather than a REST SDK or custom tunnel implementation.Reasons:
coder sshalready implements the tunnel, streams output, supports PTY allocation, and supports multi-agent selection via<workspace>.<agent>.coder create/coder startblock until the workspace is running and the workspace agent is connected.coder list -o jsongives enough structured state for agent discovery and attach-mode resolution.Create an ADR for this decision if it is not already present:
docs/adr/0009-coder-provider-cli-strategy.md.Required
onCloseonCloseis required and has no default:Reason: Coder workspaces are persistent and potentially user-owned. Any default can surprise users:
deletecan destroy something valuable;leavecan leak resources; mode-dependent defaults hide lifecycle behavior. Make the user choose explicitly.Create an ADR for this decision if it is not already present:
docs/adr/0010-coder-provider-required-onclose.md.Public options shape
Use a top-level discriminated union based on
templatevsworkspace, with?: neverexclusivity.Details:
templateandworkspaceaccept either name or ID where the CLI supports it.templateVersionis a CLI-compatible template version value; verify during the CLI spike whether IDs are accepted. If the CLI only accepts version names, document that constraint rather than claiming ID support.parametersvalues are stringified before becoming--parameter key=valueflags.parameterFilepasses through as--rich-parameter-file.presetpasses through as--presetand is kept separate fromparameters.organizationis create-mode only; omit the CLI flag when undefined and let the CLI infer from auth context.owneris attach-mode only; default to"me"for name-based lookup.workspaceNameassandcastle-<8-hex-chars>when omitted.Implementation steps
0. Verify the CLI-only foundation before deep implementation
Before implementing the provider deeply, run a short CLI spike against the target
coderCLI version and save representative outputs in notes or test fixtures used by the implementation work:coder whoami -o json coder list -o json coder list -o json --search 'owner:me' coder create --help coder ssh --helpConfirm specifically:
coder list -o jsonincludeslatest_build.resources[].agents[]with at leastname,status, anddirectoryor enough information to derive a remote workdir.owner/nameor equivalent search semantics.coder ssh <uuid>andcoder ssh <uuid>.<agent>work. If SSH does not accept UUID refs, attach-by-ID must resolve UUID → owner/name before constructingsshRef.coder createsupports the planned flags:--template,--template-version,--parameter,--rich-parameter-file,--preset,--yes, and the exact organization flag iforganizationis provided.coder create --template-versionaccepts the value form promised byCoderOptions; if IDs are not accepted, documenttemplateVersionas a version name / CLI-compatible value only.If the CLI JSON does not contain the agent/workdir data the provider needs, pause implementation and update this plan rather than silently adding brittle parsing of human-readable output.
1. Add source module
Create
src/sandboxes/coder.ts.Export:
CoderOptionsCoderCommonOptionsCoderCreateFromTemplateOptionsCoderAttachToWorkspaceOptionsCoderOnClosecoder(options: CoderOptions): IsolatedSandboxProviderUse
createIsolatedSandboxProvider({ name: "coder", env: options.env, create }).2. Build CLI execution helpers
Add small internal helpers in
coder.tsonly; do not add abstractions unless needed by multiple code paths.Suggested helpers:
coderEnv(options)— mergesprocess.envwithCODER_URL/CODER_SESSION_TOKENwhen provided.runCoder(args, opts?)— spawncoder, collect stdout/stderr, return exit code; optionally line-stream stdout.runCoderJson<T>(args)— callrunCoder, parse stdout as JSON, assert expected shape before use.pipeProcesses(left, right)— for tar pipelines and binary stream copy.shellQuote(value)— quote remote shell paths/commands safely; add focused tests or inline assertions for paths with spaces.assertNonEmptyString,assertArray, etc. — use defensive assertions around parsed CLI JSON.Preflight every provider
create()call with:This catches missing binary, bad auth, and captures
URL,Username,ID,Orgs,Rolesfor error context. Do not cache preflight results.3. Create-mode flow
When
templateis present:workspaceNameif absent:sandcastle-<8 hex>.coder createargs:create <workspaceName>--template <template>--template-version <templateVersion>if present--parameter key=valuefor everyparametersentry, afterString(value)--rich-parameter-file <parameterFile>if present--preset <preset>if presentorganizationis present--yescoder createblock until ready. Do not pass--no-wait.coder list -o jsonso the handle knows the selected Coder workspace agent and workdir.coder createsucceeds but any later setup step fails before returning the handle (workspace resolution, Coder workspace agent selection, workdir creation, etc.), attempt cleanup according toonClosefor the newly-created Coder workspace before rethrowing. Sandcastle will not callclose()ifcreate()never returns a handle.4. Attach-mode flow
When
workspaceis present:workspaceRef:workspaceis a UUID, start withworkspaceRef = workspace.owneris provided, useworkspaceRef = owner + "/" + workspace.workspaceRef = workspace.workspacealready contains/andowneris also provided, throw a configuration error rather than guessing.coder list -o jsonand the provided ID orowner/namequery semantics.coder start <workspaceRef> --yesand wait.workspaceRefto the exact value that lifecycle commands and SSH commands accept. If attach-by-ID required resolving UUID → owner/name, use the resolved owner/name form forworkspaceRefand buildsshReffrom that.latest_build.resources[].agents[].5. Coder workspace agent selection
Use
options.workspaceAgentwhen provided.If omitted:
workspaceAgent.Use the CLI reference
<workspace>.<agent>when a specific agent is needed.6. Workspace refs and workdir selection
Keep two references distinct throughout the implementation:
workspaceRef— the Coder workspace reference used for lifecycle commands (coder start,coder stop,coder delete,coder listmatching). Never append the agent name here.sshRef— the reference used for SSH commands. If a Coder workspace agent is selected, this is<workspaceRef>.<workspaceAgent>; otherwise it isworkspaceRef.Compute the provider handle's
worktreePathas an absolute path:options.workdir, if provided. Assert it is absolute; if it is relative, throw a clear configuration error.<selectedAgent.directory>/.sandcastle/worktree, if the selected Coder workspace agent declares an absolutedirectory.coder ssh <sshRef> -- sh -c 'printf %s "$HOME"', assert the result is absolute, and use<remoteHome>/.sandcastle/worktree.Do not use
~/.sandcastle/worktreeasworktreePath;~is shell syntax, not an absolute path. Ensure the directory exists withcoder ssh <sshRef> -- mkdir -p <quoted workdir>before copy/exec use.7. Implement
execUse
coder ssh <sshRef> -- sh -c <command>.Requirements:
opts.cwdby prefixing/constructing command so it runs fromopts.cwd ?? worktreePath.opts.sudoby wrapping appropriately (match existing provider behavior as closely as possible).opts.stdinby piping it to the child process stdin and closing it.{ stdout, stderr, exitCode }.opts.onLineis present, call it for each stdout line in real time, not after buffering.8. Implement
interactiveExecImplement in v1.
Use
coder ssh <sshRef> -- ...argswith piped stdio fromInteractiveExecOptions.Do not rely only on Coder's automatic TTY detection while stdio is piped through Node: the CLI may not see a TTY even when
options.stdin.isTTYis true. Ifoptions.stdin.isTTYis true, pass Coder's force-PTY flag (verify exact flag during the CLI spike, expected--force-tty). If the implementation can safely inherit process stdio when the provided streams are exactlyprocess.stdin/stdout/stderr, that is also acceptable, but keep behavior explicit and tested manually.9. Implement file copy
Use streams through
coder ssh, notcoder config-sshorscp. Avoid mutating the user's SSH config.Be precise about Sandcastle's copy contracts:
copyIn(hostDir, sandboxPath)must copy the contents ofhostDirintosandboxPath, matching the Vercel provider's directory semantics. It must not createsandboxPath/<hostBase>.copyIn(hostFile, sandboxPath)must write the file to the exactsandboxPath, even when the destination basename differs.copyFileOut(sandboxPath, hostPath)must write the remote file to the exacthostPath, even when the destination basename differs.Recommended mechanics:
copyIn:mkdir -p <sandboxPath>, thentar czf - -C <hostDir> . | coder ssh <sshRef> -- tar xzf - -C <quoted sandboxPath>.copyIn:mkdir -p <remote dirname>, then pipe a host read stream intocoder ssh <sshRef> -- sh -c 'cat > <quoted sandboxPath>'.copyFileOut:mkdir -p <host dirname>, then pipecoder ssh <sshRef> -- sh -c 'cat < <quoted sandboxPath>'stdout into a host write stream for the exacthostPath.For every pipeline, fail if either side exits non-zero and include both stderr streams in the thrown error. Use binary streams for file copy; do not route file contents through UTF-8 strings. Quote all remote paths with
shellQuote(), including paths that contain spaces or begin with-. Copy exactly what Sandcastle provides; do not add exclusions or special.githandling.10. Implement
closeUse required
onClose:"delete"→coder delete <workspaceRef> --yes"stop"→coder stop <workspaceRef> --yes"leave"→ no-opUse the unqualified
workspaceReffor lifecycle commands, neversshRef/<workspace>.<agent>.If
close()fails, surface stderr with enough workspace context. Avoid swallowing lifecycle errors unless matching existing provider behavior requires it.11. Add package export
Edit
package.jsonexports:Do not add npm dependencies or peer dependencies.
12. Add tests
Create
src/sandboxes/coder.test.tsfollowing the lightweight provider test pattern.Test at least:
coder({ template: "node", onClose: "delete" })returns tag"isolated", name"coder".coder({ workspace: "my-ws", onClose: "leave" })returns tag"isolated", name"coder".envis stored and defaults to{}.parameters,parameterFile,preset,templateVersion,organization,workspaceAgent,workdir.expectTypeOfor// @ts-expect-errortests to guard that passing bothtemplateandworkspaceis rejected and omittingonCloseis rejected.Do not run a real Coder workspace in the normal unit test suite.
13. Update docs
Update
README.md:### Codersubsection under "Sandbox Providers" with:codermust be installed and authenticated.onCloseexplanation.workspaceAgentnote for multi-agent Coder workspaces.last_used_atfresh while in flight; no heartbeat between exec calls in v1.Update
CONTEXT.mdonly if the Coder workspace / Coder workspace agent terms are not already captured.Ensure the two ADRs above exist and match the final decisions. In particular, verify the CLI-strategy ADR does not incorrectly cross-reference the
onCloseADR for dormancy/heartbeat details.14. Add changeset
Add
.changeset/<descriptive-name>.md:Dogfooding and validation
Automated validation
Run after implementation:
If
npm test -- src/sandboxes/coder.test.tsis not the repo's exact test invocation, use the existing Vitest command pattern frompackage.json.Manual Coder smoke tests
Use a real Coder deployment or a local
coder serverinstance.Prerequisites:
Create-mode smoke test:
Attach-mode smoke test:
<agent.directory>/.sandcastle/worktreeunlessworkdiroverrides.Interactive smoke test:
interactive()with the Coder provider and verify input/output works throughcoder ssh.File-copy smoke test:
Failure-mode dogfood:
coderon PATH → clear preflight error.workspaceAgent→ clear error listing names.Capture screenshots of:
If possible, record a short terminal video of create-mode and attach-mode runs for reviewer verification.
Acceptance criteria
@ai-hero/sandcastle/sandboxes/coderexportscoder()and its option types.coder()returns an isolated sandbox provider and supportsrun(),createSandbox(), andinteractive().templateandworkspace.onClose.workspaceAgentunless exactly one agent exists.execstreams stdout line-by-line viaonLinein real time.interactiveExecis implemented usingcoder ssh.copyInandcopyFileOutwork without mutating user SSH config.close()honorsdelete,stop, andleaveexactly.Risks and mitigations
spawn; usesh -conly where required by Sandcastle's command contract and carefully quote cwd/workdir/remote file paths with a dedicatedshellQuote()helper.tar; most Coder templates should satisfy this.workspaceName, let Coder's existing-name error surface.Generated with
mux• Model:anthropic:claude-opus-4-7• Thinking:max