@clipboard-health/groundcrew 4.3.4 → 4.4.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 +63 -21
- package/crew.config.example.ts +10 -0
- package/dist/lib/agentLaunch.d.ts +1 -1
- package/dist/lib/agentLaunch.d.ts.map +1 -1
- package/dist/lib/agentLaunch.js +18 -0
- package/dist/lib/config.d.ts +35 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +83 -23
- package/dist/lib/launchCommand.d.ts.map +1 -1
- package/dist/lib/launchCommand.js +119 -28
- package/dist/lib/worktrees.d.ts.map +1 -1
- package/dist/lib/worktrees.js +71 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -130,27 +130,69 @@ Resolution order: `GROUNDCREW_CONFIG` → cosmiconfig project-walk from cwd (any
|
|
|
130
130
|
<details>
|
|
131
131
|
<summary>Full configuration reference</summary>
|
|
132
132
|
|
|
133
|
-
| Key
|
|
134
|
-
|
|
|
135
|
-
| `sources`
|
|
136
|
-
| `git.remote`
|
|
137
|
-
| `git.defaultBranch`
|
|
138
|
-
| `workspace.projectDir`
|
|
139
|
-
| `workspace.knownRepositories`
|
|
140
|
-
| `orchestrator.maximumInProgress`
|
|
141
|
-
| `orchestrator.pollIntervalMilliseconds`
|
|
142
|
-
| `orchestrator.sessionLimitPercentage`
|
|
143
|
-
| `models.default`
|
|
144
|
-
| `models.definitions`
|
|
145
|
-
| `models.definitions.<name>.cmd`
|
|
146
|
-
| `models.definitions.<name>.color`
|
|
147
|
-
| `models.definitions.<name>.usage`
|
|
148
|
-
| `models.definitions.<name>.sandbox`
|
|
149
|
-
| `models.definitions.<name>.
|
|
150
|
-
| `
|
|
151
|
-
| `
|
|
152
|
-
| `
|
|
153
|
-
| `
|
|
133
|
+
| Key | Default | What it does |
|
|
134
|
+
| ---------------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
135
|
+
| `sources` | `[]` | Additional pluggable ticket sources, dispatched alongside the built-in Linear adapter. Built-in kinds: `shell`, `linear`. |
|
|
136
|
+
| `git.remote` | `"origin"` | Remote used for `fetch` and as the worktree base ref. |
|
|
137
|
+
| `git.defaultBranch` | `"main"` | Branch fetched from `git.remote` and used as the worktree base. |
|
|
138
|
+
| `workspace.projectDir` | **required** | Parent dir for cloned repos and sibling ticket worktrees. |
|
|
139
|
+
| `workspace.knownRepositories` | **required** | Repos searched for in ticket descriptions to infer where work belongs. A ticket labeled for groundcrew (`agent-*`) fails fast when no known repo appears; unlabeled tickets are ignored. |
|
|
140
|
+
| `orchestrator.maximumInProgress` | `4` | Cap on in-progress tickets at once for this `crew` instance. |
|
|
141
|
+
| `orchestrator.pollIntervalMilliseconds` | `120_000` | Poll interval in `--watch` mode. |
|
|
142
|
+
| `orchestrator.sessionLimitPercentage` | `85` | Number in `(0, 100]`. A model whose codexbar session window exceeds this percentage is skipped that tick. |
|
|
143
|
+
| `models.default` | `"claude"` | Tiebreak for `agent-any` resolution and fallback for explicit but unknown `agent-*` labels. Also used by `crew start <TICKET>` for unlabeled tickets. `crew run` ignores unlabeled tickets and does not apply this default. Must exist in `models.definitions`. |
|
|
144
|
+
| `models.definitions` | `{ claude, codex }` | Agent definitions. Additive merge with shipped defaults. |
|
|
145
|
+
| `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. |
|
|
146
|
+
| `models.definitions.<name>.color` | — | Color for the workspace status pill (cmux only; tmux silently drops it). |
|
|
147
|
+
| `models.definitions.<name>.usage` | optional | If set, codexbar usage is fetched for this model and gated by `sessionLimitPercentage`. Falls back to default when unset, with gating enabled for known models. When `usage.codexbar.source` is omitted, groundcrew uses `oauth` for Codex/Claude on macOS, `auto` for other macOS providers, and `cli` elsewhere. Set to `{ disabled: true }` to disable usage gating. |
|
|
148
|
+
| `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) and `setupCommand` (override for the inside-sandbox setup script). Groundcrew assumes the `groundcrew-<agent>` sandbox already exists. |
|
|
149
|
+
| `models.definitions.<name>.preLaunch` | optional | Host-only shell snippet run **before** the agent exec and **outside** Safehouse/sdx. Exports survive into the launch shell; under the default `safehouse` runner they're only forwarded to the agent when listed via `preLaunchEnv` (recommended) or when `cmd` includes its own `safehouse --env-pass=NAMES`. `{{worktree}}` is substituted. A non-zero exit aborts launch. Not supported when `local.runner` resolves to `sdx` (v1). |
|
|
150
|
+
| `models.definitions.<name>.preLaunchEnv` | optional | Companion to `preLaunch`: list of env var names to append to groundcrew's `safehouse-clearance` `--env-pass=` flag, so `preLaunch` exports reach the agent **without** overriding `cmd` and losing the project's egress allowlist. Each entry must match `[A-Za-z_][A-Za-z0-9_]*`. Under `runner: "none"` exports already inherit and `preLaunchEnv` is a no-op. An empty array is a uniform no-op in every runner; a **non-empty** list is rejected when `cmd` already starts with `safehouse` (user owns env forwarding) or when `runner` resolves to `sdx`. |
|
|
151
|
+
| `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. |
|
|
152
|
+
| `prompts.initial` | unattended template | First message sent to the agent. Placeholders: `{{ticket}}`, `{{worktree}}`, `{{title}}`, `{{description}}`. Override this from `crew.config.ts` for team-specific statuses, tools, plugins, or review loops. |
|
|
153
|
+
| `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. |
|
|
154
|
+
| `local.runner` | `"auto"` | Local isolation backend. `"auto"` → `safehouse` on macOS, `sdx` on Linux/WSL. Explicit: `"safehouse"`, `"sdx"`, `"none"`. `"none"` is never picked implicitly. |
|
|
155
|
+
| `logging.file` | XDG state path | Append-mode log file. `log()` / `logEvent()` tee here in addition to stdout. Defaults to `${XDG_STATE_HOME:-$HOME/.local/state}/groundcrew/groundcrew.log`. |
|
|
156
|
+
|
|
157
|
+
</details>
|
|
158
|
+
|
|
159
|
+
### Per-session credentials (`preLaunch` + `preLaunchEnv`)
|
|
160
|
+
|
|
161
|
+
Build secrets shuttle build-time values _into_ setup. `preLaunch` does the opposite for the _agent_ phase: it runs a host-shell snippet **outside** Safehouse/sdx so the minting snippet never sees `NPM_TOKEN` / `BUF_TOKEN`, and its exports still land in the launch shell. Use this when the agent needs a short-lived credential that has to be minted from something the sandbox can't reach (e.g. an engineer CLI session in Keychain), and you don't want any of that source material — build-time or otherwise — in the minting snippet's environment or in the agent's process.
|
|
162
|
+
|
|
163
|
+
The "preLaunch never sees build secrets" contract is enforced differently per runner — same outcome, different mechanism:
|
|
164
|
+
|
|
165
|
+
- **`runner: "safehouse"` (default):** `preLaunch` runs immediately after `cd`, **before** `secrets.env` is sourced into the launch shell. `.groundcrew/setup.sh` then runs inside its own profile-neutral `safehouse-clearance` wrap with `--env-pass=NPM_TOKEN,BUF_TOKEN`; build secrets are `unset` on the host before the agent's Safehouse wrap is exec'd.
|
|
166
|
+
- **`runner: "none"`:** `secrets.env` is sourced first, `.groundcrew/setup.sh` runs on the host, build-secret names are `unset`, **then** `preLaunch` runs against a clean env, then the agent is exec'd.
|
|
167
|
+
|
|
168
|
+
Same security invariant, enforced via source-after-mint ordering under `safehouse` and post-cleanup ordering under `none`. If your snippet depends on a tool installed by `setup.sh`, prefer `runner: "none"` (preLaunch runs after setup) or inline the dependency; under `safehouse`, preLaunch runs before setup.
|
|
169
|
+
|
|
170
|
+
Under the default `safehouse` runner, the agent runs under a sanitized env allowlist — exports from `preLaunch` land in the launch shell but are stripped before reaching the agent unless they're forwarded. `preLaunchEnv` is the supported way to forward them: groundcrew passes the names via `--env-pass=` on the **agent** Safehouse wrap, so you keep the project's egress host allowlist (`clearance-allow-hosts`) without touching `cmd`. The names are scoped to the agent wrap only — the setup wrap and `.groundcrew/setup.sh` never see them, by design (setup runs profile-neutral and never carries the agent's grants).
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
models: {
|
|
174
|
+
definitions: {
|
|
175
|
+
claude: {
|
|
176
|
+
preLaunch: "SESSION_TOKEN=$(your-mint-command) && export SESSION_TOKEN",
|
|
177
|
+
preLaunchEnv: ["SESSION_TOKEN"],
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`&&` ensures `export` only runs when the mint succeeded; a failed mint propagates non-zero out of `preLaunch` and aborts launch before the agent starts. `{{worktree}}` is substituted the same way as in `cmd`. Under `runner: "none"`, exports flow through unchanged and `preLaunchEnv` is a no-op. A **non-empty** `preLaunchEnv` is not supported when `local.runner` resolves to `sdx` in v1 (sdx does not forward arbitrary host env into the sandbox); validated early in `setupWorkspace`. An empty `preLaunchEnv: []` is a uniform no-op in every runner — it forwards zero names, so the unsupported-runner guards do not fire.
|
|
184
|
+
|
|
185
|
+
<details>
|
|
186
|
+
<summary>Manual fallback when <code>cmd</code> brings its own <code>safehouse</code> wrap</summary>
|
|
187
|
+
|
|
188
|
+
If your `cmd` already starts with `safehouse` (because you need wrap flags groundcrew doesn't provide), groundcrew won't auto-compose `--env-pass=` for you and a **non-empty** `preLaunchEnv` is rejected at launch (an empty `[]` is accepted as a no-op). Add the names to your own `cmd` instead — note that this opts the model out of groundcrew's default `safehouse-clearance` wrap (egress allowlist), so re-supply `--append-profile` / `--env` yourself if you need it:
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
claude: {
|
|
192
|
+
preLaunch: "SESSION_TOKEN=$(your-mint-command) && export SESSION_TOKEN",
|
|
193
|
+
cmd: 'safehouse --env-pass=SESSION_TOKEN your-agent-cli',
|
|
194
|
+
},
|
|
195
|
+
```
|
|
154
196
|
|
|
155
197
|
</details>
|
|
156
198
|
|
package/crew.config.example.ts
CHANGED
|
@@ -61,6 +61,16 @@ export default {
|
|
|
61
61
|
// cmd: "cursor-agent",
|
|
62
62
|
// color: "#929292",
|
|
63
63
|
// },
|
|
64
|
+
// // Optional: mint a short-lived credential outside Safehouse and
|
|
65
|
+
// // forward it into the agent. `preLaunch` runs in the launch shell
|
|
66
|
+
// // before the agent exec; `preLaunchEnv` lists the names to add to
|
|
67
|
+
// // groundcrew's `safehouse-clearance --env-pass=` flag so the wrap's
|
|
68
|
+
// // egress allowlist stays intact. Chain with `&&` so a failed mint
|
|
69
|
+
// // aborts launch before `export`.
|
|
70
|
+
// // claude: {
|
|
71
|
+
// // preLaunch: "SESSION_TOKEN=$(your-mint-command) && export SESSION_TOKEN",
|
|
72
|
+
// // preLaunchEnv: ["SESSION_TOKEN"],
|
|
73
|
+
// // },
|
|
64
74
|
// // To run a model under the sdx (Docker Sandboxes) runner, bind it to
|
|
65
75
|
// // an sbx agent. Required when `local.runner` resolves to `sdx`.
|
|
66
76
|
// // claude: { sandbox: { agent: "claude" } },
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agentLaunch.d.ts","sourceRoot":"","sources":["../../src/lib/agentLaunch.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"agentLaunch.d.ts","sourceRoot":"","sources":["../../src/lib/agentLaunch.ts"],"names":[],"mappings":"AAEA,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,cAAc,EACpB,MAAM,aAAa,CAAC;AAOrB,UAAU,mBAAmB;IAC3B,MAAM,EAAE,WAAW,CAAC;IACpB,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAED,wBAAsB,kBAAkB,CAAC,KAAK,EAAE;IAC9C,MAAM,EAAE,cAAc,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,eAAe,CAAC;IAC5B,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAsD/B;AAED,wBAAsB,kBAAkB,CAAC,KAAK,EAAE;IAC9C,MAAM,EAAE,cAAc,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,GAAG,OAAO,CAAC,IAAI,CAAC,CAUhB"}
|
package/dist/lib/agentLaunch.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ensureClearance } from "@clipboard-health/clearance";
|
|
2
|
+
import { hasPreLaunchEnv, } from "./config.js";
|
|
2
3
|
import { detectHostCapabilities } from "./host.js";
|
|
3
4
|
import { assertLocalRunnerRequirements, resolveLocalRunner } from "./localRunner.js";
|
|
4
5
|
import { sandboxNameFor } from "./sandboxName.js";
|
|
@@ -25,6 +26,23 @@ export async function prepareAgentLaunch(input) {
|
|
|
25
26
|
if (runner === "sdx" && input.definition.sandbox === undefined) {
|
|
26
27
|
throw new Error(`Local groundcrew ${input.purpose} with the sdx runner require a sandbox config on model '${input.model}'.`);
|
|
27
28
|
}
|
|
29
|
+
if (runner === "sdx" && input.definition.preLaunch !== undefined) {
|
|
30
|
+
throw new Error(`Local groundcrew ${input.purpose} with the sdx runner do not support preLaunch on model '${input.model}'. ` +
|
|
31
|
+
"Use local.runner 'safehouse' or 'none', or remove preLaunch from the model.");
|
|
32
|
+
}
|
|
33
|
+
if (runner === "sdx" && hasPreLaunchEnv(input.definition)) {
|
|
34
|
+
throw new Error(`Local groundcrew ${input.purpose} with the sdx runner do not support preLaunchEnv on model '${input.model}'. ` +
|
|
35
|
+
"Use local.runner 'safehouse' or 'none', or remove preLaunchEnv from the model.");
|
|
36
|
+
}
|
|
37
|
+
// Mirror of buildLaunchCommand's defense — fail at config-resolution time so
|
|
38
|
+
// the operator sees the problem before the workspace is spawned, not deep in
|
|
39
|
+
// the launch shell. The buildLaunchCommand check stays as defense in depth.
|
|
40
|
+
if (runner === "safehouse" &&
|
|
41
|
+
hasPreLaunchEnv(input.definition) &&
|
|
42
|
+
/^safehouse(\s|$)/.test(input.definition.cmd)) {
|
|
43
|
+
throw new Error(`Local groundcrew ${input.purpose} on model '${input.model}' cannot inject preLaunchEnv when 'cmd' already starts with 'safehouse'. ` +
|
|
44
|
+
"Your cmd owns the wrap, so add the names to its own '--env-pass=' flag, or drop the 'safehouse' prefix from 'cmd' to let groundcrew compose the flag for you.");
|
|
45
|
+
}
|
|
28
46
|
const sandboxName = runner === "sdx" && input.definition.sandbox !== undefined
|
|
29
47
|
? sandboxNameFor({ agent: input.definition.sandbox.agent })
|
|
30
48
|
: undefined;
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -64,6 +64,30 @@ export interface ModelDefinition {
|
|
|
64
64
|
* Groundcrew adds the Safehouse wrapper.
|
|
65
65
|
*/
|
|
66
66
|
cmd: string;
|
|
67
|
+
/**
|
|
68
|
+
* Optional shell snippet run in the launch shell **before** the agent is
|
|
69
|
+
* exec'd and **outside** Safehouse/sdx. Use to mint short-lived credentials
|
|
70
|
+
* (e.g. `export SESSION_TOKEN=...`) that the wrapped `cmd` inherits via
|
|
71
|
+
* the process environment. `{{worktree}}` is replaced before launch.
|
|
72
|
+
* Failures abort launch (unlike deps setup, which logs and continues).
|
|
73
|
+
* Not supported for `local.runner` `sdx` in v1.
|
|
74
|
+
*/
|
|
75
|
+
preLaunch?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Optional list of env var names to forward from the launch shell into
|
|
78
|
+
* the agent under the safehouse runner. Companion to `preLaunch` —
|
|
79
|
+
* names exported by `preLaunch` go here so groundcrew appends them to
|
|
80
|
+
* the `safehouse-clearance` wrap's `--env-pass=` flag, preserving the
|
|
81
|
+
* project's egress allowlist (`clearance-allow-hosts`) without forcing
|
|
82
|
+
* the user to rewrite `cmd`. Under `local.runner: "none"` exports flow
|
|
83
|
+
* through unchanged, so `preLaunchEnv` is a no-op. An empty array is a
|
|
84
|
+
* uniform no-op in every runner (it forwards zero names, so the
|
|
85
|
+
* unsupported-runner guards do not fire). A non-empty list is rejected
|
|
86
|
+
* when `local.runner` resolves to `sdx` in v1, and when `cmd` already
|
|
87
|
+
* starts with `safehouse` (the user owns env forwarding in that case).
|
|
88
|
+
* Each name must match `[A-Za-z_][A-Za-z0-9_]*` (POSIX env var name).
|
|
89
|
+
*/
|
|
90
|
+
preLaunchEnv?: string[];
|
|
67
91
|
color: string;
|
|
68
92
|
usage?: {
|
|
69
93
|
codexbar: {
|
|
@@ -219,6 +243,17 @@ export interface ResolvedConfig {
|
|
|
219
243
|
file: string;
|
|
220
244
|
};
|
|
221
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Single source of truth for "is preLaunchEnv asking us to forward anything?"
|
|
248
|
+
*
|
|
249
|
+
* An empty array forwards zero names, so it is a uniform no-op in every
|
|
250
|
+
* runner. The unsupported-runner guards (sdx, safehouse-prefixed cmd) only
|
|
251
|
+
* fire when there is actually something to forward — rejecting `[]` only on
|
|
252
|
+
* those runners would make an empty list accepted under `safehouse`/`none`
|
|
253
|
+
* but fatal elsewhere, which is a worse asymmetry than what the helper
|
|
254
|
+
* collapses. Centralized so all four call sites stay in lockstep.
|
|
255
|
+
*/
|
|
256
|
+
export declare function hasPreLaunchEnv(definition: Pick<ModelDefinition, "preLaunchEnv">): boolean;
|
|
222
257
|
/**
|
|
223
258
|
* True when `name` is a shipped default the user removed via `disabled: true`.
|
|
224
259
|
* Derived from absence in `definitions` — that's the only path that removes a
|
package/dist/lib/config.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AACvE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"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"}
|
package/dist/lib/config.js
CHANGED
|
@@ -6,6 +6,7 @@ import { pathToFileURL } from "node:url";
|
|
|
6
6
|
import { cosmiconfig } from "cosmiconfig";
|
|
7
7
|
import { log, readEnvironmentVariable, setLogFile } from "./util.js";
|
|
8
8
|
import { xdgConfigPath, xdgStatePath } from "./xdg.js";
|
|
9
|
+
import { BUILD_SECRET_NAMES } from "./buildSecrets.js";
|
|
9
10
|
export { BUILD_SECRET_NAMES } from "./buildSecrets.js";
|
|
10
11
|
/**
|
|
11
12
|
* Reserved model name. A ticket labeled `agent-any` resolves at runtime
|
|
@@ -127,6 +128,40 @@ function normalizeOptionalString(value, path) {
|
|
|
127
128
|
}
|
|
128
129
|
return value.trim();
|
|
129
130
|
}
|
|
131
|
+
const ENV_VAR_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
132
|
+
function validatePreLaunchEnv(modelName, value) {
|
|
133
|
+
const path = `models.definitions.${modelName}.preLaunchEnv`;
|
|
134
|
+
if (!Array.isArray(value)) {
|
|
135
|
+
fail(`${path} must be an array of env var names (got ${JSON.stringify(value)})`);
|
|
136
|
+
}
|
|
137
|
+
for (const [index, entry] of value.entries()) {
|
|
138
|
+
if (typeof entry !== "string" || !ENV_VAR_NAME_PATTERN.test(entry)) {
|
|
139
|
+
fail(`${path}[${index}] must be a POSIX env var name matching ${ENV_VAR_NAME_PATTERN.source} (got ${JSON.stringify(entry)})`);
|
|
140
|
+
}
|
|
141
|
+
// Build secrets are sourced into the host launch shell, forwarded only to
|
|
142
|
+
// the Safehouse *setup* wrap, and `unset` on the host before the agent
|
|
143
|
+
// wrap is exec'd. Listing one here would silently never reach the agent —
|
|
144
|
+
// fail loudly so the operator picks a different name (or removes the
|
|
145
|
+
// entry) instead of debugging a missing env var at runtime.
|
|
146
|
+
if (BUILD_SECRET_NAMES.includes(entry)) {
|
|
147
|
+
fail(`${path}[${index}] cannot be a BUILD_SECRET_NAMES entry (${BUILD_SECRET_NAMES.join(", ")}); ` +
|
|
148
|
+
"those are unset on the host before the agent wrap is exec'd, so forwarding them via --env-pass would be a no-op.");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Single source of truth for "is preLaunchEnv asking us to forward anything?"
|
|
154
|
+
*
|
|
155
|
+
* An empty array forwards zero names, so it is a uniform no-op in every
|
|
156
|
+
* runner. The unsupported-runner guards (sdx, safehouse-prefixed cmd) only
|
|
157
|
+
* fire when there is actually something to forward — rejecting `[]` only on
|
|
158
|
+
* those runners would make an empty list accepted under `safehouse`/`none`
|
|
159
|
+
* but fatal elsewhere, which is a worse asymmetry than what the helper
|
|
160
|
+
* collapses. Centralized so all four call sites stay in lockstep.
|
|
161
|
+
*/
|
|
162
|
+
export function hasPreLaunchEnv(definition) {
|
|
163
|
+
return definition.preLaunchEnv !== undefined && definition.preLaunchEnv.length > 0;
|
|
164
|
+
}
|
|
130
165
|
function isWorkspaceKindSetting(value) {
|
|
131
166
|
return (typeof value === "string" && WORKSPACE_KIND_SETTINGS.includes(value));
|
|
132
167
|
}
|
|
@@ -189,7 +224,7 @@ function failIfLegacyModelKeys(name, override) {
|
|
|
189
224
|
if (override["disabled"] !== true) {
|
|
190
225
|
fail(`models.definitions.${name}.disabled must be exactly \`true\` when set (got ${JSON.stringify(override["disabled"])})`);
|
|
191
226
|
}
|
|
192
|
-
const conflicting = ["cmd", "color", "usage", "sandbox"].filter((key) => Object.hasOwn(override, key));
|
|
227
|
+
const conflicting = ["cmd", "color", "usage", "sandbox", "preLaunch", "preLaunchEnv"].filter((key) => Object.hasOwn(override, key));
|
|
193
228
|
if (conflicting.length > 0) {
|
|
194
229
|
fail(`models.definitions.${name}: cannot combine \`disabled: true\` with other fields (${conflicting.join(", ")}). Either disable the model or override its fields, not both.`);
|
|
195
230
|
}
|
|
@@ -208,6 +243,36 @@ export function isShippedDefaultDisabled(config, name) {
|
|
|
208
243
|
function isUsageDisableSentinel(usage) {
|
|
209
244
|
return isPlainObject(usage) && "disabled" in usage && usage.disabled;
|
|
210
245
|
}
|
|
246
|
+
function buildOverrideCandidate(name, override, existing) {
|
|
247
|
+
const base = existing === undefined ? {} : cloneModelDefinition(existing);
|
|
248
|
+
// Per-key spread so overriding `cmd` alone preserves the default
|
|
249
|
+
// `color` / `usage`. Brand-new entries must supply both required fields.
|
|
250
|
+
const candidate = { ...base };
|
|
251
|
+
if (override.cmd !== undefined) {
|
|
252
|
+
candidate.cmd = override.cmd;
|
|
253
|
+
}
|
|
254
|
+
if (override.color !== undefined) {
|
|
255
|
+
candidate.color = override.color;
|
|
256
|
+
}
|
|
257
|
+
if (override.usage !== undefined) {
|
|
258
|
+
if (isUsageDisableSentinel(override.usage)) {
|
|
259
|
+
delete candidate.usage;
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
candidate.usage = override.usage;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (override.sandbox !== undefined) {
|
|
266
|
+
candidate.sandbox = normalizeSandbox(override.sandbox, `models.definitions.${name}.sandbox`);
|
|
267
|
+
}
|
|
268
|
+
if (override.preLaunch !== undefined) {
|
|
269
|
+
candidate.preLaunch = override.preLaunch;
|
|
270
|
+
}
|
|
271
|
+
if (override.preLaunchEnv !== undefined) {
|
|
272
|
+
candidate.preLaunchEnv = override.preLaunchEnv;
|
|
273
|
+
}
|
|
274
|
+
return candidate;
|
|
275
|
+
}
|
|
211
276
|
function mergeDefinitions(user) {
|
|
212
277
|
if (user !== undefined && !isPlainObject(user)) {
|
|
213
278
|
fail("models.definitions must be an object");
|
|
@@ -229,28 +294,8 @@ function mergeDefinitions(user) {
|
|
|
229
294
|
delete merged[name];
|
|
230
295
|
continue;
|
|
231
296
|
}
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
// `color` / `usage`. Brand-new entries must supply both required fields.
|
|
235
|
-
const candidate = { ...base };
|
|
236
|
-
if (override.cmd !== undefined) {
|
|
237
|
-
candidate.cmd = override.cmd;
|
|
238
|
-
}
|
|
239
|
-
if (override.color !== undefined) {
|
|
240
|
-
candidate.color = override.color;
|
|
241
|
-
}
|
|
242
|
-
if (override.usage !== undefined) {
|
|
243
|
-
if (isUsageDisableSentinel(override.usage)) {
|
|
244
|
-
delete candidate.usage;
|
|
245
|
-
}
|
|
246
|
-
else {
|
|
247
|
-
candidate.usage = override.usage;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
if (override.sandbox !== undefined) {
|
|
251
|
-
candidate.sandbox = normalizeSandbox(override.sandbox, `models.definitions.${name}.sandbox`);
|
|
252
|
-
}
|
|
253
|
-
const { cmd, color, usage, sandbox } = candidate;
|
|
297
|
+
const candidate = buildOverrideCandidate(name, override, merged[name]);
|
|
298
|
+
const { cmd, color, usage, sandbox, preLaunch, preLaunchEnv } = candidate;
|
|
254
299
|
if (typeof cmd !== "string" || cmd.length === 0) {
|
|
255
300
|
fail(`models.definitions.${name}.cmd must be a non-empty string`);
|
|
256
301
|
}
|
|
@@ -264,6 +309,12 @@ function mergeDefinitions(user) {
|
|
|
264
309
|
if (sandbox !== undefined) {
|
|
265
310
|
definition.sandbox = sandbox;
|
|
266
311
|
}
|
|
312
|
+
if (preLaunch !== undefined) {
|
|
313
|
+
definition.preLaunch = preLaunch;
|
|
314
|
+
}
|
|
315
|
+
if (preLaunchEnv !== undefined) {
|
|
316
|
+
definition.preLaunchEnv = preLaunchEnv;
|
|
317
|
+
}
|
|
267
318
|
merged[name] = definition;
|
|
268
319
|
}
|
|
269
320
|
return merged;
|
|
@@ -431,6 +482,15 @@ function validate(config) {
|
|
|
431
482
|
if (definition.sandbox !== undefined) {
|
|
432
483
|
requireString(definition.sandbox.agent, `models.definitions.${name}.sandbox.agent`);
|
|
433
484
|
}
|
|
485
|
+
if (definition.preLaunch !== undefined) {
|
|
486
|
+
requireString(definition.preLaunch, `models.definitions.${name}.preLaunch`);
|
|
487
|
+
if (definition.preLaunch.trim().length === 0) {
|
|
488
|
+
fail(`models.definitions.${name}.preLaunch must contain non-whitespace characters`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (definition.preLaunchEnv !== undefined) {
|
|
492
|
+
validatePreLaunchEnv(name, definition.preLaunchEnv);
|
|
493
|
+
}
|
|
434
494
|
}
|
|
435
495
|
/* v8 ignore next 5 @preserve -- normalizeLocalRunner rejects invalid strings before validate() runs; this is a belt-and-suspenders guard */
|
|
436
496
|
if (!LOCAL_RUNNER_SETTINGS.includes(config.local.runner)) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"launchCommand.d.ts","sourceRoot":"","sources":["../../src/lib/launchCommand.ts"],"names":[],"mappings":"AAGA,OAAO,
|
|
1
|
+
{"version":3,"file":"launchCommand.d.ts","sourceRoot":"","sources":["../../src/lib/launchCommand.ts"],"names":[],"mappings":"AAGA,OAAO,EAGL,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;AAID;;;GAGG;AACH,eAAO,MAAM,aAAa,mFACwD,CAAC;AAyKnF,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,CA0B7E"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { basename, dirname, resolve } from "node:path";
|
|
3
|
-
import { BUILD_SECRET_NAMES } from "./config.js";
|
|
3
|
+
import { BUILD_SECRET_NAMES, hasPreLaunchEnv, } from "./config.js";
|
|
4
4
|
import { shellSingleQuote } from "./shell.js";
|
|
5
5
|
export { shellSingleQuote } from "./shell.js";
|
|
6
6
|
/**
|
|
@@ -35,6 +35,9 @@ function renderAgentCommand(arguments_) {
|
|
|
35
35
|
.replaceAll("{{worktree}}", shellSingleQuote(arguments_.worktreeDir))
|
|
36
36
|
.replaceAll("{{sandbox}}", shellSingleQuote(arguments_.sandboxName));
|
|
37
37
|
}
|
|
38
|
+
function renderPreLaunch(preLaunch, worktreeDir) {
|
|
39
|
+
return preLaunch.replaceAll("{{worktree}}", shellSingleQuote(worktreeDir));
|
|
40
|
+
}
|
|
38
41
|
function setupWithStatusReporting(setupCommand) {
|
|
39
42
|
return [
|
|
40
43
|
setupCommand,
|
|
@@ -50,8 +53,45 @@ function setupWithStatusReporting(setupCommand) {
|
|
|
50
53
|
function sourceSecretsLine(secretsFile) {
|
|
51
54
|
return `if [ -f ${shellSingleQuote(secretsFile)} ]; then set -a && . ${shellSingleQuote(secretsFile)} && set +a; fi`;
|
|
52
55
|
}
|
|
56
|
+
function unsetEnvironmentLine(names) {
|
|
57
|
+
return `unset ${[...new Set(names)].join(" ")}`;
|
|
58
|
+
}
|
|
53
59
|
function unsetSecretsLine() {
|
|
54
|
-
return
|
|
60
|
+
return unsetEnvironmentLine(BUILD_SECRET_NAMES);
|
|
61
|
+
}
|
|
62
|
+
function trapCleanupLine(promptDir) {
|
|
63
|
+
const cleanupCmd = `rm -rf ${shellSingleQuote(promptDir)}`;
|
|
64
|
+
return `trap ${shellSingleQuote(cleanupCmd)} EXIT`;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Shared head of every host-shell `&&` chain: arm the `EXIT` trap that wipes
|
|
68
|
+
* `promptDir` (must come before any link that can fail, including the `cd`),
|
|
69
|
+
* then `cd` into the worktree. Kept separate from secret sourcing so the
|
|
70
|
+
* safehouse path can splice `preLaunch` between the `cd` and the secrets
|
|
71
|
+
* source — preLaunch must never see build-time secrets in env.
|
|
72
|
+
*/
|
|
73
|
+
function hostTrapAndCd(arguments_) {
|
|
74
|
+
return [trapCleanupLine(arguments_.promptDir), `cd ${shellSingleQuote(arguments_.worktreeDir)}`];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Optional source-of-secrets line. Returns `[]` when no `secretsFile` is
|
|
78
|
+
* staged so callers can splat the result into their chain unconditionally.
|
|
79
|
+
*/
|
|
80
|
+
function hostSourceSecrets(secretsFile) {
|
|
81
|
+
return secretsFile === undefined ? [] : [sourceSecretsLine(secretsFile)];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Shared tail of every host-shell `&&` chain: optional `preLaunch`, then the
|
|
85
|
+
* staged prompt read, the explicit success-path `rm -rf` (the trap covers the
|
|
86
|
+
* failure path), and the final `exec` of whatever wraps (or is) the agent.
|
|
87
|
+
*/
|
|
88
|
+
function preLaunchPromptAndExec(arguments_) {
|
|
89
|
+
const lines = [];
|
|
90
|
+
if (arguments_.definition.preLaunch !== undefined) {
|
|
91
|
+
lines.push(renderPreLaunch(arguments_.definition.preLaunch, arguments_.worktreeDir));
|
|
92
|
+
}
|
|
93
|
+
lines.push(`_p=$(cat ${shellSingleQuote(arguments_.promptFile)})`, `rm -rf ${shellSingleQuote(arguments_.promptDir)}`, arguments_.execLine);
|
|
94
|
+
return lines;
|
|
55
95
|
}
|
|
56
96
|
function tokenizeShellPrefix(command) {
|
|
57
97
|
const tokens = [];
|
|
@@ -134,11 +174,23 @@ function safehouseProfileCommandName(agentCmd) {
|
|
|
134
174
|
*/
|
|
135
175
|
export function buildLaunchCommand(arguments_) {
|
|
136
176
|
if (arguments_.runner === "sdx") {
|
|
177
|
+
if (arguments_.definition.preLaunch !== undefined) {
|
|
178
|
+
throw new Error("preLaunch is not yet supported for runner='sdx'. Set local.runner to 'safehouse' or 'none', or open an issue for sdx support.");
|
|
179
|
+
}
|
|
180
|
+
if (hasPreLaunchEnv(arguments_.definition)) {
|
|
181
|
+
throw new Error("preLaunchEnv is not yet supported for runner='sdx'. Set local.runner to 'safehouse' or 'none', or open an issue for sdx support.");
|
|
182
|
+
}
|
|
137
183
|
return buildSdxLaunchCommand(arguments_);
|
|
138
184
|
}
|
|
139
185
|
if (shouldWrapWithSafehouse(arguments_)) {
|
|
140
186
|
return buildSafehouseLaunchCommand(arguments_);
|
|
141
187
|
}
|
|
188
|
+
if (hasPreLaunchEnv(arguments_.definition) && arguments_.runner === "safehouse") {
|
|
189
|
+
// `runner === "safehouse"` but `cmd` already starts with `safehouse` — the
|
|
190
|
+
// user owns env forwarding in that case, so there's no wrap flag for us to
|
|
191
|
+
// inject into. Fail loudly instead of silently dropping the contract.
|
|
192
|
+
throw new Error("preLaunchEnv cannot be injected when `cmd` starts with `safehouse` — your cmd owns the wrap, so add the names to its own `--env-pass=` flag, or drop the `safehouse` prefix from `cmd` to let groundcrew compose the flag for you.");
|
|
193
|
+
}
|
|
142
194
|
return buildUnwrappedHostLaunchCommand(arguments_);
|
|
143
195
|
}
|
|
144
196
|
/**
|
|
@@ -165,30 +217,50 @@ function buildUnwrappedHostLaunchCommand(arguments_) {
|
|
|
165
217
|
worktreeDir: arguments_.worktreeDir,
|
|
166
218
|
sandboxName: "",
|
|
167
219
|
});
|
|
168
|
-
const lines = [
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
220
|
+
const lines = [
|
|
221
|
+
...hostTrapAndCd({ worktreeDir: arguments_.worktreeDir, promptDir }),
|
|
222
|
+
...hostSourceSecrets(arguments_.secretsFile),
|
|
223
|
+
setupWithStatusReporting(SETUP_COMMAND),
|
|
224
|
+
];
|
|
173
225
|
if (arguments_.secretsFile !== undefined) {
|
|
174
226
|
lines.push(unsetSecretsLine());
|
|
175
227
|
}
|
|
176
|
-
lines.push(
|
|
228
|
+
lines.push(...preLaunchPromptAndExec({
|
|
229
|
+
definition: arguments_.definition,
|
|
230
|
+
worktreeDir: arguments_.worktreeDir,
|
|
231
|
+
promptFile: arguments_.promptFile,
|
|
232
|
+
promptDir,
|
|
233
|
+
execLine: `exec ${agentCmd} "$_p"`,
|
|
234
|
+
}));
|
|
177
235
|
return lines.join(" && ");
|
|
178
236
|
}
|
|
179
237
|
/**
|
|
180
|
-
* Safehouse launch.
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
238
|
+
* Safehouse launch. Two Safehouse wraps, by design:
|
|
239
|
+
*
|
|
240
|
+
* 1. **Setup wrap**: plain `safehouse-clearance ... sh -c '<setup>'`. Runs
|
|
241
|
+
* `.groundcrew/setup.sh --deps-only` filesystem-isolated and
|
|
242
|
+
* egress-restricted, **without** inheriting agent-profile grants.
|
|
243
|
+
* 2. **Agent wrap**: `safehouse-clearance "$shim" -c '<exec agent>' sh "$_p"`
|
|
244
|
+
* where `$shim` is a `mktemp`-d symlink to `/bin/sh` named after the
|
|
245
|
+
* agent (e.g. `claude`). Safehouse selects the matching agent profile
|
|
246
|
+
* from the wrapped command's basename (`claude-code.sb` etc.) without
|
|
247
|
+
* needing every agent profile enabled globally.
|
|
248
|
+
*
|
|
249
|
+
* Host ordering matters: when a `preLaunch` hook is present, inherited
|
|
250
|
+
* build-secret names and listed `preLaunchEnv` names are cleared before it runs.
|
|
251
|
+
* That keeps the credential-minting snippet from seeing build-time secrets in
|
|
252
|
+
* env — neither inherited values (the launch shell inherits groundcrew's env,
|
|
253
|
+
* from which `stageBuildSecrets` reads them) nor file-sourced values — and keeps
|
|
254
|
+
* stale same-named ambient credentials from being forwarded. `secrets.env` is
|
|
255
|
+
* then sourced into the host launch shell so Safehouse can forward build secrets
|
|
256
|
+
* into the **setup wrap** via `--env-pass=` (Safehouse's `--env=FILE` mode strips
|
|
257
|
+
* them otherwise). After setup returns, `BUILD_SECRET_NAMES` are `unset` again
|
|
258
|
+
* on the host so they cannot reach the agent wrap.
|
|
185
259
|
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* command-named shim so Safehouse can select the intended agent profile while
|
|
191
|
-
* the actual agent command remains `sh -c`.
|
|
260
|
+
* `--env-pass` composition is split per wrap (deliberate, post PR #128):
|
|
261
|
+
* - Setup wrap forwards build secrets only.
|
|
262
|
+
* - Agent wrap forwards `preLaunchEnv` names only. preLaunch credentials never
|
|
263
|
+
* reach the profile-neutral setup phase.
|
|
192
264
|
*/
|
|
193
265
|
function buildSafehouseLaunchCommand(arguments_) {
|
|
194
266
|
const promptDir = dirname(arguments_.promptFile);
|
|
@@ -200,23 +272,42 @@ function buildSafehouseLaunchCommand(arguments_) {
|
|
|
200
272
|
});
|
|
201
273
|
const setupCommand = setupWithStatusReporting(SETUP_COMMAND);
|
|
202
274
|
const agentCommand = `exec ${agentCmd} "$@"`;
|
|
203
|
-
//
|
|
204
|
-
|
|
275
|
+
// Split --env-pass per wrap: the setup wrap only needs build secrets (so
|
|
276
|
+
// `npm install` etc. can authenticate); the agent wrap only needs the
|
|
277
|
+
// user's preLaunchEnv (build secrets are `unset` on the host between the
|
|
278
|
+
// two wraps, so forwarding them here would silently no-op). Keeps preLaunch
|
|
279
|
+
// credentials out of the profile-neutral setup phase — see PR #128.
|
|
280
|
+
// Trailing space keeps each flag separated from the next argv token.
|
|
281
|
+
const setupEnvPassFlag = arguments_.secretsFile === undefined ? "" : `--env-pass=${BUILD_SECRET_NAMES.join(",")} `;
|
|
282
|
+
const preLaunchEnvNames = arguments_.definition.preLaunchEnv ?? [];
|
|
283
|
+
const agentEnvPassFlag = preLaunchEnvNames.length === 0 ? "" : `--env-pass=${preLaunchEnvNames.join(",")} `;
|
|
205
284
|
const safehouseWrapper = shellSingleQuote(SAFEHOUSE_CLEARANCE_WRAPPER_PATH);
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
285
|
+
// Defensive shim+promptDir trap: by the time we arm it, `rm -rf <promptDir>`
|
|
286
|
+
// has already run (line below) so the promptDir wipe is a no-op on the happy
|
|
287
|
+
// path. Keeps the failure-window between shim creation and the explicit
|
|
288
|
+
// post-wrap cleanup covered for both targets without an unarmed window.
|
|
289
|
+
const shimAndPromptCleanup = `rm -rf "$_safehouse_shim_dir"; rm -rf ${shellSingleQuote(promptDir)}`;
|
|
290
|
+
const shimAndPromptTrap = `trap ${shellSingleQuote(shimAndPromptCleanup)} EXIT`;
|
|
291
|
+
const lines = hostTrapAndCd({ worktreeDir: arguments_.worktreeDir, promptDir });
|
|
292
|
+
if (arguments_.definition.preLaunch !== undefined) {
|
|
293
|
+
// Scrub inherited env before preLaunch runs. `stageBuildSecrets` copies
|
|
294
|
+
// build secrets out of `process.env`, and the launch shell inherits that
|
|
295
|
+
// env, so source-after-preLaunch is not enough by itself. Clearing
|
|
296
|
+
// preLaunchEnv names here also prevents stale same-named ambient values
|
|
297
|
+
// from being forwarded if the hook forgets to overwrite them.
|
|
298
|
+
lines.push(unsetEnvironmentLine([...BUILD_SECRET_NAMES, ...preLaunchEnvNames]));
|
|
299
|
+
lines.push(renderPreLaunch(arguments_.definition.preLaunch, arguments_.worktreeDir));
|
|
209
300
|
}
|
|
210
|
-
lines.push(`_p=$(cat ${shellSingleQuote(arguments_.promptFile)})`, `rm -rf ${shellSingleQuote(promptDir)}`, `${safehouseWrapper} ${
|
|
301
|
+
lines.push(...hostSourceSecrets(arguments_.secretsFile), `_p=$(cat ${shellSingleQuote(arguments_.promptFile)})`, `rm -rf ${shellSingleQuote(promptDir)}`, `${safehouseWrapper} ${setupEnvPassFlag}sh -c ${shellSingleQuote(setupCommand)}`);
|
|
211
302
|
if (arguments_.secretsFile !== undefined) {
|
|
212
303
|
lines.push(unsetSecretsLine());
|
|
213
304
|
}
|
|
214
|
-
lines.push(`_safehouse_shim_dir=$(mktemp -d "\${TMPDIR:-/tmp}/groundcrew-safehouse-XXXXXX")`,
|
|
305
|
+
lines.push(`_safehouse_shim_dir=$(mktemp -d "\${TMPDIR:-/tmp}/groundcrew-safehouse-XXXXXX")`, shimAndPromptTrap, `_safehouse_shim="$_safehouse_shim_dir/${safehouseCommandName}"`, `ln -s /bin/sh "$_safehouse_shim"`,
|
|
215
306
|
// Safehouse selects an agent profile from the wrapped command's basename.
|
|
216
307
|
// Running the real launch chain as `sh -c` would make it see `sh`, so use
|
|
217
308
|
// an agent-named symlink to /bin/sh. This preserves per-agent profile
|
|
218
309
|
// selection without enabling every agent profile.
|
|
219
|
-
|
|
310
|
+
`{ ${safehouseWrapper} ${agentEnvPassFlag}"$_safehouse_shim" -c ${shellSingleQuote(agentCommand)} sh "$_p"; _safehouse_status=$?; rm -rf "$_safehouse_shim_dir"; trap - EXIT; exit "$_safehouse_status"; }`);
|
|
220
311
|
return lines.join(" && ");
|
|
221
312
|
}
|
|
222
313
|
function buildSdxLaunchCommand(arguments_) {
|
|
@@ -244,7 +335,7 @@ function buildSdxLaunchCommand(arguments_) {
|
|
|
244
335
|
const sbxEnvironmentFlags = arguments_.secretsFile === undefined
|
|
245
336
|
? ""
|
|
246
337
|
: `${BUILD_SECRET_NAMES.map((name) => `-e ${name}`).join(" ")} `;
|
|
247
|
-
const lines = [];
|
|
338
|
+
const lines = [trapCleanupLine(promptDir)];
|
|
248
339
|
if (arguments_.secretsFile !== undefined) {
|
|
249
340
|
lines.push(sourceSecretsLine(arguments_.secretsFile));
|
|
250
341
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worktrees.d.ts","sourceRoot":"","sources":["../../src/lib/worktrees.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAOH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAGlD,OAAO,EAAE,KAAK,cAAc,EAAc,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"worktrees.d.ts","sourceRoot":"","sources":["../../src/lib/worktrees.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAOH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAGlD,OAAO,EAAE,KAAK,cAAc,EAAc,MAAM,iBAAiB,CAAC;AAKlE,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC;AAElC,qBAAa,0BAA2B,SAAQ,KAAK;IACnD,SAAgB,GAAG,EAAE,MAAM,CAAC;IAE5B,YAAmB,GAAG,EAAE,MAAM,EAI7B;CACF;AAED,wBAAgB,4BAA4B,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,0BAA0B,CAEhG;AAED,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,sDAAsD;IACtD,MAAM,EAAE,MAAM,CAAC;IACf,+CAA+C;IAC/C,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,YAAY,CAAC;CACpB;AAED,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;CAChB;AAaD,iBAAS,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAEnD;AA8PD,MAAM,MAAM,iBAAiB,GACzB;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GACtD;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,GACjB;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC;AAiHxB,iBAAS,IAAI,CAAC,MAAM,EAAE,cAAc,GAAG,aAAa,EAAE,CAErD;AAED,iBAAS,YAAY,CAAC,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,GAAG,aAAa,EAAE,CAE7E;AAED,iBAAe,MAAM,CACnB,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,YAAY,EAClB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CAQxB;AAED,iBAAe,MAAM,CACnB,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,aAAa,EACpB,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAClD,OAAO,CAAC,IAAI,CAAC,CAKf;AAED,MAAM,MAAM,YAAY,GAAG,iBAAiB,GAAG,iBAAiB,CAAC;AAEjE,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,aAAa,CAAC;IACrB,IAAI,EAAE,YAAY,CAAC;IACnB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B,+DAA+D;IAC/D,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,sCAAsC;IACtC,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,wDAAwD;IACxD,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,cAAc,EAAE,cAAc,CAAC;CAChC;AAyBD,iBAAe,QAAQ,CACrB,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,SAAS,aAAa,EAAE,EACjC,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAClD,OAAO,CAAC,cAAc,CAAC,CAiDzB;AAED,iBAAe,gBAAgB,CAAC,KAAK,EAAE;IACrC,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAE7B;AAED,eAAO,MAAM,SAAS;;;;;;;;CAQrB,CAAC"}
|
package/dist/lib/worktrees.js
CHANGED
|
@@ -7,14 +7,15 @@
|
|
|
7
7
|
* (workspace-close + worktree-remove paired) so callers don't reach into
|
|
8
8
|
* git directly.
|
|
9
9
|
*/
|
|
10
|
-
import { existsSync, readdirSync } from "node:fs";
|
|
10
|
+
import { existsSync, readdirSync, rmSync } from "node:fs";
|
|
11
11
|
import { userInfo } from "node:os";
|
|
12
|
-
import { resolve } from "node:path";
|
|
12
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
13
13
|
import { runCommandAsync } from "./commandRunner.js";
|
|
14
14
|
import { resolveDefaultBranch } from "./defaultBranch.js";
|
|
15
15
|
import { errorMessage, log } from "./util.js";
|
|
16
16
|
import { workspaces } from "./workspaces.js";
|
|
17
17
|
const LONG_RUNNING_COMMAND_OPTIONS = { stdio: "inherit", timeoutMs: 0 };
|
|
18
|
+
const WORKTREE_LIST_PREFIX = "worktree ";
|
|
18
19
|
export class WorktreeAlreadyExistsError extends Error {
|
|
19
20
|
dir;
|
|
20
21
|
constructor(dir) {
|
|
@@ -186,26 +187,46 @@ async function removeWorktree(config, entry, options) {
|
|
|
186
187
|
// ourselves so the failure message names the condition — dirty
|
|
187
188
|
// (modified/untracked files, fixable with `crew cleanup --force`) or
|
|
188
189
|
// orphan (directory exists on disk but is not registered with the
|
|
189
|
-
// parent repo,
|
|
190
|
-
|
|
190
|
+
// parent repo, fixable with `crew cleanup --force` when the path still
|
|
191
|
+
// matches groundcrew's expected worktree location).
|
|
192
|
+
if (options.signal?.aborted === true) {
|
|
191
193
|
throw error;
|
|
192
194
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
195
|
+
if (options.force) {
|
|
196
|
+
const registration = await probeWorktreeRegistration({
|
|
197
|
+
repoDir,
|
|
198
|
+
worktreeDir: entry.dir,
|
|
199
|
+
...signalProperty(options.signal),
|
|
200
|
+
});
|
|
201
|
+
if (registration !== "orphan") {
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
removeOrphanWorktreeDirectory(config, entry);
|
|
201
205
|
}
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
if (
|
|
205
|
-
throw new Error(
|
|
206
|
+
else {
|
|
207
|
+
const dirtiness = await probeWorktreeDirtiness(entry.dir, options.signal);
|
|
208
|
+
if (dirtiness.kind === "dirty") {
|
|
209
|
+
throw new Error(describeDirtyWorktree({
|
|
210
|
+
ticket: entry.ticket,
|
|
211
|
+
dir: entry.dir,
|
|
212
|
+
modified: dirtiness.modified,
|
|
213
|
+
untracked: dirtiness.untracked,
|
|
214
|
+
}), { cause: error });
|
|
206
215
|
}
|
|
216
|
+
if (dirtiness.kind === "unknown") {
|
|
217
|
+
const registration = await probeWorktreeRegistration({
|
|
218
|
+
repoDir,
|
|
219
|
+
worktreeDir: entry.dir,
|
|
220
|
+
...signalProperty(options.signal),
|
|
221
|
+
});
|
|
222
|
+
if (registration === "orphan") {
|
|
223
|
+
throw new Error(describeOrphanWorktree({ ticket: entry.ticket, dir: entry.dir }), {
|
|
224
|
+
cause: error,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
throw error;
|
|
207
229
|
}
|
|
208
|
-
throw error;
|
|
209
230
|
}
|
|
210
231
|
}
|
|
211
232
|
else {
|
|
@@ -258,19 +279,47 @@ function describeDirtyWorktree(arguments_) {
|
|
|
258
279
|
const pronoun = modified + untracked === 1 ? "it" : "them";
|
|
259
280
|
return `worktree has ${summary}. Run \`crew cleanup --force ${ticket}\` to discard ${pronoun}, or commit/stash in ${dir} first.`;
|
|
260
281
|
}
|
|
261
|
-
async function probeWorktreeRegistration(
|
|
282
|
+
async function probeWorktreeRegistration(arguments_) {
|
|
262
283
|
let output;
|
|
263
284
|
try {
|
|
264
|
-
output = await runCommandAsync("git", ["-C",
|
|
285
|
+
output = await runCommandAsync("git", ["-C", arguments_.repoDir, "worktree", "list", "--porcelain"], signalProperty(arguments_.signal));
|
|
265
286
|
}
|
|
266
287
|
catch {
|
|
267
288
|
return "unknown";
|
|
268
289
|
}
|
|
269
|
-
|
|
290
|
+
const resolvedWorktreeDir = resolve(arguments_.worktreeDir);
|
|
291
|
+
for (const line of output.split("\n")) {
|
|
292
|
+
if (!line.startsWith(WORKTREE_LIST_PREFIX)) {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (resolve(line.slice(WORKTREE_LIST_PREFIX.length)) === resolvedWorktreeDir) {
|
|
296
|
+
return "registered";
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return "orphan";
|
|
270
300
|
}
|
|
271
301
|
function describeOrphanWorktree(arguments_) {
|
|
272
|
-
const { dir } = arguments_;
|
|
273
|
-
return `directory exists but is not a registered git worktree.
|
|
302
|
+
const { ticket, dir } = arguments_;
|
|
303
|
+
return `directory exists but is not a registered git worktree. Run \`crew cleanup --force ${ticket}\` to remove ${dir}, or inspect it first if it may contain valuable files.`;
|
|
304
|
+
}
|
|
305
|
+
function expectedHostWorktreeDir(config, entry) {
|
|
306
|
+
return resolve(config.workspace.projectDir, `${entry.repository}-${entry.ticket}`);
|
|
307
|
+
}
|
|
308
|
+
function isInsideDirectory(parentDir, childDir) {
|
|
309
|
+
const childRelativePath = relative(parentDir, childDir);
|
|
310
|
+
return (childRelativePath.length > 0 &&
|
|
311
|
+
!childRelativePath.startsWith("..") &&
|
|
312
|
+
!isAbsolute(childRelativePath));
|
|
313
|
+
}
|
|
314
|
+
function removeOrphanWorktreeDirectory(config, entry) {
|
|
315
|
+
const projectDir = resolve(config.workspace.projectDir);
|
|
316
|
+
const expectedDir = expectedHostWorktreeDir(config, entry);
|
|
317
|
+
const targetDir = resolve(entry.dir);
|
|
318
|
+
if (targetDir !== expectedDir || !isInsideDirectory(projectDir, targetDir)) {
|
|
319
|
+
throw new Error(`Refusing to force-delete ${entry.dir}: expected groundcrew worktree path ${expectedDir}.`);
|
|
320
|
+
}
|
|
321
|
+
log(`Removing orphaned worktree directory ${entry.dir} (--force)...`);
|
|
322
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
274
323
|
}
|
|
275
324
|
function list(config) {
|
|
276
325
|
return listWorktrees(config);
|
package/package.json
CHANGED