@clipboard-health/groundcrew 4.7.3 → 4.9.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 +5 -5
- package/crew.config.example.ts +31 -25
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +4 -4
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +24 -13
- package/dist/commands/setupWorkspace.d.ts.map +1 -1
- package/dist/commands/setupWorkspace.js +7 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/adapters/linear/fetch.d.ts +1 -1
- package/dist/lib/adapters/linear/fetch.d.ts.map +1 -1
- package/dist/lib/adapters/linear/fetch.js +7 -7
- package/dist/lib/adapters/linear/parsing.d.ts +1 -1
- package/dist/lib/adapters/linear/parsing.d.ts.map +1 -1
- package/dist/lib/adapters/linear/parsing.js +9 -9
- package/dist/lib/buildSecrets.d.ts +2 -2
- package/dist/lib/buildSecrets.js +2 -2
- package/dist/lib/config.d.ts +23 -26
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +79 -49
- package/dist/lib/launchCommand.d.ts +7 -6
- package/dist/lib/launchCommand.d.ts.map +1 -1
- package/dist/lib/launchCommand.js +38 -30
- package/dist/lib/repositoryHooks.d.ts +8 -0
- package/dist/lib/repositoryHooks.d.ts.map +1 -0
- package/dist/lib/repositoryHooks.js +71 -0
- package/docs/adr/0001-groundcrew-uses-but-does-not-provision-sandboxes.md +1 -1
- package/docs/configuration.md +66 -36
- package/docs/credentials.md +6 -6
- package/docs/setup-hook-agent-prompt.md +46 -49
- package/docs/setup-hooks.md +64 -29
- package/docs/troubleshooting.md +3 -3
- package/package.json +7 -7
package/dist/lib/config.js
CHANGED
|
@@ -35,7 +35,7 @@ const DEFAULT_ORCHESTRATOR = {
|
|
|
35
35
|
pollIntervalMilliseconds: 120_000,
|
|
36
36
|
sessionLimitPercentage: 85,
|
|
37
37
|
};
|
|
38
|
-
const
|
|
38
|
+
const BUILT_IN_MODEL_DEFINITIONS = {
|
|
39
39
|
claude: {
|
|
40
40
|
cmd: "claude --permission-mode auto",
|
|
41
41
|
color: "#C15F3C",
|
|
@@ -47,6 +47,30 @@ const DEFAULT_MODEL_DEFINITIONS = {
|
|
|
47
47
|
usage: { codexbar: { provider: "codex" } },
|
|
48
48
|
},
|
|
49
49
|
};
|
|
50
|
+
const MODEL_DEFINITIONS_MIGRATION_MESSAGE = [
|
|
51
|
+
"configuration migration required: models are no longer enabled by default.",
|
|
52
|
+
"",
|
|
53
|
+
"Add the models you want to use:",
|
|
54
|
+
"",
|
|
55
|
+
"models: {",
|
|
56
|
+
' default: "claude",',
|
|
57
|
+
" definitions: {",
|
|
58
|
+
" claude: {},",
|
|
59
|
+
" },",
|
|
60
|
+
"},",
|
|
61
|
+
"",
|
|
62
|
+
"To keep the previous claude+codex behavior:",
|
|
63
|
+
"",
|
|
64
|
+
"models: {",
|
|
65
|
+
' default: "claude",',
|
|
66
|
+
" definitions: {",
|
|
67
|
+
" claude: {},",
|
|
68
|
+
" codex: {},",
|
|
69
|
+
" },",
|
|
70
|
+
"},",
|
|
71
|
+
"",
|
|
72
|
+
"`disabled: true` is no longer supported; remove disabled model entries instead.",
|
|
73
|
+
].join("\n");
|
|
50
74
|
const DEFAULT_PROMPT_INITIAL = [
|
|
51
75
|
"You are working on Linear ticket {{ticket}} ({{title}}) in the {{worktree}} worktree subdirectory.",
|
|
52
76
|
"",
|
|
@@ -128,6 +152,31 @@ function normalizeOptionalString(value, configKey) {
|
|
|
128
152
|
}
|
|
129
153
|
return value.trim();
|
|
130
154
|
}
|
|
155
|
+
function normalizeHookCommands(value, configKey) {
|
|
156
|
+
if (value === undefined) {
|
|
157
|
+
return {};
|
|
158
|
+
}
|
|
159
|
+
if (!isPlainObject(value)) {
|
|
160
|
+
fail(`${configKey} must be an object`);
|
|
161
|
+
}
|
|
162
|
+
const hooks = {};
|
|
163
|
+
const prepareWorktree = normalizeOptionalString(value["prepareWorktree"], `${configKey}.prepareWorktree`);
|
|
164
|
+
if (prepareWorktree !== undefined) {
|
|
165
|
+
hooks.prepareWorktree = prepareWorktree;
|
|
166
|
+
}
|
|
167
|
+
return hooks;
|
|
168
|
+
}
|
|
169
|
+
function normalizeDefaults(value) {
|
|
170
|
+
if (value === undefined) {
|
|
171
|
+
return { hooks: {} };
|
|
172
|
+
}
|
|
173
|
+
if (!isPlainObject(value)) {
|
|
174
|
+
fail("defaults must be an object");
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
hooks: normalizeHookCommands(value["hooks"], "defaults.hooks"),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
131
180
|
const ENV_VAR_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
132
181
|
function validatePreLaunchEnv(modelName, value) {
|
|
133
182
|
const configPath = `models.definitions.${modelName}.preLaunchEnv`;
|
|
@@ -139,7 +188,7 @@ function validatePreLaunchEnv(modelName, value) {
|
|
|
139
188
|
fail(`${configPath}[${index}] must be a POSIX env var name matching ${ENV_VAR_NAME_PATTERN.source} (got ${JSON.stringify(entry)})`);
|
|
140
189
|
}
|
|
141
190
|
// Build secrets are sourced into the host launch shell, forwarded only to
|
|
142
|
-
// the Safehouse *
|
|
191
|
+
// the Safehouse *prepareWorktree* wrap, and `unset` on the host before the agent
|
|
143
192
|
// wrap is exec'd. Listing one here would silently never reach the agent —
|
|
144
193
|
// fail loudly so the operator picks a different name (or removes the
|
|
145
194
|
// entry) instead of debugging a missing env var at runtime.
|
|
@@ -196,22 +245,20 @@ function normalizeSandbox(value, configKey) {
|
|
|
196
245
|
if (Object.hasOwn(value, "kits")) {
|
|
197
246
|
failRemovedConfigKey(`${configKey}.kits`, "Groundcrew no longer creates sdx sandboxes or applies sandbox kits.");
|
|
198
247
|
}
|
|
199
|
-
|
|
248
|
+
if (Object.hasOwn(value, "setupCommand")) {
|
|
249
|
+
fail(`${configKey}.setupCommand is no longer supported: use repo-local \`.groundcrew/config.json\` \`hooks.prepareWorktree\`, or \`defaults.hooks.prepareWorktree\` in crew.config.ts when you need a fallback for repos without their own hook.`);
|
|
250
|
+
}
|
|
251
|
+
const { agent } = value;
|
|
200
252
|
requireString(agent, `${configKey}.agent`);
|
|
201
253
|
const trimmedAgent = agent.trim();
|
|
202
254
|
if (trimmedAgent.length === 0) {
|
|
203
255
|
fail(`${configKey}.agent must be a non-empty string (got ${JSON.stringify(agent)})`);
|
|
204
256
|
}
|
|
205
|
-
|
|
206
|
-
const normalizedSetup = normalizeOptionalString(setupCommand, `${configKey}.setupCommand`);
|
|
207
|
-
if (normalizedSetup !== undefined) {
|
|
208
|
-
sandbox.setupCommand = normalizedSetup;
|
|
209
|
-
}
|
|
210
|
-
return sandbox;
|
|
257
|
+
return { agent: trimmedAgent };
|
|
211
258
|
}
|
|
212
259
|
function failRemovedConfigKey(configKey, reason) {
|
|
213
260
|
fail(`${configKey} is no longer supported: ${reason} ` +
|
|
214
|
-
"Provision and manage the sandbox yourself with `sbx` (for example `sbx create --name groundcrew-<agent> <agent> <projectDir>`), then keep only `models.definitions.<model>.sandbox.agent`
|
|
261
|
+
"Provision and manage the sandbox yourself with `sbx` (for example `sbx create --name groundcrew-<agent> <agent> <projectDir>`), then keep only `models.definitions.<model>.sandbox.agent` in crew.config.ts.");
|
|
215
262
|
}
|
|
216
263
|
function failIfLegacyModelKeys(name, override) {
|
|
217
264
|
if (!isPlainObject(override)) {
|
|
@@ -221,23 +268,16 @@ function failIfLegacyModelKeys(name, override) {
|
|
|
221
268
|
fail(`models.definitions.${name}.isolation is no longer supported: per-model isolation is no longer supported`);
|
|
222
269
|
}
|
|
223
270
|
if (Object.hasOwn(override, "disabled")) {
|
|
224
|
-
|
|
225
|
-
fail(`models.definitions.${name}.disabled must be exactly \`true\` when set (got ${JSON.stringify(override["disabled"])})`);
|
|
226
|
-
}
|
|
227
|
-
const conflicting = ["cmd", "color", "usage", "sandbox", "preLaunch", "preLaunchEnv"].filter((key) => Object.hasOwn(override, key));
|
|
228
|
-
if (conflicting.length > 0) {
|
|
229
|
-
fail(`models.definitions.${name}: cannot combine \`disabled: true\` with other fields (${conflicting.join(", ")}). Either disable the model or override its fields, not both.`);
|
|
230
|
-
}
|
|
271
|
+
fail(MODEL_DEFINITIONS_MIGRATION_MESSAGE);
|
|
231
272
|
}
|
|
232
273
|
}
|
|
233
274
|
/**
|
|
234
|
-
* True when `name` is a
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* Consumers needing to distinguish disabled-by-user from unknown-label use this.
|
|
275
|
+
* True when `name` is a built-in preset but not present in the enabled
|
|
276
|
+
* definitions. Consumers use this to distinguish `agent-codex` when codex is
|
|
277
|
+
* not enabled from an arbitrary unknown label like `agent-typo`.
|
|
238
278
|
*/
|
|
239
|
-
export function
|
|
240
|
-
return (Object.hasOwn(
|
|
279
|
+
export function isBuiltInModelNotEnabled(config, name) {
|
|
280
|
+
return (Object.hasOwn(BUILT_IN_MODEL_DEFINITIONS, name) &&
|
|
241
281
|
!Object.hasOwn(config.models.definitions, name));
|
|
242
282
|
}
|
|
243
283
|
function isUsageDisableSentinel(usage) {
|
|
@@ -274,27 +314,17 @@ function buildOverrideCandidate(name, override, existing) {
|
|
|
274
314
|
return candidate;
|
|
275
315
|
}
|
|
276
316
|
function mergeDefinitions(user) {
|
|
277
|
-
if (user
|
|
317
|
+
if (user === undefined) {
|
|
318
|
+
fail(MODEL_DEFINITIONS_MIGRATION_MESSAGE);
|
|
319
|
+
}
|
|
320
|
+
if (!isPlainObject(user)) {
|
|
278
321
|
fail("models.definitions must be an object");
|
|
279
322
|
}
|
|
280
|
-
const merged =
|
|
281
|
-
|
|
282
|
-
cloneModelDefinition(definition),
|
|
283
|
-
]));
|
|
284
|
-
for (const [name, override] of Object.entries(user ?? {})) {
|
|
323
|
+
const merged = {};
|
|
324
|
+
for (const [name, override] of Object.entries(user)) {
|
|
285
325
|
failIfLegacyModelKeys(name, override);
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
fail(`models.definitions.${name}: \`disabled: true\` is only valid for shipped defaults (${Object.keys(DEFAULT_MODEL_DEFINITIONS).join(", ")}). Remove the entry instead.`);
|
|
289
|
-
}
|
|
290
|
-
// Drop the key so downstream iterators (doctor, eligibility, usage) ignore
|
|
291
|
-
// the model automatically; `isShippedDefaultDisabled` lets the few consumers
|
|
292
|
-
// that need to distinguish disabled from unknown re-derive the set.
|
|
293
|
-
// oxlint-disable-next-line typescript/no-dynamic-delete -- `merged` is a fresh function-local clone of DEFAULT_MODEL_DEFINITIONS; no V8 dictionary-mode/pollution concerns
|
|
294
|
-
delete merged[name];
|
|
295
|
-
continue;
|
|
296
|
-
}
|
|
297
|
-
const candidate = buildOverrideCandidate(name, override, merged[name]);
|
|
326
|
+
const builtIn = BUILT_IN_MODEL_DEFINITIONS[name];
|
|
327
|
+
const candidate = buildOverrideCandidate(name, override, builtIn);
|
|
298
328
|
const { cmd, color, usage, sandbox, preLaunch, preLaunchEnv } = candidate;
|
|
299
329
|
if (typeof cmd !== "string" || cmd.length === 0) {
|
|
300
330
|
fail(`models.definitions.${name}.cmd must be a non-empty string`);
|
|
@@ -416,6 +446,7 @@ function applyDefaults(user) {
|
|
|
416
446
|
projectDir: expandHome(user.workspace.projectDir),
|
|
417
447
|
knownRepositories: user.workspace.knownRepositories,
|
|
418
448
|
},
|
|
449
|
+
defaults: normalizeDefaults(user.defaults),
|
|
419
450
|
orchestrator: { ...DEFAULT_ORCHESTRATOR, ...user.orchestrator },
|
|
420
451
|
models: {
|
|
421
452
|
default: user.models?.default ?? "claude",
|
|
@@ -456,7 +487,6 @@ function validate(config) {
|
|
|
456
487
|
requirePositiveInt(config.orchestrator.pollIntervalMilliseconds, "orchestrator.pollIntervalMilliseconds");
|
|
457
488
|
requirePercent(config.orchestrator.sessionLimitPercentage, "orchestrator.sessionLimitPercentage");
|
|
458
489
|
const { definitions } = config.models;
|
|
459
|
-
/* v8 ignore next 3 @preserve -- mergeDefinitions seeds claude+codex defaults, so an empty map is unreachable */
|
|
460
490
|
if (Object.keys(definitions).length === 0) {
|
|
461
491
|
fail("models.definitions must contain at least one model");
|
|
462
492
|
}
|
|
@@ -468,12 +498,12 @@ function validate(config) {
|
|
|
468
498
|
requireString(definition.color, `models.definitions.${name}.color`);
|
|
469
499
|
if (definition.usage !== undefined) {
|
|
470
500
|
const usagePath = `models.definitions.${name}.usage`;
|
|
471
|
-
/* v8 ignore next 3 @preserve -- mergeDefinitions only assigns usage from validated overrides or
|
|
501
|
+
/* v8 ignore next 3 @preserve -- mergeDefinitions only assigns usage from validated overrides or built-in presets; reaching this guard requires hand-mutating the resolved config */
|
|
472
502
|
if (typeof definition.usage !== "object" || definition.usage === null) {
|
|
473
503
|
fail(`${usagePath} must be an object`);
|
|
474
504
|
}
|
|
475
505
|
const { codexbar } = definition.usage;
|
|
476
|
-
/* v8 ignore next 3 @preserve -- mergeDefinitions only assigns usage from validated overrides or
|
|
506
|
+
/* v8 ignore next 3 @preserve -- mergeDefinitions only assigns usage from validated overrides or built-in presets; reaching this guard requires hand-mutating the resolved config */
|
|
477
507
|
if (typeof codexbar !== "object" || codexbar === null) {
|
|
478
508
|
fail(`${usagePath}.codexbar must be an object`);
|
|
479
509
|
}
|
|
@@ -496,11 +526,11 @@ function validate(config) {
|
|
|
496
526
|
if (!LOCAL_RUNNER_SETTINGS.includes(config.local.runner)) {
|
|
497
527
|
fail(`local.runner must be one of ${LOCAL_RUNNER_SETTINGS.join(", ")} (got ${JSON.stringify(config.local.runner)})`);
|
|
498
528
|
}
|
|
499
|
-
//
|
|
500
|
-
// the user gets the specific
|
|
501
|
-
//
|
|
502
|
-
if (
|
|
503
|
-
fail(`models.default ("${config.models.default}") is
|
|
529
|
+
// Built-in-not-enabled check must run before the generic "not a key" check
|
|
530
|
+
// so the user gets the specific migration-oriented message for `codex`
|
|
531
|
+
// instead of a stale-list message.
|
|
532
|
+
if (isBuiltInModelNotEnabled(config, config.models.default)) {
|
|
533
|
+
fail(`models.default ("${config.models.default}") is not enabled. Add \`models.definitions.${config.models.default}: {}\` or set models.default to an enabled model.`);
|
|
504
534
|
}
|
|
505
535
|
if (!(config.models.default in definitions)) {
|
|
506
536
|
fail(`models.default ("${config.models.default}") is not a key in models.definitions (have: ${Object.keys(definitions).join(", ")})`);
|
|
@@ -11,11 +11,6 @@ export { shellSingleQuote } from "./shell.ts";
|
|
|
11
11
|
* exercise the catch branch.
|
|
12
12
|
*/
|
|
13
13
|
export declare function resolveSafehouseClearancePath(baseUrl?: string): string;
|
|
14
|
-
/**
|
|
15
|
-
* Per-repo setup hook: if `.groundcrew/setup.sh` exists, run it with
|
|
16
|
-
* `--deps-only`; otherwise no-op.
|
|
17
|
-
*/
|
|
18
|
-
export declare const SETUP_COMMAND = "if [ -f .groundcrew/setup.sh ]; then bash .groundcrew/setup.sh --deps-only; fi";
|
|
19
14
|
interface LaunchCommandArguments {
|
|
20
15
|
definition: ModelDefinition;
|
|
21
16
|
promptFile: string;
|
|
@@ -23,11 +18,17 @@ interface LaunchCommandArguments {
|
|
|
23
18
|
/**
|
|
24
19
|
* Optional path to a `KEY='value'` env file containing build-time
|
|
25
20
|
* secrets (see `BUILD_SECRET_NAMES`). Sourced on the host shell before
|
|
26
|
-
*
|
|
21
|
+
* prepareWorktree; for the sdx runner the names are propagated into the sandbox
|
|
27
22
|
* via `sbx exec -e KEY`. Always unset before exec'ing the agent so the
|
|
28
23
|
* agent process never inherits them.
|
|
29
24
|
*/
|
|
30
25
|
secretsFile?: string | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Optional repo-preparation hook resolved by the caller from the freshly
|
|
28
|
+
* created worktree's `.groundcrew/config.json`, falling back to
|
|
29
|
+
* `defaults.hooks.prepareWorktree` from crew.config.ts.
|
|
30
|
+
*/
|
|
31
|
+
prepareWorktreeCommand?: string | undefined;
|
|
31
32
|
/**
|
|
32
33
|
* Concrete local isolation backend chosen for this launch. Resolved
|
|
33
34
|
* from `config.local.runner` via `resolveLocalRunner` before this
|
|
@@ -1 +1 @@
|
|
|
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;
|
|
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;AA2KD,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,sBAAsB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5C;;;;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"}
|
|
@@ -25,11 +25,6 @@ export function resolveSafehouseClearancePath(baseUrl = import.meta.url) {
|
|
|
25
25
|
return path.resolve(path.dirname(clearancePackageJson), "safehouse", "safehouse-clearance");
|
|
26
26
|
}
|
|
27
27
|
const SAFEHOUSE_CLEARANCE_WRAPPER_PATH = resolveSafehouseClearancePath();
|
|
28
|
-
/**
|
|
29
|
-
* Per-repo setup hook: if `.groundcrew/setup.sh` exists, run it with
|
|
30
|
-
* `--deps-only`; otherwise no-op.
|
|
31
|
-
*/
|
|
32
|
-
export const SETUP_COMMAND = "if [ -f .groundcrew/setup.sh ]; then bash .groundcrew/setup.sh --deps-only; fi";
|
|
33
28
|
function renderAgentCommand(arguments_) {
|
|
34
29
|
return arguments_.agentCmd
|
|
35
30
|
.replaceAll("{{worktree}}", shellSingleQuote(arguments_.worktreeDir))
|
|
@@ -38,17 +33,17 @@ function renderAgentCommand(arguments_) {
|
|
|
38
33
|
function renderPreLaunch(preLaunch, worktreeDir) {
|
|
39
34
|
return preLaunch.replaceAll("{{worktree}}", shellSingleQuote(worktreeDir));
|
|
40
35
|
}
|
|
41
|
-
function
|
|
36
|
+
function prepareWorktreeWithStatusReporting(prepareWorktreeCommand) {
|
|
42
37
|
return [
|
|
43
|
-
|
|
44
|
-
"
|
|
45
|
-
'if [ "$
|
|
38
|
+
`(${prepareWorktreeCommand})`,
|
|
39
|
+
"prepare_status=$?",
|
|
40
|
+
'if [ "$prepare_status" -ne 0 ]; then echo "groundcrew prepareWorktree hook exited with status $prepare_status; continuing to agent." >&2; fi',
|
|
46
41
|
].join("; ");
|
|
47
42
|
}
|
|
48
43
|
/**
|
|
49
44
|
* Source a `KEY='value'` file with auto-export so build-time secrets land
|
|
50
|
-
* in the shell env before
|
|
51
|
-
* the file disappeared between staging and launch.
|
|
45
|
+
* in the shell env before prepareWorktree runs. The `-f` guard keeps it a
|
|
46
|
+
* no-op if the file disappeared between staging and launch.
|
|
52
47
|
*/
|
|
53
48
|
function sourceSecretsLine(secretsFile) {
|
|
54
49
|
return `if [ -f ${shellSingleQuote(secretsFile)} ]; then set -a && . ${shellSingleQuote(secretsFile)} && set +a; fi`;
|
|
@@ -196,8 +191,9 @@ export function buildLaunchCommand(arguments_) {
|
|
|
196
191
|
/**
|
|
197
192
|
* The Safehouse wrap applies only when `runner === "safehouse"` and `cmd` does
|
|
198
193
|
* not already invoke `safehouse` itself. A `safehouse …` cmd owns its own
|
|
199
|
-
* sandbox flags, and we can't splice
|
|
200
|
-
* those (and the `none` runner) fall through to the unwrapped host
|
|
194
|
+
* sandbox flags, and we can't splice prepareWorktree into a command we don't
|
|
195
|
+
* control, so those (and the `none` runner) fall through to the unwrapped host
|
|
196
|
+
* path.
|
|
201
197
|
*/
|
|
202
198
|
function shouldWrapWithSafehouse(arguments_) {
|
|
203
199
|
if (arguments_.runner !== "safehouse") {
|
|
@@ -207,8 +203,9 @@ function shouldWrapWithSafehouse(arguments_) {
|
|
|
207
203
|
}
|
|
208
204
|
/**
|
|
209
205
|
* Unsandboxed host launch (`runner === "none"`, or a `safehouse …` cmd that
|
|
210
|
-
* brings its own wrap).
|
|
211
|
-
* host shell because there is no groundcrew-managed sandbox to run them
|
|
206
|
+
* brings its own wrap). prepareWorktree, secret sourcing, and the agent all run
|
|
207
|
+
* on the host shell because there is no groundcrew-managed sandbox to run them
|
|
208
|
+
* inside.
|
|
212
209
|
*/
|
|
213
210
|
function buildUnwrappedHostLaunchCommand(arguments_) {
|
|
214
211
|
const promptDir = path.dirname(arguments_.promptFile);
|
|
@@ -220,8 +217,10 @@ function buildUnwrappedHostLaunchCommand(arguments_) {
|
|
|
220
217
|
const lines = [
|
|
221
218
|
...hostTrapAndCd({ worktreeDir: arguments_.worktreeDir, promptDir }),
|
|
222
219
|
...hostSourceSecrets(arguments_.secretsFile),
|
|
223
|
-
setupWithStatusReporting(SETUP_COMMAND),
|
|
224
220
|
];
|
|
221
|
+
if (arguments_.prepareWorktreeCommand !== undefined) {
|
|
222
|
+
lines.push(prepareWorktreeWithStatusReporting(arguments_.prepareWorktreeCommand));
|
|
223
|
+
}
|
|
225
224
|
if (arguments_.secretsFile !== undefined) {
|
|
226
225
|
lines.push(unsetSecretsLine());
|
|
227
226
|
}
|
|
@@ -237,9 +236,11 @@ function buildUnwrappedHostLaunchCommand(arguments_) {
|
|
|
237
236
|
/**
|
|
238
237
|
* Safehouse launch. Two Safehouse wraps, by design:
|
|
239
238
|
*
|
|
240
|
-
* 1. **
|
|
241
|
-
*
|
|
242
|
-
* egress-restricted,
|
|
239
|
+
* 1. **prepareWorktree wrap**: plain
|
|
240
|
+
* `safehouse-clearance ... sh -c '<prepareWorktree>'`. Runs the repo
|
|
241
|
+
* preparation hook filesystem-isolated and egress-restricted,
|
|
242
|
+
* **without** inheriting agent-profile grants. Omitted entirely when no
|
|
243
|
+
* hook command is configured.
|
|
243
244
|
* 2. **Agent wrap**: `safehouse-clearance "$shim" -c '<exec agent>' sh "$_p"`
|
|
244
245
|
* where `$shim` is a `mktemp`-d symlink to `/bin/sh` named after the
|
|
245
246
|
* agent (e.g. `claude`). Safehouse selects the matching agent profile
|
|
@@ -253,14 +254,14 @@ function buildUnwrappedHostLaunchCommand(arguments_) {
|
|
|
253
254
|
* from which `stageBuildSecrets` reads them) nor file-sourced values — and keeps
|
|
254
255
|
* stale same-named ambient credentials from being forwarded. `secrets.env` is
|
|
255
256
|
* then sourced into the host launch shell so Safehouse can forward build secrets
|
|
256
|
-
* into the **
|
|
257
|
-
* them otherwise). After
|
|
257
|
+
* into the **prepareWorktree wrap** via `--env-pass=` (Safehouse's `--env=FILE` mode strips
|
|
258
|
+
* them otherwise). After prepareWorktree returns, `BUILD_SECRET_NAMES` are `unset` again
|
|
258
259
|
* on the host so they cannot reach the agent wrap.
|
|
259
260
|
*
|
|
260
261
|
* `--env-pass` composition is split per wrap (deliberate, post PR #128):
|
|
261
|
-
* -
|
|
262
|
+
* - prepareWorktree wrap forwards build secrets only.
|
|
262
263
|
* - Agent wrap forwards `preLaunchEnv` names only. preLaunch credentials never
|
|
263
|
-
* reach the profile-neutral
|
|
264
|
+
* reach the profile-neutral prepare phase.
|
|
264
265
|
*/
|
|
265
266
|
function buildSafehouseLaunchCommand(arguments_) {
|
|
266
267
|
const promptDir = path.dirname(arguments_.promptFile);
|
|
@@ -270,15 +271,17 @@ function buildSafehouseLaunchCommand(arguments_) {
|
|
|
270
271
|
worktreeDir: arguments_.worktreeDir,
|
|
271
272
|
sandboxName: "",
|
|
272
273
|
});
|
|
273
|
-
const
|
|
274
|
+
const prepareWorktreeCommand = arguments_.prepareWorktreeCommand === undefined
|
|
275
|
+
? undefined
|
|
276
|
+
: prepareWorktreeWithStatusReporting(arguments_.prepareWorktreeCommand);
|
|
274
277
|
const agentCommand = `exec ${agentCmd} "$@"`;
|
|
275
|
-
// Split --env-pass per wrap: the
|
|
278
|
+
// Split --env-pass per wrap: the prepareWorktree wrap only needs build secrets (so
|
|
276
279
|
// `npm install` etc. can authenticate); the agent wrap only needs the
|
|
277
280
|
// user's preLaunchEnv (build secrets are `unset` on the host between the
|
|
278
281
|
// two wraps, so forwarding them here would silently no-op). Keeps preLaunch
|
|
279
|
-
// credentials out of the profile-neutral
|
|
282
|
+
// credentials out of the profile-neutral prepare phase — see PR #128.
|
|
280
283
|
// Trailing space keeps each flag separated from the next argv token.
|
|
281
|
-
const
|
|
284
|
+
const prepareWorktreeEnvPassFlag = arguments_.secretsFile === undefined ? "" : `--env-pass=${BUILD_SECRET_NAMES.join(",")} `;
|
|
282
285
|
const preLaunchEnvNames = arguments_.definition.preLaunchEnv ?? [];
|
|
283
286
|
const agentEnvPassFlag = preLaunchEnvNames.length === 0 ? "" : `--env-pass=${preLaunchEnvNames.join(",")} `;
|
|
284
287
|
const safehouseWrapper = shellSingleQuote(SAFEHOUSE_CLEARANCE_WRAPPER_PATH);
|
|
@@ -298,7 +301,10 @@ function buildSafehouseLaunchCommand(arguments_) {
|
|
|
298
301
|
lines.push(unsetEnvironmentLine([...BUILD_SECRET_NAMES, ...preLaunchEnvNames]));
|
|
299
302
|
lines.push(renderPreLaunch(arguments_.definition.preLaunch, arguments_.worktreeDir));
|
|
300
303
|
}
|
|
301
|
-
lines.push(...hostSourceSecrets(arguments_.secretsFile), `_p=$(cat ${shellSingleQuote(arguments_.promptFile)})`, `rm -rf ${shellSingleQuote(promptDir)}
|
|
304
|
+
lines.push(...hostSourceSecrets(arguments_.secretsFile), `_p=$(cat ${shellSingleQuote(arguments_.promptFile)})`, `rm -rf ${shellSingleQuote(promptDir)}`);
|
|
305
|
+
if (prepareWorktreeCommand !== undefined) {
|
|
306
|
+
lines.push(`${safehouseWrapper} ${prepareWorktreeEnvPassFlag}sh -c ${shellSingleQuote(prepareWorktreeCommand)}`);
|
|
307
|
+
}
|
|
302
308
|
if (arguments_.secretsFile !== undefined) {
|
|
303
309
|
lines.push(unsetSecretsLine());
|
|
304
310
|
}
|
|
@@ -321,8 +327,10 @@ function buildSdxLaunchCommand(arguments_) {
|
|
|
321
327
|
worktreeDir: arguments_.worktreeDir,
|
|
322
328
|
sandboxName: arguments_.sandboxName,
|
|
323
329
|
});
|
|
324
|
-
const
|
|
325
|
-
|
|
330
|
+
const innerParts = [];
|
|
331
|
+
if (arguments_.prepareWorktreeCommand !== undefined) {
|
|
332
|
+
innerParts.push(prepareWorktreeWithStatusReporting(arguments_.prepareWorktreeCommand));
|
|
333
|
+
}
|
|
326
334
|
if (arguments_.secretsFile !== undefined) {
|
|
327
335
|
innerParts.push(unsetSecretsLine());
|
|
328
336
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { HookCommands } from "./config.ts";
|
|
2
|
+
interface ResolvePrepareWorktreeCommandArguments {
|
|
3
|
+
worktreeDir: string;
|
|
4
|
+
defaultHooks: HookCommands;
|
|
5
|
+
}
|
|
6
|
+
export declare function resolvePrepareWorktreeCommand(arguments_: ResolvePrepareWorktreeCommandArguments): string | undefined;
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=repositoryHooks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repositoryHooks.d.ts","sourceRoot":"","sources":["../../src/lib/repositoryHooks.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAIhD,UAAU,sCAAsC;IAC9C,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,YAAY,CAAC;CAC5B;AAED,wBAAgB,6BAA6B,CAC3C,UAAU,EAAE,sCAAsC,GACjD,MAAM,GAAG,SAAS,CAGpB"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const REPOSITORY_CONFIG_RELATIVE_PATH = ".groundcrew/config.json";
|
|
4
|
+
export function resolvePrepareWorktreeCommand(arguments_) {
|
|
5
|
+
const repositoryConfig = readRepositoryConfig(arguments_.worktreeDir);
|
|
6
|
+
return repositoryConfig?.hooks.prepareWorktree ?? arguments_.defaultHooks.prepareWorktree;
|
|
7
|
+
}
|
|
8
|
+
function readRepositoryConfig(worktreeDir) {
|
|
9
|
+
const configPath = path.join(worktreeDir, REPOSITORY_CONFIG_RELATIVE_PATH);
|
|
10
|
+
let contents;
|
|
11
|
+
try {
|
|
12
|
+
contents = readFileSync(configPath, "utf8");
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (isFileNotFoundError(error)) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
throw new Error(`Could not read ${REPOSITORY_CONFIG_RELATIVE_PATH}.`, { cause: error });
|
|
19
|
+
}
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
parsed = JSON.parse(contents);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
throw new Error(`${REPOSITORY_CONFIG_RELATIVE_PATH}: expected valid JSON.`, { cause: error });
|
|
26
|
+
}
|
|
27
|
+
return normalizeRepositoryConfig(parsed);
|
|
28
|
+
}
|
|
29
|
+
function normalizeRepositoryConfig(value) {
|
|
30
|
+
if (!isPlainObject(value)) {
|
|
31
|
+
fail("must be a JSON object");
|
|
32
|
+
}
|
|
33
|
+
if (value["version"] !== 1) {
|
|
34
|
+
fail("version must be 1");
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
hooks: normalizeHookCommands(value["hooks"]),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function normalizeHookCommands(value) {
|
|
41
|
+
if (value === undefined) {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
if (!isPlainObject(value)) {
|
|
45
|
+
fail("hooks must be an object");
|
|
46
|
+
}
|
|
47
|
+
const hooks = {};
|
|
48
|
+
const prepareWorktree = normalizeOptionalHookCommand(value["prepareWorktree"], "hooks.prepareWorktree");
|
|
49
|
+
if (prepareWorktree !== undefined) {
|
|
50
|
+
hooks.prepareWorktree = prepareWorktree;
|
|
51
|
+
}
|
|
52
|
+
return hooks;
|
|
53
|
+
}
|
|
54
|
+
function normalizeOptionalHookCommand(value, configKey) {
|
|
55
|
+
if (value === undefined) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
59
|
+
fail(`${configKey} must be a non-empty string`);
|
|
60
|
+
}
|
|
61
|
+
return value.trim();
|
|
62
|
+
}
|
|
63
|
+
function fail(message) {
|
|
64
|
+
throw new Error(`${REPOSITORY_CONFIG_RELATIVE_PATH}: ${message}`);
|
|
65
|
+
}
|
|
66
|
+
function isFileNotFoundError(error) {
|
|
67
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
68
|
+
}
|
|
69
|
+
function isPlainObject(value) {
|
|
70
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
71
|
+
}
|
|
@@ -7,7 +7,7 @@ Groundcrew launches agent processes _inside_ an isolation backend (safehouse on
|
|
|
7
7
|
## Considered Options
|
|
8
8
|
|
|
9
9
|
- **Keep the sdx lifecycle commands** — rejected: they reimplement `sbx run`/`sbx exec` setup flows, and every concept they expose (`authRecipes`, `template`, `kits`, `gitDefaults`) is a sandbox concern, not an orchestration concern.
|
|
10
|
-
- **Generalize the launch wrap to a user-supplied template string** — rejected for now: the build-time-secrets and
|
|
10
|
+
- **Generalize the launch wrap to a user-supplied template string** — rejected for now: the build-time-secrets and `prepareWorktree` plumbing inside the sdx wrap is awkward to express in a user template. Kept a small `safehouse | sdx | none` WRAP enum in core instead.
|
|
11
11
|
|
|
12
12
|
## Consequences
|
|
13
13
|
|