@clipboard-health/groundcrew 2.2.0 → 2.3.1
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 +72 -8
- package/configExample.ts +9 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +22 -10
- package/dist/commands/dispatcher.d.ts.map +1 -1
- package/dist/commands/dispatcher.js +10 -35
- package/dist/commands/doctor.d.ts +4 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +60 -13
- package/dist/commands/eligibility.d.ts +14 -0
- package/dist/commands/eligibility.d.ts.map +1 -1
- package/dist/commands/eligibility.js +44 -0
- package/dist/commands/setupWorkspace.d.ts.map +1 -1
- package/dist/commands/setupWorkspace.js +23 -4
- package/dist/commands/ticketDoctor.d.ts +48 -0
- package/dist/commands/ticketDoctor.d.ts.map +1 -0
- package/dist/commands/ticketDoctor.js +402 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/lib/boardSource.d.ts +55 -0
- package/dist/lib/boardSource.d.ts.map +1 -1
- package/dist/lib/boardSource.js +171 -26
- package/dist/lib/config.d.ts +63 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +75 -7
- package/dist/lib/dockerSandbox.d.ts +40 -0
- package/dist/lib/dockerSandbox.d.ts.map +1 -0
- package/dist/lib/dockerSandbox.js +58 -0
- package/dist/lib/host.d.ts +10 -0
- package/dist/lib/host.d.ts.map +1 -1
- package/dist/lib/host.js +8 -3
- package/dist/lib/launchCommand.d.ts +17 -3
- package/dist/lib/launchCommand.d.ts.map +1 -1
- package/dist/lib/launchCommand.js +66 -8
- package/dist/lib/localRunner.d.ts +22 -1
- package/dist/lib/localRunner.d.ts.map +1 -1
- package/dist/lib/localRunner.js +48 -5
- package/dist/lib/workspaces.d.ts.map +1 -1
- package/dist/lib/workspaces.js +138 -40
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
22
|
-
*
|
|
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,
|
|
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
|
-
|
|
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;
|
|
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"}
|
package/dist/lib/localRunner.js
CHANGED
|
@@ -1,8 +1,51 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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;
|
|
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"}
|
package/dist/lib/workspaces.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
65
|
+
function extractCmuxOpenId(output) {
|
|
58
66
|
try {
|
|
59
|
-
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- cmux --json prints a
|
|
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
|
|
62
|
-
if (
|
|
63
|
-
return
|
|
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
|
-
|
|
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",
|
|
176
|
+
arguments_.push("--workspace", workspaceId);
|
|
81
177
|
await runWorkspaceCommand("cmux", arguments_, signal);
|
|
82
178
|
}
|
|
83
|
-
async function closeCmuxWorkspace(
|
|
84
|
-
await runWorkspaceCommand("cmux", ["close-workspace", "--workspace",
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
"--
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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(
|
|
203
|
+
await applyCmuxStatus(workspaceId, spec.status, signal);
|
|
106
204
|
}
|
|
107
205
|
catch (error) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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.
|
|
231
|
+
await closeCmuxWorkspace(match.id, signal);
|
|
134
232
|
}
|
|
135
233
|
catch (error) {
|
|
136
234
|
if (isSignalAborted(signal)) {
|
package/package.json
CHANGED