@love-moon/conductor-cli 0.7.7 → 0.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.
@@ -0,0 +1,421 @@
1
+ import { promises as fsp } from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { randomUUID } from "node:crypto";
5
+ import { StringDecoder } from "node:string_decoder";
6
+
7
+ import { resolveUserHome } from "./conductor-paths.js";
8
+
9
+ const VALID_ACTIONS = new Set(["exec", "status", "cancel"]);
10
+ const MAX_TAIL_CHARS = 64_000;
11
+ const MAX_RUNS = 200;
12
+ const MAX_CONCURRENT_RUNS = 32;
13
+ const MAX_ARGS = 256;
14
+ const DEFAULT_WAIT_MS = 30_000;
15
+ const MAX_WAIT_MS = 120_000;
16
+ /** Grace between SIGTERM and SIGKILL when a run is cancelled. */
17
+ const KILL_GRACE_MS = 5_000;
18
+ const NUL = String.fromCharCode(0);
19
+
20
+ export const REMOTE_EXEC_CAPABILITY = "remote_exec";
21
+
22
+ /**
23
+ * Run arbitrary commands on this daemon's host on behalf of the account that
24
+ * owns it.
25
+ *
26
+ * This is not a new trust boundary: the same account can already reach the same
27
+ * shell through `create_pty_task`, whose `entrypoint_type: "custom"` branch
28
+ * takes a caller-supplied command, argv, cwd and env, and whose server-side
29
+ * validation only checks `cols`/`rows`. The one case where this genuinely does
30
+ * add reach is a host whose node-pty probe failed — there `pty_task` is not
31
+ * advertised at all — which is why the capability is opt-out-able via
32
+ * `remote_exec: false` in the daemon's config.
33
+ *
34
+ * Commands are spawned without a shell: the caller sends argv, so nothing is
35
+ * re-parsed here. Callers that genuinely want a shell pass `-- bash -lc "..."`.
36
+ *
37
+ * @param {object} opts
38
+ * @param {typeof spawn} [opts.spawnFn]
39
+ * @param {string} [opts.defaultWorkspace] cwd used when the request omits one
40
+ */
41
+ export function createRemoteExecHandlers(opts = {}) {
42
+ const spawnFn = opts.spawnFn || spawn;
43
+ const defaultWorkspace = opts.defaultWorkspace || resolveUserHome();
44
+ const runs = new Map();
45
+
46
+ async function exec(args = {}) {
47
+ // Start the clock before any I/O. The server's waiter starts ticking the
48
+ // moment it sends the request, and its only margin is a few seconds of
49
+ // slack; a slow `stat` on a stale network mount would otherwise let the
50
+ // server time out first, stranding a running child whose runId the caller
51
+ // never learned.
52
+ const waitMs = clampWaitMs(args.timeoutMs ?? args.timeout_ms);
53
+ const waitDeadline = Date.now() + waitMs;
54
+
55
+ const command = normalizeCommand(args.command);
56
+ if (!command) {
57
+ throw new Error("exec requires a `command` string");
58
+ }
59
+ const argv = normalizeArgv(args.args);
60
+ const running = countRunning(runs);
61
+ if (running >= MAX_CONCURRENT_RUNS) {
62
+ throw new Error(
63
+ `too many concurrent remote exec runs on this daemon (${running}/${MAX_CONCURRENT_RUNS}); cancel one first`,
64
+ );
65
+ }
66
+ const cwd = await resolveWorkspace(args.workspace ?? args.workspace_path, defaultWorkspace);
67
+ const env = buildExecEnv(args.env);
68
+
69
+ const runState = {
70
+ runId: randomUUID(),
71
+ command,
72
+ args: argv,
73
+ workspace: cwd,
74
+ status: "running",
75
+ pid: null,
76
+ exitCode: null,
77
+ signal: null,
78
+ stdoutTail: "",
79
+ stderrTail: "",
80
+ truncated: false,
81
+ error: null,
82
+ startedAt: new Date().toISOString(),
83
+ finishedAt: null,
84
+ settleWaiters: [],
85
+ child: null,
86
+ cancelRequested: false,
87
+ killTimer: null,
88
+ };
89
+ rememberRun(runs, runState);
90
+
91
+ let child;
92
+ try {
93
+ child = spawnFn(command, argv, {
94
+ cwd,
95
+ env,
96
+ stdio: ["ignore", "pipe", "pipe"],
97
+ });
98
+ } catch (error) {
99
+ finishRun(runState, "failed", { error: errMsg(error) });
100
+ return toPublicRun(runState);
101
+ }
102
+
103
+ runState.child = child;
104
+ runState.pid = typeof child.pid === "number" ? child.pid : null;
105
+
106
+ // Decode per stream: a multi-byte character can straddle two `data` chunks,
107
+ // and a naive String(chunk) would turn each half into U+FFFD.
108
+ const stdoutDecoder = new StringDecoder("utf8");
109
+ const stderrDecoder = new StringDecoder("utf8");
110
+ child.stdout?.on("data", (chunk) => {
111
+ runState.stdoutTail = appendTail(runState, runState.stdoutTail, stdoutDecoder.write(chunk));
112
+ });
113
+ child.stderr?.on("data", (chunk) => {
114
+ runState.stderrTail = appendTail(runState, runState.stderrTail, stderrDecoder.write(chunk));
115
+ });
116
+ child.on("error", (error) => {
117
+ finishRun(runState, "failed", { error: errMsg(error) });
118
+ });
119
+ child.on("close", (code, signal) => {
120
+ runState.stdoutTail = appendTail(runState, runState.stdoutTail, stdoutDecoder.end());
121
+ runState.stderrTail = appendTail(runState, runState.stderrTail, stderrDecoder.end());
122
+ const status = runState.cancelRequested ? "cancelled" : code === 0 ? "completed" : "failed";
123
+ finishRun(runState, status, {
124
+ exitCode: typeof code === "number" ? code : null,
125
+ signal: signal || null,
126
+ });
127
+ });
128
+
129
+ await waitForSettle(runState, Math.max(0, waitDeadline - Date.now()));
130
+ return toPublicRun(runState);
131
+ }
132
+
133
+ async function status(args = {}) {
134
+ return toPublicRun(mustFindRun(runs, args.runId));
135
+ }
136
+
137
+ async function cancel(args = {}) {
138
+ const runState = mustFindRun(runs, args.runId);
139
+ if (runState.status !== "running") {
140
+ return toPublicRun(runState);
141
+ }
142
+ runState.cancelRequested = true;
143
+ try {
144
+ runState.child?.kill("SIGTERM");
145
+ } catch (error) {
146
+ runState.error = runState.error || errMsg(error);
147
+ }
148
+ // Escalate if the child ignores SIGTERM. The timer is unref'd so it can
149
+ // never hold the daemon's event loop open.
150
+ runState.killTimer = setTimeout(() => {
151
+ try {
152
+ runState.child?.kill("SIGKILL");
153
+ } catch {
154
+ /* already gone */
155
+ }
156
+ }, KILL_GRACE_MS);
157
+ runState.killTimer.unref?.();
158
+
159
+ await waitForSettle(runState, KILL_GRACE_MS * 2);
160
+ return toPublicRun(runState);
161
+ }
162
+
163
+ /**
164
+ * Run a single action and return a `result` object, never throwing.
165
+ * @param {{action:string,args?:object}} payload
166
+ */
167
+ async function dispatch(payload) {
168
+ const action = payload?.action;
169
+ if (!VALID_ACTIONS.has(action)) {
170
+ return { error: `unknown action: ${action}` };
171
+ }
172
+ try {
173
+ switch (action) {
174
+ case "exec":
175
+ return { result: await exec(payload?.args ?? {}) };
176
+ case "status":
177
+ return { result: await status(payload?.args ?? {}) };
178
+ case "cancel":
179
+ return { result: await cancel(payload?.args ?? {}) };
180
+ default:
181
+ return { error: `unhandled action: ${action}` };
182
+ }
183
+ } catch (err) {
184
+ return { error: errMsg(err) };
185
+ }
186
+ }
187
+
188
+ return { dispatch, runs, defaultWorkspace };
189
+ }
190
+
191
+ /**
192
+ * @param {object} client
193
+ * @param {ReturnType<typeof createRemoteExecHandlers>} handlers
194
+ * @param {object} payload
195
+ */
196
+ export async function handleRemoteExecRequest(client, handlers, payload) {
197
+ const requestId = payload?.request_id ? String(payload.request_id) : "";
198
+ const action = payload?.action ? String(payload.action) : "";
199
+ if (!requestId) {
200
+ return { error: "missing request_id" };
201
+ }
202
+
203
+ const response = await handlers.dispatch({
204
+ action,
205
+ args: payload?.args && typeof payload.args === "object" ? payload.args : {},
206
+ });
207
+
208
+ const outgoing = {
209
+ type: "remote_exec_response",
210
+ payload: {
211
+ request_id: requestId,
212
+ action,
213
+ ...(response?.error ? { error: response.error } : { result: response?.result }),
214
+ },
215
+ };
216
+ await client.sendJson(outgoing).catch(() => {});
217
+ return response;
218
+ }
219
+
220
+ function mustFindRun(runs, rawRunId) {
221
+ const runId = typeof rawRunId === "string" ? rawRunId.trim() : "";
222
+ if (!runId) {
223
+ throw new Error("this action requires a `runId` string");
224
+ }
225
+ const runState = runs.get(runId);
226
+ if (!runState) {
227
+ throw new Error(`remote exec run not found: ${runId}`);
228
+ }
229
+ return runState;
230
+ }
231
+
232
+ function countRunning(runs) {
233
+ let count = 0;
234
+ for (const entry of runs.values()) {
235
+ if (entry.status === "running") count += 1;
236
+ }
237
+ return count;
238
+ }
239
+
240
+ export function normalizeCommand(value) {
241
+ if (typeof value !== "string") {
242
+ return "";
243
+ }
244
+ const command = value.trim();
245
+ if (!command || command.includes(NUL)) {
246
+ return "";
247
+ }
248
+ return command;
249
+ }
250
+
251
+ export function normalizeArgv(value) {
252
+ if (value === undefined || value === null) {
253
+ return [];
254
+ }
255
+ if (!Array.isArray(value)) {
256
+ throw new Error("`args` must be an array of strings");
257
+ }
258
+ if (value.length > MAX_ARGS) {
259
+ throw new Error(`\`args\` must contain at most ${MAX_ARGS} entries`);
260
+ }
261
+ return value.map((entry) => {
262
+ if (typeof entry !== "string") {
263
+ throw new Error("`args` must contain only strings");
264
+ }
265
+ if (entry.includes(NUL)) {
266
+ throw new Error("`args` must not contain NUL bytes");
267
+ }
268
+ return entry;
269
+ });
270
+ }
271
+
272
+ export async function resolveWorkspace(value, fallback) {
273
+ const raw = typeof value === "string" ? value.trim() : "";
274
+ const target = raw ? expandHome(raw) : fallback;
275
+ const resolved = path.resolve(target);
276
+
277
+ let stat;
278
+ try {
279
+ stat = await fsp.stat(resolved);
280
+ } catch (error) {
281
+ if (error?.code === "ENOENT") {
282
+ throw new Error(`workspace does not exist: ${resolved}`);
283
+ }
284
+ throw error;
285
+ }
286
+ if (!stat.isDirectory()) {
287
+ throw new Error(`workspace is not a directory: ${resolved}`);
288
+ }
289
+ return resolved;
290
+ }
291
+
292
+ function expandHome(raw) {
293
+ if (raw === "~") {
294
+ return resolveUserHome();
295
+ }
296
+ if (raw.startsWith("~/")) {
297
+ return path.join(resolveUserHome(), raw.slice(2));
298
+ }
299
+ return raw;
300
+ }
301
+
302
+ export function clampWaitMs(value) {
303
+ const parsed = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
304
+ if (!Number.isFinite(parsed) || parsed <= 0) {
305
+ return DEFAULT_WAIT_MS;
306
+ }
307
+ return Math.min(Math.trunc(parsed), MAX_WAIT_MS);
308
+ }
309
+
310
+ /**
311
+ * Inherit the daemon environment minus `CONDUCTOR_*`, matching what
312
+ * `custom_commands` does. This keeps the agent token out of the child's env; it
313
+ * is NOT a confidentiality guarantee — the token still sits in
314
+ * `~/.conductor/config.yaml`, which an arbitrary command can simply read. Do
315
+ * not build any security control on top of this.
316
+ */
317
+ export function buildExecEnv(overrides) {
318
+ const env = { ...process.env };
319
+ for (const name of Object.keys(env)) {
320
+ if (name.startsWith("CONDUCTOR_")) {
321
+ delete env[name];
322
+ }
323
+ }
324
+ if (overrides && typeof overrides === "object" && !Array.isArray(overrides)) {
325
+ for (const [key, value] of Object.entries(overrides)) {
326
+ if (!key || key.includes("=") || key.includes(NUL)) {
327
+ throw new Error(`invalid env variable name: ${key}`);
328
+ }
329
+ if (typeof value !== "string" || value.includes(NUL)) {
330
+ throw new Error(`env variable ${key} must be a string`);
331
+ }
332
+ env[key] = value;
333
+ }
334
+ }
335
+ return env;
336
+ }
337
+
338
+ function waitForSettle(runState, waitMs) {
339
+ if (runState.status !== "running") {
340
+ return Promise.resolve();
341
+ }
342
+ return new Promise((resolve) => {
343
+ let settled = false;
344
+ const finish = () => {
345
+ if (settled) return;
346
+ settled = true;
347
+ clearTimeout(timer);
348
+ const index = runState.settleWaiters.indexOf(finish);
349
+ if (index >= 0) runState.settleWaiters.splice(index, 1);
350
+ resolve();
351
+ };
352
+ const timer = setTimeout(finish, waitMs);
353
+ timer.unref?.();
354
+ runState.settleWaiters.push(finish);
355
+ });
356
+ }
357
+
358
+ function rememberRun(runs, runState) {
359
+ runs.set(runState.runId, runState);
360
+ while (runs.size > MAX_RUNS) {
361
+ const oldest = [...runs.values()].find((entry) => entry.status !== "running")?.runId;
362
+ // Only finished runs are evictable, and `MAX_CONCURRENT_RUNS` bounds how
363
+ // many can be running at once, so the map cannot grow without limit.
364
+ if (!oldest) break;
365
+ runs.delete(oldest);
366
+ }
367
+ }
368
+
369
+ function finishRun(runState, status, updates = {}) {
370
+ if (runState.status !== "running") {
371
+ return;
372
+ }
373
+ runState.status = status;
374
+ runState.finishedAt = new Date().toISOString();
375
+ runState.exitCode = updates.exitCode ?? runState.exitCode;
376
+ runState.signal = updates.signal ?? runState.signal;
377
+ runState.error = updates.error ?? runState.error;
378
+ if (runState.killTimer) {
379
+ clearTimeout(runState.killTimer);
380
+ runState.killTimer = null;
381
+ }
382
+ runState.child = null;
383
+ for (const waiter of runState.settleWaiters.splice(0)) {
384
+ waiter();
385
+ }
386
+ }
387
+
388
+ function appendTail(runState, current, chunk) {
389
+ if (!chunk) {
390
+ return current;
391
+ }
392
+ const next = current + chunk;
393
+ if (next.length <= MAX_TAIL_CHARS) {
394
+ return next;
395
+ }
396
+ runState.truncated = true;
397
+ return next.slice(next.length - MAX_TAIL_CHARS);
398
+ }
399
+
400
+ function toPublicRun(runState) {
401
+ return {
402
+ runId: runState.runId,
403
+ command: runState.command,
404
+ args: runState.args,
405
+ workspace: runState.workspace,
406
+ status: runState.status,
407
+ pid: runState.pid,
408
+ exitCode: runState.exitCode,
409
+ signal: runState.signal,
410
+ error: runState.error,
411
+ startedAt: runState.startedAt,
412
+ finishedAt: runState.finishedAt,
413
+ stdoutTail: runState.stdoutTail,
414
+ stderrTail: runState.stderrTail,
415
+ truncated: runState.truncated,
416
+ };
417
+ }
418
+
419
+ function errMsg(err) {
420
+ return err?.message ?? String(err);
421
+ }
@@ -9,12 +9,13 @@ import { resolveConductorConfigPath } from "./conductor-paths.js";
9
9
  // CLI display order for built-in backends. ai-sdk owns the canonical set of
