@clipboard-health/groundcrew 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,12 @@ This installs the `crew` binary. `@clipboard-health/clearance` is pulled in tran
15
15
 
16
16
  ## Quickstart
17
17
 
18
- 1. **Install prereqs.** Node 24, `git`, `cmux` _or_ `tmux`, and the agent CLIs themselves (`claude`, `codex`, `cursor-agent`, ...). Groundcrew is **macOS-only** and requires [Safehouse](https://agent-safehouse.dev/) on `PATH`. Optional: `codexbar` for session-usage gating. The `workspaceKind` config key picks the workspace backend (`auto` resolves to cmux when installed, else tmux).
18
+ 1. **Install prereqs.** Node 24, `git`, `cmux` _or_ `tmux`, and the agent CLIs themselves (`claude`, `codex`, `cursor-agent`, ...). Pick a local isolation backend below depending on platform and what the agent needs to do. Optional: `codexbar` for session-usage gating. The `workspaceKind` config key picks the workspace backend (`auto` resolves to cmux when installed, else tmux).
19
+ - **`safehouse`** (macOS default) — [Safehouse](https://agent-safehouse.dev/) on `PATH`. The fastest local backend; cannot safely give the agent Docker.
20
+ - **`sdx`** (Linux default, macOS opt-in) — [Docker Sandboxes](https://docs.docker.com/sandboxes/) (`sbx`) on `PATH`. Required when a ticket needs the agent to use Docker (`docker build`, `docker run`, integration tests). Each model that should run under sdx needs a `sandbox: { agent: "<sbx-agent>" }` block in `config.ts` so groundcrew knows which sbx agent to address.
21
+ - **`none`** — explicit unsandboxed escape hatch. Never picked implicitly; `crew doctor` warns when it is configured.
22
+
23
+ Groundcrew resolves `local.runner` per platform: macOS → `safehouse`, Linux/WSL → `sdx`. Set `local.runner` to `"safehouse" | "sdx" | "none"` to override; leave at `"auto"` for the platform default.
19
24
 
20
25
  2. **Create a Linear project to scope your work.** Any team works — make a project inside it and drop tickets in. The orchestrator polls by project, not by team, so you don't need a dedicated team.
21
26
 
@@ -63,11 +68,11 @@ This installs the `crew` binary. `@clipboard-health/clearance` is pulled in tran
63
68
 
64
69
  `LINEAR_API_KEY` continues to work for existing setups; if both variables are set, `GROUNDCREW_LINEAR_API_KEY` wins.
65
70
 
66
- 5. **Prepare the runner and agent auth.** Groundcrew supports one runner: a `cmux` or `tmux` workspace on macOS, with Safehouse on `PATH`, `clearance`, and locally authenticated agent CLIs.
71
+ 5. **Prepare the runner and agent auth.** Groundcrew uses a `cmux` or `tmux` workspace hosting the resolved local backend (`safehouse`, `sdx`, or `none`) plus locally authenticated agent CLIs. Setup fails fast when the resolved backend's binary or platform requirement is missing — `safehouse` requires macOS + `safehouse` on PATH; `sdx` requires macOS or Linux + `sbx` on PATH. `models.isolation` and per-model `isolation` are legacy keys and fail config validation. Per-model `sandbox` blocks are accepted again and used by the `sdx` runner.
67
72
 
68
- Setup fails before creating a worktree when the host is not macOS or `safehouse` is missing. `models.isolation`, per-model `isolation`, and per-model `sandbox` are legacy keys and now fail config validation.
73
+ For sdx, sandboxes are created automatically on first launch. Groundcrew names them deterministically as `groundcrew-<repository>-<model>`, probes `sbx ls`, and runs `sbx create [--template ...] [--kit ...] <agent> <projectDir>` when missing `projectDir` is the mount so every per-ticket worktree (created as a sibling of the bare clone) is visible inside the sandbox. First-time agent auth happens inside the sandbox the first time the agent runs via `sbx exec`. To bootstrap manually instead, run `sbx create --name groundcrew-<repo>-<model> <agent> <projectDir>` once.
69
74
 
70
- 6. **Set the clearance allowlist for local macOS runs.** Groundcrew starts `clearance` from `@clipboard-health/clearance` on `http://127.0.0.1:19999` (skipping the launch if something is already listening) and runs the agent through the bundled `safehouse-clearance` wrapper. Clearance refuses to start without an allowlist — see [its README](https://github.com/ClipboardHealth/core-utils/tree/main/packages/clearance) for the proxy's env vars, log paths, and DNS rules. The shortest path is to set the env before `crew run`:
75
+ 6. **Set the clearance allowlist for local Safehouse runs.** Only applies when `local.runner` resolves to `safehouse`. Groundcrew starts `clearance` from `@clipboard-health/clearance` on `http://127.0.0.1:19999` (skipping the launch if something is already listening) and runs the agent through the bundled `safehouse-clearance` wrapper. Clearance refuses to start without an allowlist — see [its README](https://github.com/ClipboardHealth/core-utils/tree/main/packages/clearance) for the proxy's env vars, log paths, and DNS rules. The shortest path is to set the env before `crew run`:
71
76
 
72
77
  ```bash
73
78
  CLEARANCE_ALLOW_HOSTS="api.openai.com,auth.openai.com,api.anthropic.com,mcp.linear.app,api.linear.app" \
@@ -112,12 +117,14 @@ Required fields are marked **required**; everything else has a default and can b
112
117
  | `orchestrator.sessionLimitPercentage` | `85` | Number in `(0, 100]`. A model whose codexbar session window exceeds this percentage is skipped that tick. |
113
118
  | `models.default` | `"claude"` | Tiebreak for `agent-any` resolution and fallback for explicit but unknown `agent-*` labels. Also used by `crew run --ticket <TICKET>` for unlabeled tickets. `crew run` without `--ticket` ignores unlabeled tickets and does not apply this default. Must exist in `models.definitions`. |
114
119
  | `models.definitions` | `{ claude, codex }` | Agent definitions. Additive merge with shipped defaults. |
115
- | `models.definitions.<name>.cmd` | — | Shell command launched for the model. Runs in the worktree through Safehouse/clearance. `{{worktree}}` is replaced before launch and legacy `{{sandbox}}` expands to an empty string. |
120
+ | `models.definitions.<name>.cmd` | — | Shell command launched for the model. Runs in the worktree through the resolved `local.runner`. `{{worktree}}` is replaced before launch; `{{sandbox}}` expands to the sbx sandbox name under the sdx runner and an empty string otherwise. |
116
121
  | `models.definitions.<name>.color` | — | Color for the workspace status pill (cmux only; tmux silently drops it). |
117
122
  | `models.definitions.<name>.usage` | optional | If set, codexbar usage is fetched for this model and gated by `sessionLimitPercentage`. Omit to never gate. When `usage.codexbar.source` is omitted, groundcrew uses `auto` on macOS and `cli` elsewhere. |
123
+ | `models.definitions.<name>.sandbox` | optional | Docker Sandboxes binding for the model. Required at launch when `local.runner` resolves to `sdx`. Fields: `agent` (required sbx agent name), `template`, `kits`, `setupCommand` (override for the inside-sandbox setup script). |
118
124
  | `models.definitions.<name>.disabled` | optional | When set to exactly `true`, drops the named shipped default (`claude` or `codex`). Doctor skips probing it; `agent-<name>` labels fall back to `models.default` with a warning. See "Disabling a shipped default" below. |
119
125
  | `prompts.initial` | (template) | First message sent to the agent. Placeholders: `{{ticket}}`, `{{worktree}}`, `{{title}}`, `{{description}}`. |
120
126
  | `workspaceKind` | `"auto"` | Terminal session manager. `"auto"` picks `cmux` when on PATH, else `tmux`. Set to `"cmux"` or `"tmux"` to fail loudly when the chosen backend is missing. tmux windows live in a dedicated `groundcrew` session. |
127
+ | `local.runner` | `"auto"` | Local isolation backend. `"auto"` resolves to `safehouse` on macOS and `sdx` on Linux/WSL. Explicit values: `"safehouse"`, `"sdx"`, `"none"`. `"none"` is never picked implicitly — doctor warns when it is configured. |
121
128
  | `logging.file` | XDG state path | Append-mode log file destination. `log()` / `logEvent()` tee here in addition to stdout, so a vanished workspace doesn't take the evidence with it. Defaults to `${XDG_STATE_HOME:-$HOME/.local/state}/groundcrew/groundcrew.log`. |
122
129
 
123
130
  The branch prefix (`<prefix>-<TICKET>`) is derived from your OS username (`os.userInfo().username`), not configured. Agent selection looks for a top-level Linear label named `agent-<model>` (e.g. `agent-claude`, `agent-codex`). **`crew run` without `--ticket` only fetches tickets with an `agent-*` label** — the GraphQL query filters them server-side, so unlabeled tickets are never returned by Linear's API and do not appear in the rendered board. Use `crew run --ticket <TICKET>` to provision an unlabeled ticket on demand (manual setup falls back to `models.default`). The reserved label `agent-any` routes the ticket to the configured model with the most available session capacity (lowest codexbar session-used percent), skipping any model already over `sessionLimitPercentage`. With no usage data, `agent-any` resolves to `models.default`. The name `any` cannot be used in `models.definitions`. Todo tickets blocked by Linear issues that are not in `linear.statuses.terminal` are skipped until their blockers reach a terminal status.
@@ -164,16 +171,16 @@ crew cleanup <TICKET>
164
171
 
165
172
  ## Gotchas
166
173
 
167
- - **Execution is macOS plus Safehouse only.** There is no `models.isolation` strategy and no direct local execution mode. Linux/WSL is not supported.
174
+ - **Local execution picks one of safehouse/sdx/none.** `local.runner: "auto"` resolves to `safehouse` on macOS and `sdx` (Docker Sandboxes) on Linux/WSL. Override with `local.runner: "safehouse" | "sdx" | "none"`. There is no per-model `isolation` knob anymore the runner is global. `sdx` requires a per-model `sandbox: { agent }` block so groundcrew can map the model to an sbx agent.
168
175
  - **Safehouse-already-wrapped commands are not re-wrapped.** If a `models.definitions.<name>.cmd` already starts with `safehouse`, groundcrew assumes that command owns its Safehouse flags and does not add the `safehouse-clearance` wrapper a second time. Changing the proxy's allowlist after it's running requires killing the PID in `${XDG_CACHE_HOME:-$HOME/.cache}/clearance/clearance.pid` so the next launch picks up the new env.
169
- - **Legacy Docker Sandboxes state is unmanaged.** Groundcrew no longer discovers or cleans `.sbx` worktrees or persistent Docker Sandboxes containers. If you have old state, inspect and remove it manually with `sbx`.
176
+ - **Sandbox lifecycle is create-only.** Groundcrew auto-creates the sandbox for a `<repository, model>` pair when missing, but never deletes one sandboxes persist across tickets and across `crew cleanup`. Auth state lives inside the sandbox, so deleting it forces a re-login. Inspect or remove them manually with `sbx ls` / `sbx rm`.
170
177
  - **Dead tmux windows vanish by default.** When a wrapped agent command fails (e.g. `safehouse-clearance` not found, `npm install` crash), the tmux window closes immediately and the error scrolls into the void. Set `GROUNDCREW_KEEP_DEAD_WINDOWS=1` (any non-empty value works) in the env you launch `crew` from to flip the per-window `remain-on-exit` to `on`; the window stays open with the error visible. Close it manually with `tmux kill-window -t groundcrew:<ticket>` after diagnosis. tmux backend only.
171
178
  - **Status names matter.** If your team uses `Started` instead of `In Progress`, set `linear.statuses.inProgress = "Started"`.
172
179
  - **Leaf-only.** Parent issues with children are ignored — sub-issues are the work items.
173
180
  - **Tickets stay in the in-progress status until something else moves them.** Groundcrew sets a ticket to `inProgress` when it provisions a workspace and never advances it. The next transition (typically "in review" when a PR opens) is left to your team's Linear automation rules.
174
181
  - **Project must be on a single Linear team in practice.** Cross-team projects work — the orchestrator caches the in-progress state ID per team — but every team in the project must use the same status name for `linear.statuses.inProgress`.
175
182
  - **Claude launches in bypass-permissions mode by default.** Groundcrew creates isolated per-ticket worktrees for unattended runs, so the shipped `claude` command is `claude --permission-mode bypassPermissions` to avoid workspace-trust and tool-permission prompts blocking automation. Override `models.definitions.claude.cmd` if you want a stricter mode.
176
- - **Doctor's command introspection is shallow.** Doctor reports whether the host can run local tickets with macOS plus Safehouse, then tokenizes model `cmd` and checks the first two non-flag tokens against PATH (so `safehouse claude --foo` checks both `safehouse` and `claude`). Boolean flags without values, env-var assignments (`FOO=1`), shell pipelines, and subshells are not parsed — verify those manually. In particular, `npx -y claude` and `env FOO=1 claude` only check the wrapper, not the wrapped CLI.
183
+ - **Doctor's command introspection is shallow.** Doctor reports the resolved local runner (safehouse / sdx / none) and whether its prerequisites are met, then tokenizes model `cmd` and checks the first two non-flag tokens against PATH (so `safehouse claude --foo` checks both `safehouse` and `claude`). Boolean flags without values, env-var assignments (`FOO=1`), shell pipelines, and subshells are not parsed — verify those manually. In particular, `npx -y claude` and `env FOO=1 claude` only check the wrapper, not the wrapped CLI. When `local.runner` is `"none"`, doctor surfaces a single WARNING line so the unsandboxed launch is visible at a glance.
177
184
  - **Doctor checks every enabled model, including shipped defaults you didn't disable.** `models.definitions` includes both shipped defaults (`claude`, `codex`) by default via additive merge. If you only intend to label tickets `agent-claude` and don't have `codex` installed, set `models.definitions.codex: { disabled: true }` (see "Disabling a shipped default" under "Config reference"). Without that, doctor exits non-zero on a missing `codex` binary even though `crew run` would never route to it.
178
185
  - **Switch to tmux if cmux is misbehaving.** Set `workspaceKind: "tmux"` to force the tmux backend when cmux's CLI/socket bridge is flaky (symptoms: `cmux --json list-workspaces` returning `Failed to write to socket (Broken pipe)` or `Socket not found at ...cmux.sock` on every tick). tmux is more reliable — just a unix socket, no GUI app — at the cost of losing cmux's status pills, notifications, and vertical-tab sidebar.
179
186
  - **Agent CLI must accept a positional prompt.** The handoff is `<your cmd> "<prompt>"`. `claude`, `codex`, and `cursor-agent` all support this.
package/configExample.ts CHANGED
@@ -44,9 +44,18 @@ export const config: Config = {
44
44
  // cmd: "cursor-agent",
45
45
  // color: "#929292",
46
46
  // },
47
+ // // To run a model under the sdx (Docker Sandboxes) runner, bind it to
48
+ // // an sbx agent. Required when `local.runner` resolves to `sdx`.
49
+ // // claude: { sandbox: { agent: "claude" } },
47
50
  // },
48
51
  // },
49
52
  //
53
+ // // Local isolation backend. Defaults to `"auto"` — macOS → safehouse,
54
+ // // Linux → sdx (Docker Sandboxes). `"none"` is an explicit unsandboxed
55
+ // // escape hatch and is never picked implicitly. Switch to `"sdx"` on
56
+ // // macOS when you need an agent to use Docker safely.
57
+ // local: { runner: "auto" },
58
+ //
50
59
  // prompts: {
51
60
  // initial: [
52
61
  // "Begin work on {{ticket}} ({{title}}) in the {{worktree}} wt subdirectory.",
@@ -1 +1 @@
1
- {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../src/commands/doctor.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAwIH,wBAAsB,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC,CA6D/C"}
1
+ {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../src/commands/doctor.ts"],"names":[],"mappings":"AAAA;;;GAGG;AA8IH,wBAAsB,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC,CAkE/C"}
@@ -3,8 +3,9 @@
3
3
  * Returns true if every required check passes; false otherwise.
4
4
  */
5
5
  import { existsSync, statSync } from "node:fs";
6
- import { loadConfig } from "../lib/config.js";
6
+ import { loadConfig, } from "../lib/config.js";
7
7
  import { detectHostCapabilities, which } from "../lib/host.js";
8
+ import { resolveLocalRunner } from "../lib/localRunner.js";
8
9
  import { errorMessage, resolveLinearApiKey, writeOutput } from "../lib/util.js";
9
10
  import { resolveWorkspaceKind } from "../lib/workspaces.js";
10
11
  // Tokenization stops after this many non-flag tokens. Two is enough to
@@ -140,8 +141,13 @@ export async function doctor() {
140
141
  writeOutput(`[--] host: ${errorMessage(error)}`);
141
142
  return false;
142
143
  }
143
- const localCapability = localCapabilityCheck(host);
144
- reportLocalCapability(localCapability);
144
+ const resolvedRunner = resolveLocalRunner(config.local.runner, host);
145
+ const localCapability = localCapabilityCheck(host, resolvedRunner);
146
+ reportLocalCapability({
147
+ check: localCapability,
148
+ setting: config.local.runner,
149
+ resolved: resolvedRunner,
150
+ });
145
151
  const workspaceOutcome = resolveWorkspaceOutcome(config, host);
146
152
  reportWorkspaceKind(config, workspaceOutcome);
147
153
  const checks = [
@@ -176,22 +182,43 @@ export async function doctor() {
176
182
  writeOutput("All required checks passed.");
177
183
  return true;
178
184
  }
179
- function localCapabilityCheck(host) {
180
- const supportsLocalRunner = host.isSafehouseSupported && host.hasSafehouse;
185
+ function localCapabilityCheck(host, resolved) {
186
+ if (resolved === "safehouse") {
187
+ const ok = host.isSafehouseSupported && host.hasSafehouse;
188
+ return {
189
+ name: "local runner (safehouse)",
190
+ ok,
191
+ required: false,
192
+ hint: ok
193
+ ? "ready"
194
+ : "safehouse runner requires macOS with `safehouse` on PATH (install from https://agent-safehouse.dev/)",
195
+ };
196
+ }
197
+ if (resolved === "sdx") {
198
+ const ok = host.isSdxSupported && host.hasSbx;
199
+ return {
200
+ name: "local runner (sdx)",
201
+ ok,
202
+ required: false,
203
+ hint: ok
204
+ ? "ready"
205
+ : "sdx runner requires `sbx` (Docker Sandboxes) on PATH (install from https://docs.docker.com/sandboxes/)",
206
+ };
207
+ }
208
+ // resolved === "none"
181
209
  return {
182
- name: "local runner (macOS + Safehouse)",
183
- ok: supportsLocalRunner,
210
+ name: "local runner (none)",
211
+ ok: true,
184
212
  required: false,
185
- hint: supportsLocalRunner
186
- ? "ready"
187
- : "groundcrew requires macOS with Safehouse on PATH (install from https://agent-safehouse.dev/)",
213
+ hint: "WARNING: local.runner='none' — agent runs unsandboxed on the host. Only use this when you understand the implications.",
188
214
  };
189
215
  }
190
- function reportLocalCapability(check) {
216
+ function reportLocalCapability(arguments_) {
191
217
  writeOutput();
192
218
  writeOutput("Local runner");
193
219
  writeOutput("------------");
194
- writeOutput(format(check));
220
+ writeOutput(`requested: ${arguments_.setting} → resolved: ${arguments_.resolved}`);
221
+ writeOutput(format(arguments_.check));
195
222
  }
196
223
  function resolveWorkspaceOutcome(config, host) {
197
224
  try {
@@ -1 +1 @@
1
- {"version":3,"file":"setupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/setupWorkspace.ts"],"names":[],"mappings":"AAOA,OAAO,EAAkC,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AASvF,UAAU,aAAa;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AAgBD,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,OAAO,CAAC,EAAE,aAAa,CAAC;CACzB;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAmED,wBAAsB,cAAc,CAClC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,qBAAqB,EAC9B,UAAU,GAAE,wBAA6B,GACxC,OAAO,CAAC,IAAI,CAAC,CA6Ef;AA0FD,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GACjC,OAAO,CAAC,IAAI,CAAC,CAoBf"}
1
+ {"version":3,"file":"setupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/setupWorkspace.ts"],"names":[],"mappings":"AAOA,OAAO,EAAkC,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAUvF,UAAU,aAAa;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AAgBD,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,OAAO,CAAC,EAAE,aAAa,CAAC;CACzB;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAmED,wBAAsB,cAAc,CAClC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,qBAAqB,EAC9B,UAAU,GAAE,wBAA6B,GACxC,OAAO,CAAC,IAAI,CAAC,CAoGf;AA0FD,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GACjC,OAAO,CAAC,IAAI,CAAC,CAoBf"}
@@ -1,13 +1,14 @@
1
1
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
- import { join } from "node:path";
3
+ import { join, resolve } from "node:path";
4
4
  import { ensureClearance } from "@clipboard-health/clearance";
5
5
  import { fetchResolvedIssue } from "../lib/boardSource.js";
6
6
  import { BUILD_SECRET_NAMES, loadConfig } from "../lib/config.js";
7
+ import { ensureSandbox, sandboxNameFor } from "../lib/dockerSandbox.js";
7
8
  import { detectHostCapabilities } from "../lib/host.js";
8
9
  import { buildLaunchCommand, shellSingleQuote } from "../lib/launchCommand.js";
9
10
  import { createLinearIssueStatusUpdater } from "../lib/linearIssueStatus.js";
10
- import { assertLocalRunnerRequirements } from "../lib/localRunner.js";
11
+ import { assertLocalRunnerRequirements, resolveLocalRunner } from "../lib/localRunner.js";
11
12
  import { errorMessage, getLinearClient, log, readEnvironmentVariable } from "../lib/util.js";
12
13
  import { workspaces } from "../lib/workspaces.js";
13
14
  import { isWorktreeAlreadyExistsError, worktrees } from "../lib/worktrees.js";
@@ -75,8 +76,16 @@ export async function setupWorkspace(config, options, runOptions = {}) {
75
76
  if (!definition) {
76
77
  throw new Error(`Unknown model: ${model}`);
77
78
  }
78
- assertLocalRunnerRequirements(await detectHostCapabilities(signal));
79
- await ensureClearance({ logger: log });
79
+ const host = await detectHostCapabilities(signal);
80
+ const runner = resolveLocalRunner(config.local.runner, host);
81
+ assertLocalRunnerRequirements(host, runner);
82
+ if (runner === "safehouse") {
83
+ await ensureClearance({ logger: log });
84
+ }
85
+ if (runner === "sdx" && definition.sandbox === undefined) {
86
+ throw new Error(`Local groundcrew runs with the sdx runner require a sandbox config on model '${model}'. ` +
87
+ "Add `sandbox: { agent: '<sbx-agent-name>' }` to the model in your config.ts.");
88
+ }
80
89
  const spec = { repository, ticket };
81
90
  let created;
82
91
  try {
@@ -113,11 +122,21 @@ export async function setupWorkspace(config, options, runOptions = {}) {
113
122
  const stagedPrompt = stagePrompt({ config, ticket, ticketDetails, worktreeName });
114
123
  promptDir = stagedPrompt.directory;
115
124
  const secretsFile = stageBuildSecrets(promptDir);
125
+ const sandboxName = runner === "sdx" ? sandboxNameFor({ repository, model }) : undefined;
126
+ if (runner === "sdx" && sandboxName !== undefined && definition.sandbox !== undefined) {
127
+ await ensureSandbox({
128
+ sandboxName,
129
+ sandbox: definition.sandbox,
130
+ mountPath: resolve(config.workspace.projectDir),
131
+ }, signal);
132
+ }
116
133
  const launchCommand = buildLaunchCommand({
117
134
  definition,
118
135
  promptFile: stagedPrompt.file,
119
136
  worktreeDir: launchDir,
120
137
  secretsFile,
138
+ runner,
139
+ sandboxName,
121
140
  });
122
141
  const launchCmd = stageWorkspaceLaunchCommand(promptDir, launchCommand);
123
142
  log("Opening workspace...");
@@ -15,6 +15,39 @@ export declare const AGENT_ANY_MODEL = "any";
15
15
  */
16
16
  export type WorkspaceKindSetting = "auto" | "cmux" | "tmux";
17
17
  export declare const WORKSPACE_KIND_SETTINGS: readonly WorkspaceKindSetting[];
18
+ /**
19
+ * Concrete local isolation backend selected for a launch. `safehouse` is
20
+ * macOS-only (clearance HTTP-egress + sandbox profile); `sdx` is Docker
21
+ * Sandboxes (`sbx` CLI) — works on Linux and macOS and is the only known
22
+ * option that lets the agent use Docker safely without exposing the host
23
+ * socket; `none` is an explicit unsandboxed escape hatch.
24
+ */
25
+ export type LocalRunner = "safehouse" | "sdx" | "none";
26
+ /**
27
+ * User-facing local runner setting. `auto` resolves at launch time:
28
+ * macOS picks `safehouse`, Linux picks `sdx`. `none` is never picked
29
+ * implicitly.
30
+ */
31
+ export type LocalRunnerSetting = LocalRunner | "auto";
32
+ export declare const LOCAL_RUNNER_SETTINGS: readonly LocalRunnerSetting[];
33
+ /**
34
+ * Per-model Docker Sandboxes (sdx) binding. Required at launch when
35
+ * `local.runner` resolves to `sdx` so groundcrew knows which sbx agent
36
+ * to address and how to seed the sandbox.
37
+ */
38
+ export interface SandboxDefinition {
39
+ /** sbx agent name (e.g. "claude", "codex"). */
40
+ agent: string;
41
+ /** Optional `sbx run --template` value. */
42
+ template?: string;
43
+ /** Optional `sbx run --kit` values (each passed as a separate flag). */
44
+ kits?: string[];
45
+ /**
46
+ * Setup command run **inside** the sandbox before the agent exec.
47
+ * Defaults to `DEFAULT_SANDBOX_SETUP_COMMAND` when omitted.
48
+ */
49
+ setupCommand?: string;
50
+ }
18
51
  export interface ModelDefinition {
19
52
  /**
20
53
  * Shell command launched for the model. Wrapped with Safehouse/clearance
@@ -32,6 +65,12 @@ export interface ModelDefinition {
32
65
  source?: string;
33
66
  };
34
67
  };
68
+ /**
69
+ * Docker Sandboxes binding. Required when `local.runner` resolves to
70
+ * `sdx` — pure additive: omitted models can still run under `safehouse`
71
+ * or `none` without surprise.
72
+ */
73
+ sandbox?: SandboxDefinition;
35
74
  }
36
75
  /**
37
76
  * User-facing model entry shape. Discriminated union so the type system
@@ -51,6 +90,14 @@ type UserModelDefinition = EnabledUserModelDefinition | DisabledUserModelDefinit
51
90
  * assumed to already have the right Node and npm versions.
52
91
  */
53
92
  export declare const DEFAULT_HOST_SETUP_COMMAND = "if [ -x .claude/setup.sh ]; then ./.claude/setup.sh --deps-only; elif [ -f .claude/setup.sh ] && command -v bash >/dev/null 2>&1; then bash .claude/setup.sh --deps-only; else npm clean-install; fi";
93
+ /**
94
+ * Setup command run inside an sdx (Docker Sandboxes) sandbox before the
95
+ * agent process exec. Independent of the host setup — sandboxes typically
96
+ * lack Node tooling on first start, so we keep the recipe scoped to the
97
+ * common case of an npm-managed repo while still letting per-model
98
+ * `sandbox.setupCommand` override it for languages outside that path.
99
+ */
100
+ export declare const DEFAULT_SANDBOX_SETUP_COMMAND = "if [ -x .claude/setup.sh ]; then ./.claude/setup.sh --deps-only; elif [ -f .claude/setup.sh ] && command -v bash >/dev/null 2>&1; then bash .claude/setup.sh --deps-only; else npm clean-install; fi";
54
101
  /**
55
102
  * Loose user-facing shape — what a `config.ts` file declares.
56
103
  * Fields with defaults are optional; only `linear.projectSlug` and the
@@ -107,6 +154,14 @@ export interface Config {
107
154
  * to fail loudly when the chosen backend is missing.
108
155
  */
109
156
  workspaceKind?: WorkspaceKindSetting;
157
+ /**
158
+ * Local isolation backend selector. Defaults to `"auto"` (macOS →
159
+ * safehouse, Linux → sdx). `"none"` is an explicit unsandboxed escape
160
+ * hatch — never selected implicitly.
161
+ */
162
+ local?: {
163
+ runner?: LocalRunnerSetting;
164
+ };
110
165
  logging?: {
111
166
  /**
112
167
  * Append-mode log file destination. `log()` and `logEvent()` tee here
@@ -158,6 +213,14 @@ export interface ResolvedConfig {
158
213
  * `auto` resolves to cmux when installed, else tmux.
159
214
  */
160
215
  workspaceKind: WorkspaceKindSetting;
216
+ /**
217
+ * Local isolation selection. The user-facing `auto` is preserved here
218
+ * so `localRunner.resolve()` can pick the platform default later — the
219
+ * resolver is the only place that knows the host capabilities.
220
+ */
221
+ local: {
222
+ runner: LocalRunnerSetting;
223
+ };
161
224
  logging: {
162
225
  file: string;
163
226
  };
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAEvD;;;;;GAKG;AACH,eAAO,MAAM,eAAe,QAAQ,CAAC;AAErC;;;;;;GAMG;AACH,MAAM,MAAM,oBAAoB,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAE5D,eAAO,MAAM,uBAAuB,EAAE,SAAS,oBAAoB,EAIzD,CAAC;AAEX,MAAM,WAAW,eAAe;IAC9B;;;;;;;OAOG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE;QACN,QAAQ,EAAE;YAAE,QAAQ,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;KACjD,CAAC;CACH;AAED;;;;;GAKG;AACH,KAAK,0BAA0B,GAAG,OAAO,CAAC,eAAe,CAAC,GAAG;IAAE,QAAQ,CAAC,EAAE,KAAK,CAAA;CAAE,CAAC;AAClF,UAAU,2BAA2B;IACnC,QAAQ,EAAE,IAAI,CAAC;CAChB;AACD,KAAK,mBAAmB,GAAG,0BAA0B,GAAG,2BAA2B,CAAC;AAEpF;;;GAGG;AACH,eAAO,MAAM,0BAA0B,yMACiK,CAAC;AAEzM;;;;GAIG;AACH,MAAM,WAAW,MAAM;IACrB,MAAM,EAAE;QACN;;;;;;;;WAQG;QACH,WAAW,EAAE,MAAM,CAAC;QACpB,QAAQ,CAAC,EAAE;YACT,IAAI,CAAC,EAAE,MAAM,CAAC;YACd,UAAU,CAAC,EAAE,MAAM,CAAC;YACpB,IAAI,CAAC,EAAE,MAAM,CAAC;YACd,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;SACrB,CAAC;KACH,CAAC;IACF,GAAG,CAAC,EAAE;QACJ,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,aAAa,CAAC,EAAE,MAAM,CAAC;KACxB,CAAC;IACF,SAAS,EAAE;QACT,UAAU,EAAE,MAAM,CAAC;QACnB,iBAAiB,EAAE,MAAM,EAAE,CAAC;KAC7B,CAAC;IACF,YAAY,CAAC,EAAE;QACb,iBAAiB,CAAC,EAAE,MAAM,CAAC;QAC3B,wBAAwB,CAAC,EAAE,MAAM,CAAC;QAClC,sBAAsB,CAAC,EAAE,MAAM,CAAC;KACjC,CAAC;IACF,MAAM,CAAC,EAAE;QACP,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB;;;;;WAKG;QACH,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC;KACnD,CAAC;IACF,OAAO,CAAC,EAAE;QACR,OAAO,CAAC,EAAE,MAAM,CAAC;KAClB,CAAC;IACF;;;;OAIG;IACH,aAAa,CAAC,EAAE,oBAAoB,CAAC;IACrC,OAAO,CAAC,EAAE;QACR;;;;;WAKG;QACH,IAAI,CAAC,EAAE,MAAM,CAAC;KACf,CAAC;CACH;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE;QACN,2EAA2E;QAC3E,WAAW,EAAE,MAAM,CAAC;QACpB,uEAAuE;QACvE,MAAM,EAAE,MAAM,CAAC;QACf,QAAQ,EAAE;YACR,IAAI,EAAE,MAAM,CAAC;YACb,UAAU,EAAE,MAAM,CAAC;YACnB,IAAI,EAAE,MAAM,CAAC;YACb,QAAQ,EAAE,MAAM,EAAE,CAAC;SACpB,CAAC;KACH,CAAC;IACF,GAAG,EAAE;QACH,MAAM,EAAE,MAAM,CAAC;QACf,aAAa,EAAE,MAAM,CAAC;KACvB,CAAC;IACF,SAAS,EAAE;QACT,UAAU,EAAE,MAAM,CAAC;QACnB,iBAAiB,EAAE,MAAM,EAAE,CAAC;KAC7B,CAAC;IACF,YAAY,EAAE;QACZ,iBAAiB,EAAE,MAAM,CAAC;QAC1B,wBAAwB,EAAE,MAAM,CAAC;QACjC,sBAAsB,EAAE,MAAM,CAAC;KAChC,CAAC;IACF,MAAM,EAAE;QACN,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;KAC9C,CAAC;IACF,OAAO,EAAE;QACP,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC;IACF;;;OAGG;IACH,aAAa,EAAE,oBAAoB,CAAC;IACpC,OAAO,EAAE;QACP,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;CACH;AAuPD;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,IAAI,CAAC,cAAc,EAAE,QAAQ,CAAC,EACtC,IAAI,EAAE,MAAM,GACX,OAAO,CAKT;AA6ND,wBAAsB,UAAU,IAAI,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC,CA8BpE"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"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"}
@@ -17,11 +17,25 @@ export const WORKSPACE_KIND_SETTINGS = [
17
17
  "cmux",
18
18
  "tmux",
19
19
  ];
20
+ export const LOCAL_RUNNER_SETTINGS = [
21
+ "auto",
22
+ "safehouse",
23
+ "sdx",
24
+ "none",
25
+ ];
20
26
  /**
21
27
  * Setup command run inside sibling worktrees on the host. The host is
22
28
  * assumed to already have the right Node and npm versions.
23
29
  */
24
30
  export const DEFAULT_HOST_SETUP_COMMAND = "if [ -x .claude/setup.sh ]; then ./.claude/setup.sh --deps-only; elif [ -f .claude/setup.sh ] && command -v bash >/dev/null 2>&1; then bash .claude/setup.sh --deps-only; else npm clean-install; fi";
31
+ /**
32
+ * Setup command run inside an sdx (Docker Sandboxes) sandbox before the
33
+ * agent process exec. Independent of the host setup — sandboxes typically
34
+ * lack Node tooling on first start, so we keep the recipe scoped to the
35
+ * common case of an npm-managed repo while still letting per-model
36
+ * `sandbox.setupCommand` override it for languages outside that path.
37
+ */
38
+ export const DEFAULT_SANDBOX_SETUP_COMMAND = "if [ -x .claude/setup.sh ]; then ./.claude/setup.sh --deps-only; elif [ -f .claude/setup.sh ] && command -v bash >/dev/null 2>&1; then bash .claude/setup.sh --deps-only; else npm clean-install; fi";
25
39
  const DEFAULT_STATUSES = {
26
40
  todo: "Todo",
27
41
  inProgress: "In Progress",
@@ -194,6 +208,43 @@ function normalizeWorkspaceKind(value, path) {
194
208
  }
195
209
  return value;
196
210
  }
211
+ function isLocalRunnerSetting(value) {
212
+ return typeof value === "string" && LOCAL_RUNNER_SETTINGS.includes(value);
213
+ }
214
+ function normalizeLocalRunner(value, path) {
215
+ if (value === undefined) {
216
+ return undefined;
217
+ }
218
+ if (!isLocalRunnerSetting(value)) {
219
+ fail(`${path} must be one of ${LOCAL_RUNNER_SETTINGS.join(", ")} (got ${JSON.stringify(value)})`);
220
+ }
221
+ return value;
222
+ }
223
+ function normalizeSandbox(value, path) {
224
+ if (!isPlainObject(value)) {
225
+ fail(`${path} must be an object`);
226
+ }
227
+ const { agent, template, kits, setupCommand } = value;
228
+ requireString(agent, `${path}.agent`);
229
+ const trimmedAgent = agent.trim();
230
+ if (trimmedAgent.length === 0) {
231
+ fail(`${path}.agent must be a non-empty string (got ${JSON.stringify(agent)})`);
232
+ }
233
+ const sandbox = { agent: trimmedAgent };
234
+ const normalizedTemplate = normalizeOptionalString(template, `${path}.template`);
235
+ if (normalizedTemplate !== undefined) {
236
+ sandbox.template = normalizedTemplate;
237
+ }
238
+ const normalizedKits = normalizeOptionalStringArray(kits, `${path}.kits`);
239
+ if (normalizedKits !== undefined) {
240
+ sandbox.kits = normalizedKits;
241
+ }
242
+ const normalizedSetup = normalizeOptionalString(setupCommand, `${path}.setupCommand`);
243
+ if (normalizedSetup !== undefined) {
244
+ sandbox.setupCommand = normalizedSetup;
245
+ }
246
+ return sandbox;
247
+ }
197
248
  function failIfLegacyModelKeys(name, override) {
198
249
  if (!isPlainObject(override)) {
199
250
  fail(`models.definitions.${name} must be an object`);
@@ -201,14 +252,11 @@ function failIfLegacyModelKeys(name, override) {
201
252
  if (Object.hasOwn(override, "isolation")) {
202
253
  fail(`models.definitions.${name}.isolation is no longer supported: per-model isolation is no longer supported`);
203
254
  }
204
- if (Object.hasOwn(override, "sandbox")) {
205
- fail(`models.definitions.${name}.sandbox is no longer supported: Docker Sandboxes are no longer supported`);
206
- }
207
255
  if (Object.hasOwn(override, "disabled")) {
208
256
  if (override["disabled"] !== true) {
209
257
  fail(`models.definitions.${name}.disabled must be exactly \`true\` when set (got ${JSON.stringify(override["disabled"])})`);
210
258
  }
211
- const conflicting = ["cmd", "color", "usage"].filter((key) => Object.hasOwn(override, key));
259
+ const conflicting = ["cmd", "color", "usage", "sandbox"].filter((key) => Object.hasOwn(override, key));
212
260
  if (conflicting.length > 0) {
213
261
  fail(`models.definitions.${name}: cannot combine \`disabled: true\` with other fields (${conflicting.join(", ")}). Either disable the model or override its fields, not both.`);
214
262
  }
@@ -258,7 +306,10 @@ function mergeDefinitions(user) {
258
306
  if (override.usage !== undefined) {
259
307
  candidate.usage = override.usage;
260
308
  }
261
- const { cmd, color, usage } = candidate;
309
+ if (override.sandbox !== undefined) {
310
+ candidate.sandbox = normalizeSandbox(override.sandbox, `models.definitions.${name}.sandbox`);
311
+ }
312
+ const { cmd, color, usage, sandbox } = candidate;
262
313
  if (typeof cmd !== "string" || cmd.length === 0) {
263
314
  fail(`models.definitions.${name}.cmd must be a non-empty string`);
264
315
  }
@@ -269,6 +320,9 @@ function mergeDefinitions(user) {
269
320
  if (usage !== undefined) {
270
321
  definition.usage = usage;
271
322
  }
323
+ if (sandbox !== undefined) {
324
+ definition.sandbox = sandbox;
325
+ }
272
326
  merged[name] = definition;
273
327
  }
274
328
  return merged;
@@ -294,10 +348,14 @@ function applyDefaults(user) {
294
348
  requireString(user.linear.projectSlug, "linear.projectSlug");
295
349
  requireObject(user.workspace, "workspace");
296
350
  if (isPlainObject(user.models) && Object.hasOwn(user.models, "isolation")) {
297
- fail("models.isolation is no longer supported: local isolation is always Safehouse; remove this key");
351
+ fail("models.isolation is no longer supported: set `local.runner` ('safehouse' | 'sdx' | 'none' | 'auto') instead");
298
352
  }
299
353
  if (Object.hasOwn(user, "remote")) {
300
- fail("remote is no longer supported: groundcrew is macOS + Safehouse only; remove the remote block from your config");
354
+ fail("remote is no longer supported: groundcrew runs locally via safehouse/sdx/none; remove the remote block from your config");
355
+ }
356
+ const userLocal = user.local;
357
+ if (userLocal !== undefined && !isPlainObject(userLocal)) {
358
+ fail("local must be an object");
301
359
  }
302
360
  const slugId = extractSlugId(user.linear.projectSlug);
303
361
  if (slugId === undefined) {
@@ -323,6 +381,9 @@ function applyDefaults(user) {
323
381
  initial: user.prompts?.initial ?? DEFAULT_PROMPT_INITIAL,
324
382
  },
325
383
  workspaceKind: normalizeWorkspaceKind(user.workspaceKind, "workspaceKind") ?? "auto",
384
+ local: {
385
+ runner: normalizeLocalRunner(userLocal?.runner, "local.runner") ?? "auto",
386
+ },
326
387
  logging: {
327
388
  file: expandHome(normalizeOptionalString(user.logging?.file, "logging.file") ?? defaultLogFile()),
328
389
  },
@@ -379,6 +440,13 @@ function validate(config) {
379
440
  }
380
441
  requireString(codexbar.provider, `${usagePath}.codexbar.provider`);
381
442
  }
443
+ if (definition.sandbox !== undefined) {
444
+ requireString(definition.sandbox.agent, `models.definitions.${name}.sandbox.agent`);
445
+ }
446
+ }
447
+ /* v8 ignore next 5 @preserve -- normalizeLocalRunner rejects invalid strings before validate() runs; this is a belt-and-suspenders guard */
448
+ if (!LOCAL_RUNNER_SETTINGS.includes(config.local.runner)) {
449
+ fail(`local.runner must be one of ${LOCAL_RUNNER_SETTINGS.join(", ")} (got ${JSON.stringify(config.local.runner)})`);
382
450
  }
383
451
  // Disabled-default check must run before the generic "not a key" check so
384
452
  // the user gets the specific "is disabled" message instead of a stale-list
@@ -0,0 +1,40 @@
1
+ import type { SandboxDefinition } from "./config.ts";
2
+ /**
3
+ * Derive a deterministic sbx sandbox name from the repository + model
4
+ * tuple so `crew sandbox auth <repo>` and the subsequent `crew local`
5
+ * launch agree on which sandbox to target. Lowercased and reduced to the
6
+ * sbx-safe charset (`a-z0-9.+-`) so unusual repo names still round-trip
7
+ * cleanly. Keep the prefix stable — doctor and teardown use it to
8
+ * identify groundcrew-owned sandboxes.
9
+ */
10
+ export declare function sandboxNameFor(arguments_: {
11
+ repository: string;
12
+ model: string;
13
+ }): string;
14
+ /**
15
+ * Probe `sbx ls` to see whether a sandbox with `sandboxName` already
16
+ * exists. Used by `crew sandbox auth` to switch between create vs reuse
17
+ * branches without surfacing the raw sbx error on first run.
18
+ */
19
+ export declare function sandboxExists(sandboxName: string, signal?: AbortSignal): Promise<boolean>;
20
+ interface EnsureSandboxArguments {
21
+ sandboxName: string;
22
+ sandbox: SandboxDefinition;
23
+ /**
24
+ * Host path bound into the sandbox at the same path. Pass the workspace
25
+ * `projectDir` so all per-ticket worktrees (siblings of the bare repo
26
+ * clone) are visible to `sbx exec -w <worktreeDir>` after creation.
27
+ */
28
+ mountPath: string;
29
+ }
30
+ /**
31
+ * Idempotent guard: ensure a Docker Sandboxes container exists for the
32
+ * given repository + model. Probes `sbx ls`; if `sandboxName` is missing,
33
+ * calls `sbx create --name <name> [--template <t>] [--kit <k>]... <agent>
34
+ * <mountPath>` to provision it. First-time agent auth still happens inside
35
+ * the sandbox the first time `sbx exec` runs the agent — `create` only
36
+ * provisions the container, it does not attach.
37
+ */
38
+ export declare function ensureSandbox(arguments_: EnsureSandboxArguments, signal?: AbortSignal): Promise<void>;
39
+ export {};
40
+ //# sourceMappingURL=dockerSandbox.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dockerSandbox.d.ts","sourceRoot":"","sources":["../../src/lib/dockerSandbox.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAErD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,UAAU,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAMxF;AAED;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,CAM/F;AAED,UAAU,sBAAsB;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,iBAAiB,CAAC;IAC3B;;;;OAIG;IACH,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;GAOG;AACH,wBAAsB,aAAa,CACjC,UAAU,EAAE,sBAAsB,EAClC,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,IAAI,CAAC,CAqBf"}
@@ -0,0 +1,58 @@
1
+ import { runCommandAsync } from "./commandRunner.js";
2
+ /**
3
+ * Derive a deterministic sbx sandbox name from the repository + model
4
+ * tuple so `crew sandbox auth <repo>` and the subsequent `crew local`
5
+ * launch agree on which sandbox to target. Lowercased and reduced to the
6
+ * sbx-safe charset (`a-z0-9.+-`) so unusual repo names still round-trip
7
+ * cleanly. Keep the prefix stable — doctor and teardown use it to
8
+ * identify groundcrew-owned sandboxes.
9
+ */
10
+ export function sandboxNameFor(arguments_) {
11
+ const raw = `groundcrew-${arguments_.repository}-${arguments_.model}`.toLowerCase();
12
+ return raw
13
+ .replaceAll(/[^a-z0-9.+-]+/g, "-")
14
+ .replaceAll(/-+/g, "-")
15
+ .replaceAll(/^-|-$/g, "");
16
+ }
17
+ /**
18
+ * Probe `sbx ls` to see whether a sandbox with `sandboxName` already
19
+ * exists. Used by `crew sandbox auth` to switch between create vs reuse
20
+ * branches without surfacing the raw sbx error on first run.
21
+ */
22
+ export async function sandboxExists(sandboxName, signal) {
23
+ const out = signal === undefined
24
+ ? await runCommandAsync("sbx", ["ls"])
25
+ : await runCommandAsync("sbx", ["ls"], { signal });
26
+ return out.split("\n").some((line) => line.trim().split(/\s+/)[0] === sandboxName);
27
+ }
28
+ /**
29
+ * Idempotent guard: ensure a Docker Sandboxes container exists for the
30
+ * given repository + model. Probes `sbx ls`; if `sandboxName` is missing,
31
+ * calls `sbx create --name <name> [--template <t>] [--kit <k>]... <agent>
32
+ * <mountPath>` to provision it. First-time agent auth still happens inside
33
+ * the sandbox the first time `sbx exec` runs the agent — `create` only
34
+ * provisions the container, it does not attach.
35
+ */
36
+ export async function ensureSandbox(arguments_, signal) {
37
+ if (await sandboxExists(arguments_.sandboxName, signal)) {
38
+ return;
39
+ }
40
+ const createArguments = ["create", "--name", arguments_.sandboxName];
41
+ if (arguments_.sandbox.template !== undefined) {
42
+ createArguments.push("--template", arguments_.sandbox.template);
43
+ }
44
+ for (const kit of arguments_.sandbox.kits ?? []) {
45
+ createArguments.push("--kit", kit);
46
+ }
47
+ createArguments.push(arguments_.sandbox.agent, arguments_.mountPath);
48
+ const options = signal === undefined ? {} : { signal };
49
+ try {
50
+ await runCommandAsync("sbx", createArguments, options);
51
+ }
52
+ catch (error) {
53
+ if (await sandboxExists(arguments_.sandboxName, signal)) {
54
+ return;
55
+ }
56
+ throw error;
57
+ }
58
+ }
@@ -6,18 +6,28 @@
6
6
  export interface HostCapabilities {
7
7
  /** True when the `safehouse` binary is on PATH. */
8
8
  hasSafehouse: boolean;
9
+ /** True when the `sbx` (Docker Sandboxes) binary is on PATH. */
10
+ hasSbx: boolean;
9
11
  /** True when the `cmux` binary is on PATH. */
10
12
  hasCmux: boolean;
11
13
  /** True when the `tmux` binary is on PATH. */
12
14
  hasTmux: boolean;
13
15
  /** True when the host platform is macOS. Safehouse is macOS-only. */
14
16
  isMacOS: boolean;
17
+ /** True when the host platform is Linux. */
18
+ isLinux: boolean;
15
19
  /**
16
20
  * True when the host platform is one Safehouse supports. Safehouse is
17
21
  * macOS-only at time of writing; local setup uses this to reject Linux
18
22
  * or WSL before creating a worktree.
19
23
  */
20
24
  isSafehouseSupported: boolean;
25
+ /**
26
+ * True when sdx (Docker Sandboxes) is supportable on this platform —
27
+ * sbx is published for both macOS and Linux, so this stays in sync with
28
+ * "macOS || Linux". WSL inherits Linux capabilities transparently.
29
+ */
30
+ isSdxSupported: boolean;
21
31
  }
22
32
  /**
23
33
  * Resolves a binary on PATH the same way `which` does. Returns the first
@@ -1 +1 @@
1
- {"version":3,"file":"host.d.ts","sourceRoot":"","sources":["../../src/lib/host.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAMH,MAAM,WAAW,gBAAgB;IAC/B,mDAAmD;IACnD,YAAY,EAAE,OAAO,CAAC;IACtB,8CAA8C;IAC9C,OAAO,EAAE,OAAO,CAAC;IACjB,8CAA8C;IAC9C,OAAO,EAAE,OAAO,CAAC;IACjB,qEAAqE;IACrE,OAAO,EAAE,OAAO,CAAC;IACjB;;;;OAIG;IACH,oBAAoB,EAAE,OAAO,CAAC;CAC/B;AAED;;;;GAIG;AACH,wBAAsB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAc1F;AAED,wBAAsB,sBAAsB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAc5F"}
1
+ {"version":3,"file":"host.d.ts","sourceRoot":"","sources":["../../src/lib/host.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAMH,MAAM,WAAW,gBAAgB;IAC/B,mDAAmD;IACnD,YAAY,EAAE,OAAO,CAAC;IACtB,gEAAgE;IAChE,MAAM,EAAE,OAAO,CAAC;IAChB,8CAA8C;IAC9C,OAAO,EAAE,OAAO,CAAC;IACjB,8CAA8C;IAC9C,OAAO,EAAE,OAAO,CAAC;IACjB,qEAAqE;IACrE,OAAO,EAAE,OAAO,CAAC;IACjB,4CAA4C;IAC5C,OAAO,EAAE,OAAO,CAAC;IACjB;;;;OAIG;IACH,oBAAoB,EAAE,OAAO,CAAC;IAC9B;;;;OAIG;IACH,cAAc,EAAE,OAAO,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAsB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAc1F;AAED,wBAAsB,sBAAsB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAmB5F"}
package/dist/lib/host.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * current machine. Doctor and setup inject a capabilities object directly
4
4
  * so tests don't have to mock `which`.
5
5
  */
6
- import { platform } from "node:process";
6
+ import process from "node:process";
7
7
  import { runCommandAsync } from "./commandRunner.js";
8
8
  /**
9
9
  * Resolves a binary on PATH the same way `which` does. Returns the first
@@ -26,17 +26,22 @@ export async function which(cmd, signal) {
26
26
  }
27
27
  }
28
28
  export async function detectHostCapabilities(signal) {
29
- const isMacOS = platform === "darwin";
30
- const [safehouse, cmux, tmux] = await Promise.all([
29
+ const isMacOS = process.platform === "darwin";
30
+ const isLinux = process.platform === "linux";
31
+ const [safehouse, sbx, cmux, tmux] = await Promise.all([
31
32
  which("safehouse", signal),
33
+ which("sbx", signal),
32
34
  which("cmux", signal),
33
35
  which("tmux", signal),
34
36
  ]);
35
37
  return {
36
38
  hasSafehouse: safehouse !== undefined,
39
+ hasSbx: sbx !== undefined,
37
40
  hasCmux: cmux !== undefined,
38
41
  hasTmux: tmux !== undefined,
39
42
  isMacOS,
43
+ isLinux,
40
44
  isSafehouseSupported: isMacOS,
45
+ isSdxSupported: isMacOS || isLinux,
41
46
  };
42
47
  }
@@ -1,4 +1,4 @@
1
- import { type ModelDefinition } from "./config.ts";
1
+ import { type LocalRunner, type ModelDefinition } from "./config.ts";
2
2
  export { shellSingleQuote } from "./shell.ts";
3
3
  /**
4
4
  * Resolve the shipped Safehouse proxy wrapper inside `@clipboard-health/clearance`
@@ -18,10 +18,24 @@ interface LaunchCommandArguments {
18
18
  /**
19
19
  * Optional path to a `KEY='value'` env file containing build-time
20
20
  * secrets (see `BUILD_SECRET_NAMES`). Sourced on the host shell before
21
- * setup and always unset before exec'ing the agent so the agent process
22
- * never inherits them.
21
+ * setup; for the sdx runner the names are propagated into the sandbox
22
+ * via `sbx exec -e KEY`. Always unset before exec'ing the agent so the
23
+ * agent process never inherits them.
23
24
  */
24
25
  secretsFile?: string | undefined;
26
+ /**
27
+ * Concrete local isolation backend chosen for this launch. Resolved
28
+ * from `config.local.runner` via `resolveLocalRunner` before this
29
+ * function is called — `auto` is never seen here.
30
+ */
31
+ runner: LocalRunner;
32
+ /**
33
+ * sbx sandbox name when `runner === "sdx"`. Derived by the caller from
34
+ * `sandboxNameFor({ repository, model })`. Required for sdx; ignored
35
+ * otherwise. Kept off the model definition so a model can launch under
36
+ * safehouse on one host and sdx on another without config edits.
37
+ */
38
+ sandboxName?: string | undefined;
25
39
  }
26
40
  /**
27
41
  * Build the shell command that runs inside the workspace. The prompt is
@@ -1 +1 @@
1
- {"version":3,"file":"launchCommand.d.ts","sourceRoot":"","sources":["../../src/lib/launchCommand.ts"],"names":[],"mappings":"AAGA,OAAO,EAAkD,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAGnG,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;;;;;GASG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,GAAE,MAAwB,GAAG,MAAM,CAcvF;AA+BD,UAAU,sBAAsB;IAC9B,UAAU,EAAE,eAAe,CAAC;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAClC;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,sBAAsB,GAAG,MAAM,CA2B7E"}
1
+ {"version":3,"file":"launchCommand.d.ts","sourceRoot":"","sources":["../../src/lib/launchCommand.ts"],"names":[],"mappings":"AAGA,OAAO,EAIL,KAAK,WAAW,EAChB,KAAK,eAAe,EACrB,MAAM,aAAa,CAAC;AAGrB,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;;;;;GASG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,GAAE,MAAwB,GAAG,MAAM,CAcvF;AAmCD,UAAU,sBAAsB;IAC9B,UAAU,EAAE,eAAe,CAAC;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC;;;;OAIG;IACH,MAAM,EAAE,WAAW,CAAC;IACpB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAClC;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,sBAAsB,GAAG,MAAM,CAK7E"}
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { dirname, resolve } from "node:path";
3
- import { BUILD_SECRET_NAMES, DEFAULT_HOST_SETUP_COMMAND } from "./config.js";
3
+ import { BUILD_SECRET_NAMES, DEFAULT_HOST_SETUP_COMMAND, DEFAULT_SANDBOX_SETUP_COMMAND, } from "./config.js";
4
4
  import { shellSingleQuote } from "./shell.js";
5
5
  export { shellSingleQuote } from "./shell.js";
6
6
  /**
@@ -28,7 +28,7 @@ const SAFEHOUSE_CLEARANCE_WRAPPER_PATH = resolveSafehouseClearancePath();
28
28
  function renderAgentCommand(arguments_) {
29
29
  return arguments_.agentCmd
30
30
  .replaceAll("{{worktree}}", shellSingleQuote(arguments_.worktreeDir))
31
- .replaceAll("{{sandbox}}", shellSingleQuote(""));
31
+ .replaceAll("{{sandbox}}", shellSingleQuote(arguments_.sandboxName));
32
32
  }
33
33
  function setupWithStatusReporting(setupCommand) {
34
34
  return [
@@ -57,17 +57,23 @@ function unsetSecretsLine() {
57
57
  * prompt in hand.
58
58
  */
59
59
  export function buildLaunchCommand(arguments_) {
60
+ if (arguments_.runner === "sdx") {
61
+ return buildSdxLaunchCommand(arguments_);
62
+ }
63
+ return buildHostLaunchCommand(arguments_);
64
+ }
65
+ function buildHostLaunchCommand(arguments_) {
60
66
  const promptDir = dirname(arguments_.promptFile);
61
67
  const agentCmd = renderAgentCommand({
62
68
  agentCmd: arguments_.definition.cmd,
63
69
  worktreeDir: arguments_.worktreeDir,
70
+ sandboxName: "",
71
+ });
72
+ const wrapped = wrapAgentForHostRunner({
73
+ runner: arguments_.runner,
74
+ rawCmd: arguments_.definition.cmd,
75
+ agentCmd,
64
76
  });
65
- // Skip the wrap if `cmd` already starts with `safehouse` so legacy
66
- // configs don't double-wrap.
67
- const cmdStartsWithSafehouse = /^safehouse(\s|$)/.test(arguments_.definition.cmd);
68
- const wrapped = cmdStartsWithSafehouse
69
- ? agentCmd
70
- : [shellSingleQuote(SAFEHOUSE_CLEARANCE_WRAPPER_PATH), agentCmd].join(" ");
71
77
  const lines = [`cd ${shellSingleQuote(arguments_.worktreeDir)}`];
72
78
  if (arguments_.secretsFile !== undefined) {
73
79
  lines.push(sourceSecretsLine(arguments_.secretsFile));
@@ -79,3 +85,55 @@ export function buildLaunchCommand(arguments_) {
79
85
  lines.push(`_p=$(cat ${shellSingleQuote(arguments_.promptFile)})`, `rm -rf ${shellSingleQuote(promptDir)}`, `exec ${wrapped} "$_p"`);
80
86
  return lines.join(" && ");
81
87
  }
88
+ function wrapAgentForHostRunner(arguments_) {
89
+ if (arguments_.runner === "none") {
90
+ return arguments_.agentCmd;
91
+ }
92
+ // buildLaunchCommand routes `sdx` through buildSdxLaunchCommand, so the
93
+ // only remaining shape here is `safehouse`. Treat the explicit branch as
94
+ // the safehouse wrap to keep this function readable; the `sdx` arm exists
95
+ // only to satisfy TS's exhaustiveness checker.
96
+ /* v8 ignore next 3 @preserve -- buildLaunchCommand short-circuits sdx before calling this helper */
97
+ if (arguments_.runner === "sdx") {
98
+ return arguments_.agentCmd;
99
+ }
100
+ // safehouse: skip the wrap if `cmd` already starts with `safehouse` so
101
+ // legacy configs don't double-wrap.
102
+ const cmdStartsWithSafehouse = /^safehouse(\s|$)/.test(arguments_.rawCmd);
103
+ if (cmdStartsWithSafehouse) {
104
+ return arguments_.agentCmd;
105
+ }
106
+ return [shellSingleQuote(SAFEHOUSE_CLEARANCE_WRAPPER_PATH), arguments_.agentCmd].join(" ");
107
+ }
108
+ function buildSdxLaunchCommand(arguments_) {
109
+ /* v8 ignore next 5 @preserve -- setupWorkspace passes sandboxName + sandbox config when picking sdx; missing fields are programmer errors */
110
+ if (arguments_.sandboxName === undefined || arguments_.definition.sandbox === undefined) {
111
+ throw new Error("buildLaunchCommand: runner='sdx' requires sandboxName and a model `sandbox` config block (set sandbox.agent on the model in config.ts).");
112
+ }
113
+ const promptDir = dirname(arguments_.promptFile);
114
+ const agentCmd = renderAgentCommand({
115
+ agentCmd: arguments_.definition.cmd,
116
+ worktreeDir: arguments_.worktreeDir,
117
+ sandboxName: arguments_.sandboxName,
118
+ });
119
+ const setupCommand = arguments_.definition.sandbox.setupCommand ?? DEFAULT_SANDBOX_SETUP_COMMAND;
120
+ const innerParts = [setupWithStatusReporting(setupCommand)];
121
+ if (arguments_.secretsFile !== undefined) {
122
+ innerParts.push(unsetSecretsLine());
123
+ }
124
+ innerParts.push(`exec ${agentCmd} "$@"`);
125
+ const innerCommand = innerParts.join("; ");
126
+ // Passthrough form (`-e KEY` without `=VALUE`): sbx reads each value
127
+ // from its own env at invocation time — populated by sourceSecretsLine
128
+ // a few lines up. Avoids `-e KEY="$KEY"`, which would embed the value
129
+ // in argv and break on `"`, `$`, or backticks in the token.
130
+ const sbxEnvironmentFlags = arguments_.secretsFile === undefined
131
+ ? ""
132
+ : `${BUILD_SECRET_NAMES.map((name) => `-e ${name}`).join(" ")} `;
133
+ const lines = [];
134
+ if (arguments_.secretsFile !== undefined) {
135
+ lines.push(sourceSecretsLine(arguments_.secretsFile));
136
+ }
137
+ lines.push(`_p=$(cat ${shellSingleQuote(arguments_.promptFile)})`, `rm -rf ${shellSingleQuote(promptDir)}`, `exec sbx exec -it ${sbxEnvironmentFlags}-w ${shellSingleQuote(arguments_.worktreeDir)} ${shellSingleQuote(arguments_.sandboxName)} sh -lc ${shellSingleQuote(innerCommand)} sh "$_p"`);
138
+ return lines.join(" && ");
139
+ }
@@ -1,3 +1,24 @@
1
+ import type { LocalRunner, LocalRunnerSetting } from "./config.ts";
1
2
  import type { HostCapabilities } from "./host.ts";
2
- export declare function assertLocalRunnerRequirements(host: HostCapabilities): void;
3
+ /**
4
+ * Resolve `local.runner` from config + host capabilities into a concrete
5
+ * backend. `auto` defaults to safehouse on macOS and sdx on Linux — both
6
+ * are the deny-first paths for their platform. `none` and the explicit
7
+ * names pass through unchanged so users always get exactly what they
8
+ * asked for. Pure: takes everything it needs as arguments so the
9
+ * dispatcher can test platform pivots without touching real hosts.
10
+ */
11
+ export declare function resolveLocalRunner(setting: LocalRunnerSetting, host: HostCapabilities): LocalRunner;
12
+ /**
13
+ * Verify the host can run the chosen local isolation backend before we
14
+ * create a worktree. The runner has already been resolved from
15
+ * `config.local.runner` (via `resolveLocalRunner`), so `auto` never gets
16
+ * here — the caller passes `safehouse`, `sdx`, or `none`.
17
+ *
18
+ * `none` is a deliberately unsafe escape hatch. It is never selected
19
+ * implicitly (`auto` picks `safehouse`/`sdx`); when the user has set it
20
+ * explicitly, this helper logs a single warning so the unsandboxed launch
21
+ * is visible in groundcrew's log, but does not throw.
22
+ */
23
+ export declare function assertLocalRunnerRequirements(host: HostCapabilities, runner: LocalRunner): void;
3
24
  //# sourceMappingURL=localRunner.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"localRunner.d.ts","sourceRoot":"","sources":["../../src/lib/localRunner.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAElD,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,gBAAgB,GAAG,IAAI,CAS1E"}
1
+ {"version":3,"file":"localRunner.d.ts","sourceRoot":"","sources":["../../src/lib/localRunner.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACnE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAGlD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,kBAAkB,EAC3B,IAAI,EAAE,gBAAgB,GACrB,WAAW,CAQb;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,gBAAgB,EAAE,MAAM,EAAE,WAAW,GAAG,IAAI,CA6B/F"}
@@ -1,8 +1,51 @@
1
- export function assertLocalRunnerRequirements(host) {
2
- if (!host.isSafehouseSupported) {
3
- throw new Error("groundcrew runs require macOS with Safehouse. Linux/WSL is not supported.");
1
+ import { log } from "./util.js";
2
+ /**
3
+ * Resolve `local.runner` from config + host capabilities into a concrete
4
+ * backend. `auto` defaults to safehouse on macOS and sdx on Linux — both
5
+ * are the deny-first paths for their platform. `none` and the explicit
6
+ * names pass through unchanged so users always get exactly what they
7
+ * asked for. Pure: takes everything it needs as arguments so the
8
+ * dispatcher can test platform pivots without touching real hosts.
9
+ */
10
+ export function resolveLocalRunner(setting, host) {
11
+ if (setting !== "auto") {
12
+ return setting;
4
13
  }
5
- if (!host.hasSafehouse) {
6
- throw new Error("groundcrew runs require `safehouse` on PATH. Install Safehouse from https://agent-safehouse.dev/ and retry.");
14
+ // macOS → safehouse; everything else (Linux/WSL, exotic platforms) → sdx.
15
+ // `assertLocalRunnerRequirements` then enforces sdx's platform/binary
16
+ // preconditions and surfaces a precise error on truly unsupported hosts.
17
+ return host.isMacOS ? "safehouse" : "sdx";
18
+ }
19
+ /**
20
+ * Verify the host can run the chosen local isolation backend before we
21
+ * create a worktree. The runner has already been resolved from
22
+ * `config.local.runner` (via `resolveLocalRunner`), so `auto` never gets
23
+ * here — the caller passes `safehouse`, `sdx`, or `none`.
24
+ *
25
+ * `none` is a deliberately unsafe escape hatch. It is never selected
26
+ * implicitly (`auto` picks `safehouse`/`sdx`); when the user has set it
27
+ * explicitly, this helper logs a single warning so the unsandboxed launch
28
+ * is visible in groundcrew's log, but does not throw.
29
+ */
30
+ export function assertLocalRunnerRequirements(host, runner) {
31
+ if (runner === "safehouse") {
32
+ if (!host.isSafehouseSupported) {
33
+ throw new Error("Local groundcrew runs with the safehouse runner require macOS. On Linux/WSL, set local.runner to 'sdx' (default) or 'auto'.");
34
+ }
35
+ if (!host.hasSafehouse) {
36
+ throw new Error("Local groundcrew runs require `safehouse` on PATH. Install Safehouse from https://agent-safehouse.dev/ and retry.");
37
+ }
38
+ return;
39
+ }
40
+ if (runner === "sdx") {
41
+ if (!host.isSdxSupported) {
42
+ throw new Error("Local groundcrew runs with the sdx runner require macOS or Linux.");
43
+ }
44
+ if (!host.hasSbx) {
45
+ throw new Error("Local groundcrew runs with the sdx runner require `sbx` (Docker Sandboxes) on PATH. Install from https://docs.docker.com/sandboxes/ and retry.");
46
+ }
47
+ return;
7
48
  }
49
+ // runner === "none"
50
+ log("WARNING: local.runner='none' — agent process will run on the host without sandboxing. Only use this when you understand the implications.");
8
51
  }
@@ -1 +1 @@
1
- {"version":3,"file":"workspaces.d.ts","sourceRoot":"","sources":["../../src/lib/workspaces.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACxE,OAAO,EAA0B,KAAK,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAG1E,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,MAAM,CAAC;AAE5C,MAAM,WAAW,SAAS;IACxB,2CAA2C;IAC3C,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,eAAe,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,QAAQ;IACvB,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,GAAG,EAAE,MAAM,CAAC;IACZ,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AA+L7C,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,oBAAoB,CAAC;IAChC,QAAQ,EAAE,aAAa,CAAC;IACxB,yDAAyD;IACzD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,UAAU,gBAAgB;IACxB,MAAM,EAAE,cAAc,CAAC;IACvB,IAAI,EAAE,gBAAgB,CAAC;CACxB;AAED,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,gBAAgB,GAAG,mBAAmB,CAUtF;AA+ND,iBAAe,eAAe,CAC5B,MAAM,EAAE,cAAc,EACtB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,cAAc,CAAC,CAezB;AAED,eAAO,MAAM,UAAU;IACf,IAAI,SAAS,cAAc,QAAQ,QAAQ,WAAW,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvF,KAAK;IACC,KAAK,SAAS,cAAc,QAAQ,MAAM,WAAW,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAIhF,UAAU,SACN,cAAc,QAChB,MAAM,WACH,WAAW,GACnB,OAAO,CAAC,mBAAmB,GAAG,SAAS,CAAC;CAI5C,CAAC"}
1
+ {"version":3,"file":"workspaces.d.ts","sourceRoot":"","sources":["../../src/lib/workspaces.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACxE,OAAO,EAA0B,KAAK,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAI1E,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,MAAM,CAAC;AAE5C,MAAM,WAAW,SAAS;IACxB,2CAA2C;IAC3C,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,eAAe,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,QAAQ;IACvB,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,GAAG,EAAE,MAAM,CAAC;IACZ,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAgU7C,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,oBAAoB,CAAC;IAChC,QAAQ,EAAE,aAAa,CAAC;IACxB,yDAAyD;IACzD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,UAAU,gBAAgB;IACxB,MAAM,EAAE,cAAc,CAAC;IACvB,IAAI,EAAE,gBAAgB,CAAC;CACxB;AAED,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,gBAAgB,GAAG,mBAAmB,CAUtF;AA+ND,iBAAe,eAAe,CAC5B,MAAM,EAAE,cAAc,EACtB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,cAAc,CAAC,CAezB;AAED,eAAO,MAAM,UAAU;IACf,IAAI,SAAS,cAAc,QAAQ,QAAQ,WAAW,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvF,KAAK;IACC,KAAK,SAAS,cAAc,QAAQ,MAAM,WAAW,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAIhF,UAAU,SACN,cAAc,QAChB,MAAM,WACH,WAAW,GACnB,OAAO,CAAC,mBAAmB,GAAG,SAAS,CAAC;CAI5C,CAAC"}
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { runCommandAsync } from "./commandRunner.js";
8
8
  import { detectHostCapabilities } from "./host.js";
9
+ import { shellSingleQuote } from "./shell.js";
9
10
  import { errorMessage, log, readEnvironmentVariable } from "./util.js";
10
11
  async function runWorkspaceCommand(command, arguments_, signal) {
11
12
  return signal === undefined
@@ -24,23 +25,30 @@ function parseCmuxList(output) {
24
25
  if (typeof ws.title !== "string" || ws.title.length === 0) {
25
26
  continue;
26
27
  }
27
- items.push({ title: ws.title, ref: pickCmuxRef({ ...ws, title: ws.title }) });
28
+ const id = pickCmuxId(ws);
29
+ if (id === undefined) {
30
+ log(`cmux list-workspaces returned workspace "${ws.title}" without a usable id or ref; skipping`);
31
+ continue;
32
+ }
33
+ items.push({ title: ws.title, id });
28
34
  }
29
35
  return items;
30
36
  }
31
37
  /**
32
- * Pick the most-specific identifier cmux returned for this workspace.
33
- * Caller has already verified `title` is non-empty, so the title fallback
34
- * is always defined.
38
+ * The stable workspace handle cmux v2 expects in JSON-RPC params. Prefer
39
+ * the UUID; fall back to the legacy `workspace:N` short ref when older
40
+ * cmux builds don't surface it. Returns `undefined` when neither is
41
+ * available — cmux v2 `workspace.close` rejects titles, so we must never
42
+ * forward `title` as a workspace handle.
35
43
  */
36
- function pickCmuxRef(ws) {
37
- if (typeof ws.ref === "string" && ws.ref.length > 0) {
38
- return ws.ref;
39
- }
44
+ function pickCmuxId(ws) {
40
45
  if (typeof ws.id === "string" && ws.id.length > 0) {
41
46
  return ws.id;
42
47
  }
43
- return ws.title;
48
+ if (typeof ws.ref === "string" && ws.ref.length > 0) {
49
+ return ws.ref;
50
+ }
51
+ return undefined;
44
52
  }
45
53
  async function listCmuxRaw(signal) {
46
54
  try {
@@ -54,13 +62,17 @@ async function listCmuxRaw(signal) {
54
62
  return undefined;
55
63
  }
56
64
  }
57
- function extractCmuxOpenRef(output) {
65
+ function extractCmuxOpenId(output) {
58
66
  try {
59
- // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- cmux --json prints a workspace ref/id object
67
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- cmux --json prints a workspace_id/ref object
60
68
  const parsed = JSON.parse(output);
61
- const candidate = parsed.ref ?? parsed.id ?? "";
62
- if (candidate.length > 0) {
63
- return candidate;
69
+ const uuid = parsed.workspace_id ?? parsed.id ?? "";
70
+ if (uuid.length > 0) {
71
+ return uuid;
72
+ }
73
+ const ref = parsed.workspace_ref ?? parsed.ref ?? "";
74
+ if (ref.length > 0) {
75
+ return ref;
64
76
  }
65
77
  }
66
78
  catch {
@@ -69,7 +81,91 @@ function extractCmuxOpenRef(output) {
69
81
  const match = /workspace:\d+/.exec(output);
70
82
  return match ? match[0] : undefined;
71
83
  }
72
- async function applyCmuxStatus(ref, status, signal) {
84
+ /**
85
+ * Inspect `cmux current-workspace`. When groundcrew is itself launched
86
+ * inside a cmux SSH workspace, `workspace.create` lands the new workspace
87
+ * on the local (macOS) cmux app rather than the remote where the agent's
88
+ * worktree lives. We can't replicate cmux's full SSH bootstrap
89
+ * (relay_port, daemon, etc.) from the remote side, so we instead wrap the
90
+ * agent launch command in a plain `ssh` to the same destination. Returns
91
+ * `undefined` when there is nothing to inherit, leaving callers free to
92
+ * launch locally as usual.
93
+ */
94
+ async function probeCurrentCmuxRemote(signal) {
95
+ if (readEnvironmentVariable("CMUX_WORKSPACE_ID") === undefined) {
96
+ return undefined;
97
+ }
98
+ let output;
99
+ try {
100
+ output = await runWorkspaceCommand("cmux", ["--json", "current-workspace"], signal);
101
+ }
102
+ catch (error) {
103
+ if (isSignalAborted(signal)) {
104
+ throw error;
105
+ }
106
+ // CMUX_WORKSPACE_ID is set, so we are inside a cmux workspace and a
107
+ // probe failure means we cannot tell whether this is an SSH context.
108
+ // Silently degrading to the local path would point cmux at a working
109
+ // directory that lives on a remote host; surface the failure instead
110
+ // so the caller can roll the worktree back rather than launch into
111
+ // the void.
112
+ throw new Error(`cmux current-workspace probe failed while CMUX_WORKSPACE_ID is set: ${errorMessage(error)}`, { cause: error });
113
+ }
114
+ try {
115
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- cmux --json current-workspace shape per v2 API
116
+ const parsed = JSON.parse(output);
117
+ const remote = parsed.workspace?.remote;
118
+ if (remote === undefined ||
119
+ remote.connected !== true ||
120
+ remote.transport !== "ssh" ||
121
+ typeof remote.destination !== "string" ||
122
+ remote.destination.length === 0) {
123
+ return undefined;
124
+ }
125
+ const inherited = { destination: remote.destination };
126
+ if (typeof remote.port === "number") {
127
+ inherited.port = remote.port;
128
+ }
129
+ if (typeof remote.identity_file === "string" && remote.identity_file.length > 0) {
130
+ inherited.identity_file = remote.identity_file;
131
+ }
132
+ if (Array.isArray(remote.ssh_options) && remote.ssh_options.length > 0) {
133
+ inherited.ssh_options = remote.ssh_options;
134
+ }
135
+ return inherited;
136
+ }
137
+ catch (error) {
138
+ // Same reasoning as the command-failure branch above: with
139
+ // CMUX_WORKSPACE_ID set, malformed JSON means we cannot decide
140
+ // between local and SSH context, so refuse rather than silently
141
+ // launching at the wrong working directory.
142
+ throw new Error(`cmux current-workspace returned malformed output while CMUX_WORKSPACE_ID is set: ${errorMessage(error)}`, { cause: error });
143
+ }
144
+ }
145
+ /**
146
+ * Compose an `ssh -t <destination> -- <cd && cmd>` invocation that lands
147
+ * a new cmux workspace's terminal on the same SSH remote where
148
+ * groundcrew is running. Path-bearing fields (`cwd`, the launch script
149
+ * inside `command`) stay valid because the remote shell evaluates them.
150
+ * The outermost return value is a single shell string suitable for
151
+ * `cmux new-workspace --command`.
152
+ */
153
+ function buildSshWrappedCommand(spec, remote) {
154
+ const remoteShell = `cd ${shellSingleQuote(spec.cwd)} && ${spec.command}`;
155
+ const sshTokens = ["ssh", "-t"];
156
+ if (remote.port !== undefined) {
157
+ sshTokens.push("-p", String(remote.port));
158
+ }
159
+ if (remote.identity_file !== undefined) {
160
+ sshTokens.push("-i", shellSingleQuote(remote.identity_file));
161
+ }
162
+ for (const option of remote.ssh_options ?? []) {
163
+ sshTokens.push("-o", shellSingleQuote(option));
164
+ }
165
+ sshTokens.push(shellSingleQuote(remote.destination), "--", shellSingleQuote(remoteShell));
166
+ return sshTokens.join(" ");
167
+ }
168
+ async function applyCmuxStatus(workspaceId, status, signal) {
73
169
  const arguments_ = ["set-status", "model", status.text];
74
170
  if (status.icon !== undefined) {
75
171
  arguments_.push("--icon", status.icon);
@@ -77,41 +173,40 @@ async function applyCmuxStatus(ref, status, signal) {
77
173
  if (status.color !== undefined) {
78
174
  arguments_.push("--color", status.color);
79
175
  }
80
- arguments_.push("--workspace", ref);
176
+ arguments_.push("--workspace", workspaceId);
81
177
  await runWorkspaceCommand("cmux", arguments_, signal);
82
178
  }
83
- async function closeCmuxWorkspace(refOrName, signal) {
84
- await runWorkspaceCommand("cmux", ["close-workspace", "--workspace", refOrName], signal);
179
+ async function closeCmuxWorkspace(workspaceId, signal) {
180
+ await runWorkspaceCommand("cmux", ["close-workspace", "--workspace", workspaceId], signal);
85
181
  }
86
182
  const cmuxAdapter = {
87
183
  async open(spec, signal) {
88
- const output = await runWorkspaceCommand("cmux", [
89
- "--json",
90
- "new-workspace",
91
- "--name",
92
- spec.name,
93
- "--cwd",
94
- spec.cwd,
95
- "--command",
96
- spec.command,
97
- ], signal);
98
- const ref = extractCmuxOpenRef(output);
99
- if (ref === undefined) {
184
+ const inheritedRemote = await probeCurrentCmuxRemote(signal);
185
+ const newWorkspaceArguments = ["--json", "new-workspace", "--name", spec.name];
186
+ if (inheritedRemote === undefined) {
187
+ newWorkspaceArguments.push("--working-directory", spec.cwd, "--command", spec.command);
188
+ }
189
+ else {
190
+ // Skip --working-directory: the path is on the SSH remote and would
191
+ // fall back to $HOME (macOS) when cmux tries to chdir locally. The
192
+ // wrapped ssh command does its own `cd` on the remote side.
193
+ newWorkspaceArguments.push("--command", buildSshWrappedCommand(spec, inheritedRemote));
194
+ }
195
+ const output = await runWorkspaceCommand("cmux", newWorkspaceArguments, signal);
196
+ const workspaceId = extractCmuxOpenId(output);
197
+ if (workspaceId === undefined) {
100
198
  log(`cmux new-workspace returned unrecognized output for ${spec.name}; if a workspace was created, run \`cmux close-workspace\` manually.`);
101
199
  throw new Error(`Unexpected cmux output: ${output}`);
102
200
  }
103
201
  if (spec.status !== undefined) {
104
202
  try {
105
- await applyCmuxStatus(ref, spec.status, signal);
203
+ await applyCmuxStatus(workspaceId, spec.status, signal);
106
204
  }
107
205
  catch (error) {
108
- try {
109
- await closeCmuxWorkspace(ref, signal);
110
- }
111
- catch (closeError) {
112
- log(`cmux close-workspace failed for ${spec.name}: ${errorMessage(closeError)}`);
113
- }
114
- throw error;
206
+ // v2 cmux builds may not implement `set-status`; status pills are
207
+ // a nice-to-have, not load-bearing. Log and keep the workspace
208
+ // rather than tearing down a successful launch.
209
+ log(`cmux set-status failed for ${spec.name} (continuing): ${errorMessage(error)}`);
115
210
  }
116
211
  }
117
212
  },
@@ -122,7 +217,10 @@ const cmuxAdapter = {
122
217
  async close(name, signal) {
123
218
  const raw = await listCmuxRaw(signal);
124
219
  if (raw === undefined) {
125
- await closeCmuxWorkspace(name, signal);
220
+ // cmux v2 `workspace.close` rejects titles, so forwarding `name`
221
+ // would always fail. The list failure has already been logged by
222
+ // `listCmuxRaw`; bail rather than guarantee a downstream error.
223
+ log(`cmux close-workspace skipped for ${name}: list-workspaces failed, no usable id`);
126
224
  return;
127
225
  }
128
226
  const match = raw.find((ws) => ws.title === name);
@@ -130,7 +228,7 @@ const cmuxAdapter = {
130
228
  return;
131
229
  }
132
230
  try {
133
- await closeCmuxWorkspace(match.ref, signal);
231
+ await closeCmuxWorkspace(match.id, signal);
134
232
  }
135
233
  catch (error) {
136
234
  if (isSignalAborted(signal)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clipboard-health/groundcrew",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Linear-driven orchestrator that launches AI coding agents in git worktrees, with workspace lifecycle and usage tracking.",
5
5
  "keywords": [
6
6
  "agent",