@love-moon/conductor-cli 0.8.0 → 0.10.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/CHANGELOG.md +44 -0
- package/bin/conductor-config.js +34 -0
- package/bin/conductor-diagnose.js +3 -1
- package/bin/conductor-fire.js +41 -2
- package/bin/conductor-remote-exec.js +365 -0
- package/bin/conductor-update.js +13 -0
- package/bin/conductor.js +5 -0
- package/package.json +5 -5
- package/src/ai-manager-handlers.js +5 -2
- package/src/daemon.js +170 -5
- package/src/remote-exec-handlers.js +421 -0
- package/src/runtime-backends.js +69 -7
- package/src/version-check.js +23 -0
|
@@ -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
|
+
}
|
package/src/runtime-backends.js
CHANGED
|
@@ -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`
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
|
@@ -365,6 +366,46 @@ function readConfigEnvValue(configFilePath, key) {
|
|
|
365
366
|
}
|
|
366
367
|
}
|
|
367
368
|
|
|
369
|
+
// Read the top-level `disable_built_in_cli_list` config key: the mirror of
|
|
370
|
+
// `allow_cli_list` for the command-optional built-in backends (copilot, dsh)
|
|
371
|
+
// that are advertised WITHOUT any allow_cli_list entry. Listing a name here
|
|
372
|
+
// suppresses that automatic advertising so an operator can opt out of a
|
|
373
|
+
// built-in they don't want. Scoped to command-optional built-ins on purpose:
|
|
374
|
+
// a stray external or PATH-CLI name (e.g. "codex") is a documented no-op,
|
|
375
|
+
// since those never auto-advertise in the first place.
|
|
376
|
+
function readDisabledBuiltInBackends(configFilePath) {
|
|
377
|
+
const targetPath = resolveConductorConfigPath(configFilePath);
|
|
378
|
+
try {
|
|
379
|
+
if (!targetPath || !fs.existsSync(targetPath)) {
|
|
380
|
+
return new Set();
|
|
381
|
+
}
|
|
382
|
+
const parsed = yaml.load(fs.readFileSync(targetPath, "utf8"));
|
|
383
|
+
const raw = parsed?.disable_built_in_cli_list;
|
|
384
|
+
if (!Array.isArray(raw)) {
|
|
385
|
+
return new Set();
|
|
386
|
+
}
|
|
387
|
+
return new Set(
|
|
388
|
+
raw
|
|
389
|
+
.map((value) => normalizeRuntimeBackendName(value))
|
|
390
|
+
.filter((value) => value && isCommandOptionalBuiltInRuntimeBackend(value)),
|
|
391
|
+
);
|
|
392
|
+
} catch {
|
|
393
|
+
return new Set();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Reuse a caller-provided disabled set when present (so a hot loop reads the
|
|
398
|
+
// config file once), else read it now. Keeps every call site one line.
|
|
399
|
+
function resolveDisabledBuiltIns(options = {}) {
|
|
400
|
+
return options.disabledBuiltIns instanceof Set
|
|
401
|
+
? options.disabledBuiltIns
|
|
402
|
+
: readDisabledBuiltInBackends(options.configFilePath);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function isDisabledBuiltInRuntimeBackend(backend, options = {}) {
|
|
406
|
+
return resolveDisabledBuiltIns(options).has(normalizeRuntimeBackendName(backend));
|
|
407
|
+
}
|
|
408
|
+
|
|
368
409
|
function resolveProviderModulePaths(options = {}) {
|
|
369
410
|
return [
|
|
370
411
|
...listProviderModulePaths(process.env.AISDK_PROVIDER_PATH),
|
|
@@ -576,6 +617,21 @@ export async function filterRuntimeSupportedAllowCliList(allowCliList, options =
|
|
|
576
617
|
}
|
|
577
618
|
|
|
578
619
|
export async function resolveConfiguredRuntimeBackend(backend, allowCliList, options = {}) {
|
|
620
|
+
const resolved = await resolveConfiguredRuntimeBackendInner(backend, allowCliList, options);
|
|
621
|
+
if (!resolved?.runtimeBackend) {
|
|
622
|
+
return resolved;
|
|
623
|
+
}
|
|
624
|
+
// A disabled command-optional built-in (copilot/dsh via
|
|
625
|
+
// `disable_built_in_cli_list`) is never resolvable, whether it reached here
|
|
626
|
+
// through an explicit allow_cli_list entry or the command-optional fallback.
|
|
627
|
+
const disabled = resolveDisabledBuiltIns(options);
|
|
628
|
+
if (disabled.has(resolved.runtimeBackend) || disabled.has(resolved.requestedBackend)) {
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
return resolved;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function resolveConfiguredRuntimeBackendInner(backend, allowCliList, options = {}) {
|
|
579
635
|
const normalizedBackend = normalizeRuntimeBackendName(backend);
|
|
580
636
|
if (!normalizedBackend || LEGACY_RUNTIME_BACKEND_ALIASES.has(normalizedBackend)) {
|
|
581
637
|
return null;
|
|
@@ -633,6 +689,9 @@ export async function listAdvertisedBackends(allowCliList, options = {}) {
|
|
|
633
689
|
)
|
|
634
690
|
: {};
|
|
635
691
|
const configuredBackends = Object.keys(filteredAllowCliList);
|
|
692
|
+
// Read the disable list once and thread it through every per-backend
|
|
693
|
+
// resolve below, so this whole pass touches the config file a single time.
|
|
694
|
+
const resolveOptions = { ...options, disabledBuiltIns: readDisabledBuiltInBackends(options.configFilePath) };
|
|
636
695
|
let runtimeBackends = [...BUILT_IN_RUNTIME_BACKENDS];
|
|
637
696
|
let discoveryError = null;
|
|
638
697
|
try {
|
|
@@ -646,7 +705,7 @@ export async function listAdvertisedBackends(allowCliList, options = {}) {
|
|
|
646
705
|
|
|
647
706
|
for (const backend of configuredBackends) {
|
|
648
707
|
try {
|
|
649
|
-
const configuredBackend = await resolveConfiguredRuntimeBackend(backend, filteredAllowCliList,
|
|
708
|
+
const configuredBackend = await resolveConfiguredRuntimeBackend(backend, filteredAllowCliList, resolveOptions);
|
|
650
709
|
if (!configuredBackend?.runtimeBackend) {
|
|
651
710
|
continue;
|
|
652
711
|
}
|
|
@@ -682,7 +741,10 @@ export async function listAdvertisedBackends(allowCliList, options = {}) {
|
|
|
682
741
|
}
|
|
683
742
|
|
|
684
743
|
const commandOptionalBuiltIns = BUILT_IN_RUNTIME_BACKENDS.filter(
|
|
685
|
-
(backend) =>
|
|
744
|
+
(backend) =>
|
|
745
|
+
isCommandOptionalBuiltInRuntimeBackend(backend) &&
|
|
746
|
+
!runtimeBackendMap[backend] &&
|
|
747
|
+
!resolveOptions.disabledBuiltIns.has(backend),
|
|
686
748
|
);
|
|
687
749
|
for (const backend of commandOptionalBuiltIns) {
|
|
688
750
|
runtimeBackendMap[backend] = backend;
|
package/src/version-check.js
CHANGED
|
@@ -13,6 +13,7 @@ export const DEFAULT_HOMEBREW_FORMULA = "lovemoon-ai/tap/conductor";
|
|
|
13
13
|
const DEFAULT_UPDATE_WINDOW = { startMinutes: 120, endMinutes: 240 };
|
|
14
14
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
15
15
|
const INSTALL_METHOD_FILENAME = ".install-method";
|
|
16
|
+
const GLOBAL_PACKAGE_MARKER = `${path.sep}lib${path.sep}node_modules${path.sep}`;
|
|
16
17
|
|
|
17
18
|
function resolveTimeoutMs(value) {
|
|
18
19
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
@@ -139,6 +140,28 @@ export function resolveInstallMethod(options = {}) {
|
|
|
139
140
|
}
|
|
140
141
|
}
|
|
141
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Derive the npm global prefix that the currently running package was installed into.
|
|
145
|
+
*
|
|
146
|
+
* A global install always lands at `<prefix>/lib/node_modules/<package>`, so the prefix is
|
|
147
|
+
* whatever precedes that marker. Returns `null` for layouts that are not a global install
|
|
148
|
+
* (git checkout, project-local `node_modules`), where the caller should leave npm's own
|
|
149
|
+
* prefix resolution alone.
|
|
150
|
+
*/
|
|
151
|
+
export function resolveGlobalInstallPrefix(packageRoot) {
|
|
152
|
+
if (typeof packageRoot !== "string" || !packageRoot.trim()) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const normalized = path.resolve(packageRoot);
|
|
157
|
+
const markerIndex = normalized.lastIndexOf(GLOBAL_PACKAGE_MARKER);
|
|
158
|
+
if (markerIndex <= 0) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return normalized.slice(0, markerIndex);
|
|
163
|
+
}
|
|
164
|
+
|
|
142
165
|
export function buildUpgradeCommand(options = {}) {
|
|
143
166
|
const installMethod = resolveInstallMethod(options);
|
|
144
167
|
if (installMethod === "homebrew") {
|