10
10
  // built-in backends; CLI just picks an ordering for "Supported Backends:" log
11
11
  // output. The self-check below ensures this list always matches ai-sdk's set.
12
- const BUILT_IN_RUNTIME_BACKENDS = ["codex", "claude", "kimi", "opencode", "copilot", "chat-web"];
12
+ const BUILT_IN_RUNTIME_BACKENDS = ["codex", "claude", "kimi", "opencode", "copilot", "chat-web", "dsh"];
13
13
  const BUILT_IN_RUNTIME_BACKEND_SET = new Set(BUILT_IN_RUNTIME_BACKENDS);
14
14
  // Backends that don't shell out to a CLI binary AND should be advertised
15
- // without any user-side allow_cli_list entry. `copilot` is the only such
16
- // backend today — it ships with @github/copilot-sdk as a hard dep so
17
- // every install gets it for free.
15
+ // without any user-side allow_cli_list entry. `copilot` ships with
16
+ // @github/copilot-sdk as a hard dep so every install gets it for free;
17
+ // `dsh` (DeepSeek Harness) ships its pinned runtime inside ai-sdk's own
18
+ // dependencies and is spawned via @deepseek-ai/dsh-sdk-client.
18
19
  //
19
20
  // chat-web is intentionally NOT here even though it's also command-optional
20
21
  // (it drives a Chromium browser, not a CLI). The reason: chat-web has
@@ -23,7 +24,7 @@ const BUILT_IN_RUNTIME_BACKEND_SET = new Set(BUILT_IN_RUNTIME_BACKENDS);
23
24
  // aliases like `web-chatgpt` / `web-gemini` is just confusing — the alias
24
25
  // IS the sub-provider choice. Users who want chat-web must declare an
25
26
  // explicit allow_cli_list entry (which is also where `--model` lives).
26
- const COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKENDS = ["copilot"];
27
+ const COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKENDS = ["copilot", "dsh"];
27
28
  const COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKEND_SET = new Set(COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKENDS);
28
29
 
29
30
  // Legacy aliases (e.g. "code" → "codex", "kimi-cli" → "kimi") are derived