@love-moon/conductor-cli 0.8.0 → 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.
- package/CHANGELOG.md +17 -0
- package/bin/conductor-config.js +24 -0
- package/bin/conductor-fire.js +30 -2
- package/bin/conductor-remote-exec.js +365 -0
- package/bin/conductor.js +5 -0
- package/package.json +5 -5
- package/src/ai-manager-handlers.js +5 -2
- package/src/daemon.js +97 -5
- package/src/remote-exec-handlers.js +421 -0
- package/src/runtime-backends.js +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @love-moon/conductor-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3a499cc: Add `conductor remote-exec` for running a single command on another daemon's
|
|
8
|
+
host, over a new `remote_exec_request`/`remote_exec_response` daemon protocol
|
|
9
|
+
pair gated by a `remote_exec` capability. Supports `--workspace`, `--env`,
|
|
10
|
+
`--timeout` with automatic polling for long commands, `--kill-on-timeout`, and
|
|
11
|
+
ssh-style exit codes. Hosts can decline with `remote_exec: false` in the config.
|
|
12
|
+
- a15b55d: Add per-turn multi-image and local context-file inputs, plus authenticated attachment materialization from Conductor Web to the executing daemon.
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- Updated dependencies [a15b55d]
|
|
17
|
+
- @love-moon/ai-sdk@0.9.0
|
|
18
|
+
- @love-moon/conductor-sdk@0.9.0
|
|
19
|
+
|
|
3
20
|
## 0.8.0
|
|
4
21
|
|
|
5
22
|
### Minor Changes
|
package/bin/conductor-config.js
CHANGED
|
@@ -6,6 +6,7 @@ import path from "node:path";
|
|
|
6
6
|
import process from "node:process";
|
|
7
7
|
import readline from "node:readline/promises";
|
|
8
8
|
import { execFileSync, execSync } from "node:child_process";
|
|
9
|
+
import { createRequire } from "node:module";
|
|
9
10
|
import yargs from "yargs/yargs";
|
|
10
11
|
import { hideBin } from "yargs/helpers";
|
|
11
12
|
import { RUNTIME_SUPPORTED_BACKENDS } from "../src/runtime-backends.js";
|
|
@@ -43,6 +44,11 @@ const DEFAULT_CLIs = {
|
|
|
43
44
|
execArgs: "",
|
|
44
45
|
description: "GitHub Copilot (built in via SDK)"
|
|
45
46
|
},
|
|
47
|
+
dsh: {
|
|
48
|
+
command: "dsh",
|
|
49
|
+
execArgs: "",
|
|
50
|
+
description: "DeepSeek Harness agent (built in via SDK; needs DEEPSEEK_API_KEY)"
|
|
51
|
+
},
|
|
46
52
|
// chat-web is the runtime backend (an in-process Chromium driver, not a CLI
|
|
47
53
|
// binary). It has multiple sub-providers selected via --model. Each user-
|
|
48
54
|
// facing alias below resolves to the chat-web runtime; advertising the bare
|
|
@@ -102,6 +108,20 @@ function isBuiltInChatWebAvailable() {
|
|
|
102
108
|
);
|
|
103
109
|
}
|
|
104
110
|
|
|
111
|
+
function isBuiltInDshAvailable() {
|
|
112
|
+
// The dsh runtime ships as pinned dependencies of @love-moon/ai-sdk (not of
|
|
113
|
+
// the CLI itself), so resolve it THROUGH ai-sdk's resolution context — a
|
|
114
|
+
// direct resolve from cli/bin fails under pnpm's isolated node_modules.
|
|
115
|
+
try {
|
|
116
|
+
const require = createRequire(import.meta.url);
|
|
117
|
+
const aiSdkEntry = require.resolve("@love-moon/ai-sdk");
|
|
118
|
+
createRequire(aiSdkEntry).resolve("@deepseek-ai/dsh-sdk-jsonrpc-demo/bin");
|
|
119
|
+
return true;
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
105
125
|
function buildConfigEntryLines(cli, info, { commented = false } = {}) {
|
|
106
126
|
const fullCommand = info.execArgs
|
|
107
127
|
? `${info.command} ${info.execArgs}`
|
|
@@ -335,6 +355,10 @@ function detectInstalledCLIs() {
|
|
|
335
355
|
detected.push(key);
|
|
336
356
|
continue;
|
|
337
357
|
}
|
|
358
|
+
if (runtimeBackend === "dsh" && isBuiltInDshAvailable()) {
|
|
359
|
+
detected.push(key);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
338
362
|
if (isCommandAvailable(info.command)) {
|
|
339
363
|
detected.push(key);
|
|
340
364
|
}
|
package/bin/conductor-fire.js
CHANGED
|
@@ -798,7 +798,7 @@ async function main() {
|
|
|
798
798
|
extraEnv: env,
|
|
799
799
|
extraHeaders: buildConductorConnectHeaders(pkgJson.version, {
|
|
800
800
|
backends: [cliArgs.backend],
|
|
801
|
-
capabilities: ["refresh_session_inplace"],
|
|
801
|
+
capabilities: ["refresh_session_inplace", "task_attachments_v1"],
|
|
802
802
|
}),
|
|
803
803
|
configFile: cliArgs.configFile,
|
|
804
804
|
onConnected: (event) => {
|
|
@@ -3069,7 +3069,13 @@ export class BridgeRunner {
|
|
|
3069
3069
|
}
|
|
3070
3070
|
|
|
3071
3071
|
async respondToMessage(message) {
|
|
3072
|
-
const
|
|
3072
|
+
const localAttachments = Array.isArray(message.attachments)
|
|
3073
|
+
? message.attachments.filter((attachment) =>
|
|
3074
|
+
attachment && typeof attachment === "object" && typeof (attachment.path || attachment.localPath) === "string"
|
|
3075
|
+
)
|
|
3076
|
+
: [];
|
|
3077
|
+
const rawContent = String(message.content || "").trim();
|
|
3078
|
+
const content = rawContent || (localAttachments.length ? "Analyze the attached files." : "");
|
|
3073
3079
|
if (!content) {
|
|
3074
3080
|
this.copilotLog(`skip empty message replyTo=${message?.message_id || "latest"}`);
|
|
3075
3081
|
return;
|
|
@@ -3091,6 +3097,22 @@ export class BridgeRunner {
|
|
|
3091
3097
|
String(message.role || "").toLowerCase() === "user" &&
|
|
3092
3098
|
content === this.pendingInitialPrompt;
|
|
3093
3099
|
const useInitialImages = isQueuedInitialPromptMessage && this.includeInitialImages;
|
|
3100
|
+
const nativeImageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
3101
|
+
const media = localAttachments
|
|
3102
|
+
.filter((attachment) => attachment.kind === "image" && nativeImageMimes.has(String(attachment.mimeType || "").toLowerCase()))
|
|
3103
|
+
.map((attachment) => ({
|
|
3104
|
+
kind: "image",
|
|
3105
|
+
path: attachment.path || attachment.localPath,
|
|
3106
|
+
mimeType: attachment.mimeType,
|
|
3107
|
+
name: attachment.name,
|
|
3108
|
+
}));
|
|
3109
|
+
const contextFiles = localAttachments
|
|
3110
|
+
.filter((attachment) => !media.some((image) => image.path === (attachment.path || attachment.localPath)))
|
|
3111
|
+
.map((attachment) => ({
|
|
3112
|
+
path: attachment.path || attachment.localPath,
|
|
3113
|
+
mimeType: attachment.mimeType,
|
|
3114
|
+
name: attachment.name,
|
|
3115
|
+
}));
|
|
3094
3116
|
if (
|
|
3095
3117
|
this.useSessionFileReplyStream &&
|
|
3096
3118
|
typeof this.backendSession?.setSessionReplyTarget === "function"
|
|
@@ -3140,6 +3162,8 @@ export class BridgeRunner {
|
|
|
3140
3162
|
|
|
3141
3163
|
const turnPromise = this.dispatchBackendTurn(content, {
|
|
3142
3164
|
useInitialImages,
|
|
3165
|
+
media,
|
|
3166
|
+
contextFiles,
|
|
3143
3167
|
onProgress: (payload) => {
|
|
3144
3168
|
void this.reportRuntimeStatus(payload, replyTo);
|
|
3145
3169
|
},
|
|
@@ -3330,8 +3354,12 @@ export class BridgeRunner {
|
|
|
3330
3354
|
const goalCapable = Boolean(
|
|
3331
3355
|
snapshot && snapshot.capabilities && snapshot.capabilities.goal === true,
|
|
3332
3356
|
);
|
|
3357
|
+
const hasAttachmentInputs =
|
|
3358
|
+
(Array.isArray(options.media) && options.media.length > 0) ||
|
|
3359
|
+
(Array.isArray(options.contextFiles) && options.contextFiles.length > 0);
|
|
3333
3360
|
const willRunGoal =
|
|
3334
3361
|
goalDirective != null &&
|
|
3362
|
+
!hasAttachmentInputs &&
|
|
3335
3363
|
goalCapable &&
|
|
3336
3364
|
typeof this.backendSession?.runGoal === "function";
|
|
3337
3365
|
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* conductor remote-exec — run one command on another daemon's host.
|
|
5
|
+
*
|
|
6
|
+
* conductor remote-exec --target ubuntu --workspace /home/duino/ws/holomotion ls .
|
|
7
|
+
* conductor remote-exec --target ubuntu -- bash -lc "pnpm build 2>&1 | tail -20"
|
|
8
|
+
*
|
|
9
|
+
* The command is sent as argv and spawned without a shell on the target, so
|
|
10
|
+
* quoting is not re-interpreted remotely. Pass `-- bash -lc "..."` when pipes
|
|
11
|
+
* or globs are actually wanted.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import process from "node:process";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
import { ConductorConfig, loadConfig } from "@love-moon/conductor-sdk";
|
|
20
|
+
import { envForExplicitConfigFile } from "../src/config-env.js";
|
|
21
|
+
import { resolveConductorConfigPath } from "../src/conductor-paths.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Following ssh: the remote command's exit code is passed through verbatim
|
|
25
|
+
* (0-254) and 255 is reserved for this CLI's own failures. Reusing 1/2/4 for
|
|
26
|
+
* local errors would make `grep` finding nothing (1) or `ls` on a missing path
|
|
27
|
+
* (2) indistinguishable from a network error or a usage mistake.
|
|
28
|
+
*/
|
|
29
|
+
const EXIT = { OK: 0, CLI_ERROR: 255 };
|
|
30
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
31
|
+
const POLL_INTERVAL_MS = 1_000;
|
|
32
|
+
/** How long a single request may block server-side before we switch to polling. */
|
|
33
|
+
const POST_WAIT_MS = 10_000;
|
|
34
|
+
|
|
35
|
+
const VALUE_FLAGS = new Map([
|
|
36
|
+
["--target", "target"],
|
|
37
|
+
["-t", "target"],
|
|
38
|
+
["--workspace", "workspace"],
|
|
39
|
+
["-w", "workspace"],
|
|
40
|
+
["--timeout", "timeout"],
|
|
41
|
+
["--config-file", "configFile"],
|
|
42
|
+
["--env", "env"],
|
|
43
|
+
["-e", "env"],
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const BOOL_FLAGS = new Map([
|
|
47
|
+
["--json", "json"],
|
|
48
|
+
["--kill-on-timeout", "killOnTimeout"],
|
|
49
|
+
["--help", "help"],
|
|
50
|
+
["-h", "help"],
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const isMainModule = (() => {
|
|
54
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
55
|
+
const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
|
|
56
|
+
return entryFile === currentFile;
|
|
57
|
+
})();
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Split argv into flags and the remote command.
|
|
61
|
+
*
|
|
62
|
+
* Both `... --workspace /p ls .` and `... --workspace /p -- ls .` are accepted:
|
|
63
|
+
* the first token that is not a recognized flag starts the remote command, and
|
|
64
|
+
* everything after it is passed through verbatim.
|
|
65
|
+
*/
|
|
66
|
+
export function parseArgs(argv) {
|
|
67
|
+
const options = { env: {}, json: false, help: false };
|
|
68
|
+
const command = [];
|
|
69
|
+
|
|
70
|
+
let index = 0;
|
|
71
|
+
while (index < argv.length) {
|
|
72
|
+
const token = argv[index];
|
|
73
|
+
|
|
74
|
+
if (token === "--") {
|
|
75
|
+
command.push(...argv.slice(index + 1));
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let name = token;
|
|
80
|
+
let inlineValue = null;
|
|
81
|
+
if (token.startsWith("--") && token.includes("=")) {
|
|
82
|
+
const splitAt = token.indexOf("=");
|
|
83
|
+
name = token.slice(0, splitAt);
|
|
84
|
+
inlineValue = token.slice(splitAt + 1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (BOOL_FLAGS.has(name) && inlineValue === null) {
|
|
88
|
+
options[BOOL_FLAGS.get(name)] = true;
|
|
89
|
+
index += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (VALUE_FLAGS.has(name)) {
|
|
94
|
+
const key = VALUE_FLAGS.get(name);
|
|
95
|
+
const value = inlineValue !== null ? inlineValue : argv[index + 1];
|
|
96
|
+
if (value === undefined) {
|
|
97
|
+
throw new UsageError(`${name} requires a value`);
|
|
98
|
+
}
|
|
99
|
+
if (key === "env") {
|
|
100
|
+
const splitAt = value.indexOf("=");
|
|
101
|
+
if (splitAt <= 0) {
|
|
102
|
+
throw new UsageError(`--env expects KEY=VALUE, got: ${value}`);
|
|
103
|
+
}
|
|
104
|
+
options.env[value.slice(0, splitAt)] = value.slice(splitAt + 1);
|
|
105
|
+
} else {
|
|
106
|
+
options[key] = value;
|
|
107
|
+
}
|
|
108
|
+
index += inlineValue !== null ? 1 : 2;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
command.push(...argv.slice(index));
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { options, command };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class UsageError extends Error {}
|
|
120
|
+
|
|
121
|
+
/** Accepts `500ms`, `30s`, `2m`, or a bare number of seconds. */
|
|
122
|
+
export function parseTimeoutMs(value) {
|
|
123
|
+
if (value === undefined || value === null || value === "") {
|
|
124
|
+
return DEFAULT_TIMEOUT_MS;
|
|
125
|
+
}
|
|
126
|
+
const raw = String(value).trim().toLowerCase();
|
|
127
|
+
const match = raw.match(/^(\d+(?:\.\d+)?)(ms|s|m)?$/);
|
|
128
|
+
if (!match) {
|
|
129
|
+
throw new UsageError(`invalid --timeout value: ${value}`);
|
|
130
|
+
}
|
|
131
|
+
const amount = Number.parseFloat(match[1]);
|
|
132
|
+
const unit = match[2] || "s";
|
|
133
|
+
const multiplier = unit === "ms" ? 1 : unit === "m" ? 60_000 : 1_000;
|
|
134
|
+
const ms = Math.round(amount * multiplier);
|
|
135
|
+
if (!Number.isFinite(ms) || ms <= 0) {
|
|
136
|
+
throw new UsageError(`invalid --timeout value: ${value}`);
|
|
137
|
+
}
|
|
138
|
+
return ms;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function loadCliConfig(configFile, env = process.env) {
|
|
142
|
+
const configPath = resolveConductorConfigPath(configFile, env);
|
|
143
|
+
const configEnv = envForExplicitConfigFile(configFile, env);
|
|
144
|
+
if (fs.existsSync(configPath)) {
|
|
145
|
+
return loadConfig(configPath, { env: configEnv });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const agentToken = typeof env.CONDUCTOR_AGENT_TOKEN === "string" ? env.CONDUCTOR_AGENT_TOKEN.trim() : "";
|
|
149
|
+
const backendUrl = typeof env.CONDUCTOR_BACKEND_URL === "string" ? env.CONDUCTOR_BACKEND_URL.trim() : "";
|
|
150
|
+
if (agentToken && backendUrl) {
|
|
151
|
+
return new ConductorConfig({ agentToken, backendUrl });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return loadConfig(configPath, { env: configEnv });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function callApi(config, method, pathname, body, fetchImpl) {
|
|
158
|
+
const url = new URL(pathname, config.backendUrl);
|
|
159
|
+
const response = await fetchImpl(url.toString(), {
|
|
160
|
+
method,
|
|
161
|
+
headers: {
|
|
162
|
+
Authorization: `Bearer ${config.agentToken}`,
|
|
163
|
+
Accept: "application/json",
|
|
164
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
165
|
+
},
|
|
166
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const text = await response.text();
|
|
170
|
+
let payload = null;
|
|
171
|
+
try {
|
|
172
|
+
payload = text ? JSON.parse(text) : null;
|
|
173
|
+
} catch {
|
|
174
|
+
payload = null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!response.ok) {
|
|
178
|
+
const message = payload?.error || text.trim() || `HTTP ${response.status}`;
|
|
179
|
+
const error = new Error(message);
|
|
180
|
+
error.status = response.status;
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
return payload;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
187
|
+
|
|
188
|
+
export function showHelp(consoleImpl = console) {
|
|
189
|
+
consoleImpl.log(`conductor remote-exec - run a command on another daemon's host
|
|
190
|
+
|
|
191
|
+
Usage:
|
|
192
|
+
conductor remote-exec --target <daemon> [options] <command> [args...]
|
|
193
|
+
conductor remote-exec --target <daemon> [options] -- <command> [args...]
|
|
194
|
+
|
|
195
|
+
Options:
|
|
196
|
+
-t, --target <daemon> Daemon name to run on (required)
|
|
197
|
+
-w, --workspace <path> Working directory on the target (default: target's home)
|
|
198
|
+
--timeout <dur> Overall deadline, e.g. 30s, 2m, 500ms (default: 60s)
|
|
199
|
+
-e, --env KEY=VALUE Extra environment variable (repeatable)
|
|
200
|
+
--json Print the raw run result as JSON
|
|
201
|
+
--kill-on-timeout Stop the remote command when --timeout is reached
|
|
202
|
+
(default: it keeps running on the target)
|
|
203
|
+
--config-file <p> Conductor config file to authenticate with
|
|
204
|
+
-h, --help Show this help
|
|
205
|
+
|
|
206
|
+
Notes:
|
|
207
|
+
The command is spawned without a shell. For pipes, globs or redirection use:
|
|
208
|
+
conductor remote-exec -t ubuntu -- bash -lc "ls | wc -l"
|
|
209
|
+
|
|
210
|
+
Exit codes follow ssh: the remote command's own code is passed through, and
|
|
211
|
+
255 means this CLI failed (bad usage, daemon offline, network error).
|
|
212
|
+
|
|
213
|
+
Examples:
|
|
214
|
+
conductor remote-exec --target ubuntu --workspace /home/duino/ws/holomotion ls .
|
|
215
|
+
conductor remote-exec -t ubuntu -w /srv/app -- git log --oneline -5
|
|
216
|
+
`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function runRemoteExec(argv, deps = {}) {
|
|
220
|
+
const consoleImpl = deps.console || console;
|
|
221
|
+
const fetchImpl = deps.fetch || globalThis.fetch;
|
|
222
|
+
const env = deps.env || process.env;
|
|
223
|
+
const sleep = deps.sleep || delay;
|
|
224
|
+
const now = deps.now || (() => Date.now());
|
|
225
|
+
|
|
226
|
+
let parsed;
|
|
227
|
+
try {
|
|
228
|
+
parsed = parseArgs(argv);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
consoleImpl.error(`Error: ${error.message}`);
|
|
231
|
+
return EXIT.CLI_ERROR;
|
|
232
|
+
}
|
|
233
|
+
const { options, command } = parsed;
|
|
234
|
+
|
|
235
|
+
if (options.help) {
|
|
236
|
+
showHelp(consoleImpl);
|
|
237
|
+
return EXIT.OK;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const target = typeof options.target === "string" ? options.target.trim() : "";
|
|
241
|
+
if (!target) {
|
|
242
|
+
consoleImpl.error("Error: --target <daemon> is required");
|
|
243
|
+
showHelp(consoleImpl);
|
|
244
|
+
return EXIT.CLI_ERROR;
|
|
245
|
+
}
|
|
246
|
+
if (command.length === 0) {
|
|
247
|
+
consoleImpl.error("Error: no command given");
|
|
248
|
+
showHelp(consoleImpl);
|
|
249
|
+
return EXIT.CLI_ERROR;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let timeoutMs;
|
|
253
|
+
try {
|
|
254
|
+
timeoutMs = parseTimeoutMs(options.timeout);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
consoleImpl.error(`Error: ${error.message}`);
|
|
257
|
+
return EXIT.CLI_ERROR;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let config;
|
|
261
|
+
try {
|
|
262
|
+
config = deps.config || loadCliConfig(options.configFile, env);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
consoleImpl.error(`Error: ${error.message}`);
|
|
265
|
+
return EXIT.CLI_ERROR;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const basePath = `/api/agents/${encodeURIComponent(target)}/exec`;
|
|
269
|
+
const deadline = now() + timeoutMs;
|
|
270
|
+
|
|
271
|
+
let run;
|
|
272
|
+
try {
|
|
273
|
+
run = await callApi(config, "POST", basePath, {
|
|
274
|
+
command: command[0],
|
|
275
|
+
args: command.slice(1),
|
|
276
|
+
...(options.workspace ? { workspace: options.workspace } : {}),
|
|
277
|
+
...(Object.keys(options.env).length > 0 ? { env: options.env } : {}),
|
|
278
|
+
// Deliberately short, and independent of `--timeout`: the overall deadline
|
|
279
|
+
// is owned by the poll loop below. Handing the daemon the full deadline
|
|
280
|
+
// would make one HTTP request block for it, and would leave the loop
|
|
281
|
+
// unreachable because the POST alone would consume the whole budget.
|
|
282
|
+
timeoutMs: Math.min(timeoutMs, POST_WAIT_MS),
|
|
283
|
+
}, fetchImpl);
|
|
284
|
+
} catch (error) {
|
|
285
|
+
consoleImpl.error(`Error: ${error.message}`);
|
|
286
|
+
return EXIT.CLI_ERROR;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let pollError = null;
|
|
290
|
+
while (run?.status === "running" && now() < deadline) {
|
|
291
|
+
if (!run.runId) {
|
|
292
|
+
pollError = new Error("daemon reported a running command but returned no runId");
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
await sleep(POLL_INTERVAL_MS);
|
|
296
|
+
try {
|
|
297
|
+
run = await callApi(
|
|
298
|
+
config,
|
|
299
|
+
"GET",
|
|
300
|
+
`${basePath}/runs/${encodeURIComponent(run.runId)}`,
|
|
301
|
+
null,
|
|
302
|
+
fetchImpl,
|
|
303
|
+
);
|
|
304
|
+
pollError = null;
|
|
305
|
+
} catch (error) {
|
|
306
|
+
// A saturated or briefly unreachable daemon can fail one status poll.
|
|
307
|
+
// Keep waiting until the caller's own deadline rather than aborting a
|
|
308
|
+
// long-running command that is still perfectly healthy.
|
|
309
|
+
pollError = error;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (run?.status === "running" && options.killOnTimeout && run.runId) {
|
|
314
|
+
try {
|
|
315
|
+
run = await callApi(
|
|
316
|
+
config,
|
|
317
|
+
"DELETE",
|
|
318
|
+
`${basePath}/runs/${encodeURIComponent(run.runId)}`,
|
|
319
|
+
null,
|
|
320
|
+
fetchImpl,
|
|
321
|
+
);
|
|
322
|
+
consoleImpl.error(`[conductor] deadline reached; stopped the command on ${target}`);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
consoleImpl.error(`[conductor] failed to stop the run on ${target}: ${error.message}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (options.json) {
|
|
329
|
+
consoleImpl.log(JSON.stringify(run, null, 2));
|
|
330
|
+
} else {
|
|
331
|
+
if (run?.stdoutTail) process.stdout.write(run.stdoutTail);
|
|
332
|
+
if (run?.stderrTail) process.stderr.write(run.stderrTail);
|
|
333
|
+
if (run?.truncated) {
|
|
334
|
+
consoleImpl.error(`[conductor] output truncated; showing the tail only`);
|
|
335
|
+
}
|
|
336
|
+
if (run?.error) {
|
|
337
|
+
consoleImpl.error(`Error: ${run.error}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (run?.status === "running") {
|
|
342
|
+
if (pollError) {
|
|
343
|
+
consoleImpl.error(`[conductor] last status poll failed: ${pollError.message}`);
|
|
344
|
+
}
|
|
345
|
+
consoleImpl.error(
|
|
346
|
+
`[conductor] still running on ${target} after ${timeoutMs}ms; ` +
|
|
347
|
+
`it keeps going there — poll GET ${basePath}/runs/${run.runId}, ` +
|
|
348
|
+
`stop it with DELETE on the same path, or use --kill-on-timeout`,
|
|
349
|
+
);
|
|
350
|
+
return EXIT.CLI_ERROR;
|
|
351
|
+
}
|
|
352
|
+
if (run?.status === "cancelled") {
|
|
353
|
+
return EXIT.CLI_ERROR;
|
|
354
|
+
}
|
|
355
|
+
if (typeof run?.exitCode === "number") {
|
|
356
|
+
return run.exitCode;
|
|
357
|
+
}
|
|
358
|
+
return run?.status === "completed" ? EXIT.OK : EXIT.CLI_ERROR;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (isMainModule) {
|
|
362
|
+
// `process.exitCode` rather than `process.exit()`: writes to a pipe are async,
|
|
363
|
+
// and exiting outright truncates them. Let the loop drain and end naturally.
|
|
364
|
+
process.exitCode = await runRemoteExec(process.argv.slice(2));
|
|
365
|
+
}
|
package/bin/conductor.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* project - Manage Conductor projects (list/show/create/...)
|
|
16
16
|
* issue - Manage issues (list/show/create/update/start/done)
|
|
17
17
|
* task - Manage tasks (create/list/show/send/messages/schedule)
|
|
18
|
+
* remote-exec - Run a command on another daemon's host
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -50,6 +51,7 @@ export function runConductorCli(args = argv, deps = {}) {
|
|
|
50
51
|
"project",
|
|
51
52
|
"issue",
|
|
52
53
|
"task",
|
|
54
|
+
"remote-exec",
|
|
53
55
|
];
|
|
54
56
|
|
|
55
57
|
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
@@ -135,6 +137,7 @@ Subcommands:
|
|
|
135
137
|
project Manage Conductor projects (list/show/create/...)
|
|
136
138
|
issue Manage issues (list/show/create/update/start/done)
|
|
137
139
|
task Manage tasks (create/list/show/send/messages/schedule)
|
|
140
|
+
remote-exec Run a command on another daemon's host
|
|
138
141
|
|
|
139
142
|
Options:
|
|
140
143
|
-h, --help Show this help message
|
|
@@ -159,6 +162,7 @@ Examples:
|
|
|
159
162
|
conductor task create --title "Refactor module" --prompt "Extract the parser" --backend codex
|
|
160
163
|
conductor task send <task-id> "please add a unit test"
|
|
161
164
|
conductor task schedule create <task-id> "follow up" --delay 10m
|
|
165
|
+
conductor remote-exec --target ubuntu --workspace /home/duino/ws/holomotion ls .
|
|
162
166
|
|
|
163
167
|
For subcommand-specific help:
|
|
164
168
|
conductor fire --help
|
|
@@ -172,6 +176,7 @@ For subcommand-specific help:
|
|
|
172
176
|
conductor project --help
|
|
173
177
|
conductor issue --help
|
|
174
178
|
conductor task --help
|
|
179
|
+
conductor remote-exec --help
|
|
175
180
|
|
|
176
181
|
Version: ${pkgJson.version}
|
|
177
182
|
`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@love-moon/conductor-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"gitCommitId": "
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"gitCommitId": "606e5a6",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/lovemoon-ai/conductor.git"
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"test": "node --test test/*.test.js"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@love-moon/ai-sdk": "0.
|
|
28
|
-
"@love-moon/conductor-sdk": "0.
|
|
27
|
+
"@love-moon/ai-sdk": "0.9.0",
|
|
28
|
+
"@love-moon/conductor-sdk": "0.9.0",
|
|
29
29
|
"@github/copilot-sdk": "^0.3.0",
|
|
30
30
|
"chrome-launcher": "^1.2.1",
|
|
31
31
|
"chrome-remote-interface": "^0.33.0",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"optionalDependencies": {
|
|
40
40
|
"@roamhq/wrtc": "^0.10.0",
|
|
41
|
-
"@love-moon/chat-web": "0.
|
|
41
|
+
"@love-moon/chat-web": "0.9.0"
|
|
42
42
|
},
|
|
43
43
|
"pnpm": {
|
|
44
44
|
"onlyBuiltDependencies": [
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { AiManager } from "@love-moon/ai-sdk";
|
|
6
6
|
|
|
7
7
|
const VALID_ACTIONS = new Set(["status", "quota", "list_accounts", "switch_account"]);
|
|
8
|
-
const BASE_QUOTA_TOOLS = ["codex", "claude", "kimi", "copilot"];
|
|
8
|
+
const BASE_QUOTA_TOOLS = ["codex", "claude", "kimi", "copilot", "dsh"];
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* @param {object} opts
|
|
@@ -23,7 +23,7 @@ export function createAiManagerHandlers(opts = {}) {
|
|
|
23
23
|
manager.getCurrentCodexAccount().catch(() => null),
|
|
24
24
|
]);
|
|
25
25
|
const network = {};
|
|
26
|
-
const tools = [
|
|
26
|
+
const tools = [...BASE_QUOTA_TOOLS];
|
|
27
27
|
await Promise.all(
|
|
28
28
|
tools.map(async (tool) => {
|
|
29
29
|
if (install[tool]?.installed) {
|
|
@@ -70,6 +70,9 @@ export function createAiManagerHandlers(opts = {}) {
|
|
|
70
70
|
addJob("copilot", () => manager.getCopilotQuota({
|
|
71
71
|
forceRefresh,
|
|
72
72
|
}));
|
|
73
|
+
addJob("dsh", () => manager.getDshQuota({
|
|
74
|
+
forceRefresh,
|
|
75
|
+
}));
|
|
73
76
|
const externalBackends = pickExternalQuotaBackends(args);
|
|
74
77
|
for (const backend of externalBackends) {
|
|
75
78
|
if (!tools.has(backend)) {
|
package/src/daemon.js
CHANGED
|
@@ -30,6 +30,11 @@ import {
|
|
|
30
30
|
createCustomCommandHandlers,
|
|
31
31
|
handleCustomCommandsRequest,
|
|
32
32
|
} from "./custom-command-handlers.js";
|
|
33
|
+
import {
|
|
34
|
+
REMOTE_EXEC_CAPABILITY,
|
|
35
|
+
createRemoteExecHandlers,
|
|
36
|
+
handleRemoteExecRequest,
|
|
37
|
+
} from "./remote-exec-handlers.js";
|
|
33
38
|
import { resolveResumeContext } from "./fire/resume.js";
|
|
34
39
|
import {
|
|
35
40
|
filterRuntimeSupportedAllowCliList,
|
|
@@ -213,6 +218,32 @@ function getFireTmuxModeEnabled(userConfig) {
|
|
|
213
218
|
return false;
|
|
214
219
|
}
|
|
215
220
|
|
|
221
|
+
// Whether this host will accept `remote-exec`. Unlike `pty_task` (gated on a
|
|
222
|
+
// node-pty probe) and `custom_commands` (opt-in per script), remote exec would
|
|
223
|
+
// otherwise be unconditional, leaving a shared CI box or a root-owned daemon no
|
|
224
|
+
// way to decline. Defaults to enabled to match the other daemon capabilities.
|
|
225
|
+
//
|
|
226
|
+
// Resolution order:
|
|
227
|
+
// 1. CONDUCTOR_REMOTE_EXEC env var ("1"/"true"/"on" enable, "0"/"false"/"off" disable)
|
|
228
|
+
// 2. remote_exec boolean in the resolved Conductor config.yaml
|
|
229
|
+
// 3. Default: true
|
|
230
|
+
function getRemoteExecEnabled(userConfig) {
|
|
231
|
+
const rawEnv = process.env.CONDUCTOR_REMOTE_EXEC;
|
|
232
|
+
if (typeof rawEnv === "string" && rawEnv.trim()) {
|
|
233
|
+
const normalized = rawEnv.trim().toLowerCase();
|
|
234
|
+
if (normalized === "1" || normalized === "true" || normalized === "on" || normalized === "yes") {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
if (normalized === "0" || normalized === "false" || normalized === "off" || normalized === "no") {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (userConfig && typeof userConfig === "object" && userConfig.remote_exec === false) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
216
247
|
function normalizePlanLimitType(limitType) {
|
|
217
248
|
if (typeof limitType !== "string") {
|
|
218
249
|
return null;
|
|
@@ -799,6 +830,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
799
830
|
// warning and silently fall back to direct spawn rather than failing every
|
|
800
831
|
// create_task with ENOENT.
|
|
801
832
|
const FIRE_TMUX_MODE_ENABLED = getFireTmuxModeEnabled(userConfig);
|
|
833
|
+
const remoteExecEnabled = getRemoteExecEnabled(userConfig);
|
|
802
834
|
|
|
803
835
|
// Get allow_cli_list from config
|
|
804
836
|
const RAW_ALLOW_CLI_LIST = getRawAllowCliList(userConfig);
|
|
@@ -2196,11 +2228,19 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2196
2228
|
|
|
2197
2229
|
if (linkStat) {
|
|
2198
2230
|
if (!linkStat.isSymbolicLink()) {
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2231
|
+
// A real file/dir at the destination means something materialised
|
|
2232
|
+
// data inside the worktree (e.g. `pnpm install` replaced the
|
|
2233
|
+
// node_modules link with a real directory). It may hold real data,
|
|
2234
|
+
// so never clobber it — but throwing here made every task in this
|
|
2235
|
+
// worktree permanently un-restartable. Keep the local copy and skip
|
|
2236
|
+
// the link; remove the path manually (or drop the entry from
|
|
2237
|
+
// worktree.symlink) to restore sharing with the project workspace.
|
|
2238
|
+
logError(
|
|
2239
|
+
`[worktree] skipping symlink for ${configuredPath}: destination already exists and ` +
|
|
2240
|
+
`is not a symlink: ${linkPath}. Keeping the local copy — remove it manually to ` +
|
|
2241
|
+
`restore sharing via worktree.symlink in .conductor/settings.yaml.`,
|
|
2203
2242
|
);
|
|
2243
|
+
continue;
|
|
2204
2244
|
}
|
|
2205
2245
|
// Compare the link's TARGET, not whether that target resolves. A link
|
|
2206
2246
|
// that already points at the right place is correct even when the
|
|
@@ -2934,8 +2974,14 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2934
2974
|
"project_agents_registry",
|
|
2935
2975
|
"restart_daemon",
|
|
2936
2976
|
"refresh_session_inplace",
|
|
2977
|
+
"task_attachments_v1",
|
|
2937
2978
|
CUSTOM_COMMANDS_CAPABILITY,
|
|
2938
2979
|
];
|
|
2980
|
+
if (remoteExecEnabled) {
|
|
2981
|
+
advertisedCapabilities.push(REMOTE_EXEC_CAPABILITY);
|
|
2982
|
+
} else {
|
|
2983
|
+
log("[remote-exec] Disabled by config (remote_exec: false); capability not advertised");
|
|
2984
|
+
}
|
|
2939
2985
|
if (ptyTaskCapabilityEnabled) {
|
|
2940
2986
|
advertisedCapabilities.push("pty_task", "terminal_snapshot");
|
|
2941
2987
|
}
|
|
@@ -2944,6 +2990,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2944
2990
|
}
|
|
2945
2991
|
const aiManagerHandlers = createAiManagerHandlers({ configPath: effectiveConfigPath });
|
|
2946
2992
|
const customCommandHandlers = createCustomCommandHandlers({ configPath: effectiveConfigPath });
|
|
2993
|
+
const remoteExecHandlers = remoteExecEnabled
|
|
2994
|
+
? createRemoteExecHandlers({ defaultWorkspace: homeDir })
|
|
2995
|
+
: null;
|
|
2947
2996
|
|
|
2948
2997
|
const client = createWebSocketClient(sdkConfig, {
|
|
2949
2998
|
extraHeaders,
|
|
@@ -5171,6 +5220,40 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5171
5220
|
logError(`Unhandled custom_commands_request failure: ${error?.message || error}`);
|
|
5172
5221
|
});
|
|
5173
5222
|
}
|
|
5223
|
+
if (event.type === "remote_exec_request") {
|
|
5224
|
+
// Remote exec is the only execution path that leaves no Task row and no
|
|
5225
|
+
// per-run log file, so record it here — otherwise a run is invisible
|
|
5226
|
+
// once the daemon restarts. argv is deliberately omitted: it routinely
|
|
5227
|
+
// carries secrets and this log is collected by `collect_logs`.
|
|
5228
|
+
const execArgs = event?.payload?.args && typeof event.payload.args === "object" ? event.payload.args : {};
|
|
5229
|
+
log(
|
|
5230
|
+
`[remote-exec] ${event?.payload?.action || "?"} command=${execArgs.command || ""} ` +
|
|
5231
|
+
`argc=${Array.isArray(execArgs.args) ? execArgs.args.length : 0} cwd=${execArgs.workspace || "<default>"}`,
|
|
5232
|
+
);
|
|
5233
|
+
// Fail fast rather than letting the caller wait out its full timeout —
|
|
5234
|
+
// it has no other way to learn that this daemon will never answer.
|
|
5235
|
+
const rejectReason = !remoteExecHandlers
|
|
5236
|
+
? "remote exec is disabled on this daemon (remote_exec: false)"
|
|
5237
|
+
: daemonShuttingDown
|
|
5238
|
+
? "daemon is shutting down"
|
|
5239
|
+
: "";
|
|
5240
|
+
if (rejectReason) {
|
|
5241
|
+
void client
|
|
5242
|
+
.sendJson({
|
|
5243
|
+
type: "remote_exec_response",
|
|
5244
|
+
payload: {
|
|
5245
|
+
request_id: event?.payload?.request_id ? String(event.payload.request_id) : "",
|
|
5246
|
+
action: event?.payload?.action ? String(event.payload.action) : "",
|
|
5247
|
+
error: rejectReason,
|
|
5248
|
+
},
|
|
5249
|
+
})
|
|
5250
|
+
.catch(() => {});
|
|
5251
|
+
return;
|
|
5252
|
+
}
|
|
5253
|
+
handleRemoteExecRequest(client, remoteExecHandlers, event.payload).catch((error) => {
|
|
5254
|
+
logError(`Unhandled remote_exec_request failure: ${error?.message || error}`);
|
|
5255
|
+
});
|
|
5256
|
+
}
|
|
5174
5257
|
if (event.type === "restart_daemon") {
|
|
5175
5258
|
void handleRestartDaemon(event.payload).catch((error) => {
|
|
5176
5259
|
logError(`Unhandled restart_daemon failure: ${error?.message || error}`);
|
|
@@ -6218,6 +6301,10 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6218
6301
|
PWD: taskDir,
|
|
6219
6302
|
CONDUCTOR_PROJECT_ID: projectId,
|
|
6220
6303
|
CONDUCTOR_TASK_ID: taskId,
|
|
6304
|
+
// Fire derives its own stable host identity from the owning daemon plus
|
|
6305
|
+
// the task. Passing the resolved name keeps that identity meaningful
|
|
6306
|
+
// even when the daemon was named through the config file.
|
|
6307
|
+
CONDUCTOR_DAEMON_NAME: AGENT_NAME,
|
|
6221
6308
|
CONDUCTOR_LAUNCHED_BY_DAEMON: "1",
|
|
6222
6309
|
...(cliCommand ? { CONDUCTOR_CLI_COMMAND: cliCommand } : {}),
|
|
6223
6310
|
};
|
|
@@ -6732,7 +6819,11 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6732
6819
|
resolvedResumeCwd = await resolveRestartCwd({
|
|
6733
6820
|
taskId: normalizedTargetTaskId,
|
|
6734
6821
|
projectId: normalizedProjectId,
|
|
6735
|
-
|
|
6822
|
+
// A fork starts a fresh target-backend session, but its workspace
|
|
6823
|
+
// still belongs to the source task. Resolve the source session in
|
|
6824
|
+
// its own provider namespace; target + source session id is not a
|
|
6825
|
+
// meaningful pair for cross-backend handoff.
|
|
6826
|
+
backendType: sourceBackendType,
|
|
6736
6827
|
launchConfig: targetLaunchConfig,
|
|
6737
6828
|
sessionId: normalizedSourceSessionId,
|
|
6738
6829
|
sourceSessionFilePath: sourceSessionFilePath ? String(sourceSessionFilePath) : "",
|
|
@@ -6920,6 +7011,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6920
7011
|
PWD: taskDir,
|
|
6921
7012
|
CONDUCTOR_PROJECT_ID: normalizedProjectId,
|
|
6922
7013
|
CONDUCTOR_TASK_ID: normalizedTargetTaskId,
|
|
7014
|
+
CONDUCTOR_DAEMON_NAME: AGENT_NAME,
|
|
6923
7015
|
CONDUCTOR_LAUNCHED_BY_DAEMON: "1",
|
|
6924
7016
|
...(cliCommand ? { CONDUCTOR_CLI_COMMAND: cliCommand } : {}),
|
|
6925
7017
|
};
|
|
@@ -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
|