@love-moon/conductor-cli 0.11.2 → 0.12.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 +41 -0
- package/bin/conductor-config.js +4 -0
- package/bin/conductor-remote.js +93 -0
- package/bin/conductor-send-file.js +38 -16
- package/bin/conductor.js +6 -5
- package/package.json +5 -5
- package/src/daemon.js +149 -24
- package/src/remote/archive.js +129 -0
- package/src/remote/client.js +198 -0
- package/src/remote/cp.js +796 -0
- package/{bin/conductor-remote-exec.js → src/remote/exec.js} +126 -154
- package/src/remote-file-handlers.js +703 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# @love-moon/conductor-cli
|
|
2
2
|
|
|
3
|
+
## 0.12.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- b749180: Add `conductor remote cp` for copying a single file to or from another daemon's
|
|
8
|
+
host, and fold the existing `remote-exec` command into a `conductor remote`
|
|
9
|
+
umbrella alongside it.
|
|
10
|
+
|
|
11
|
+
- `conductor remote exec ...` replaces `conductor remote-exec ...`. Behaviour is
|
|
12
|
+
unchanged; only the command path moved.
|
|
13
|
+
- `conductor remote cp <src> <dst>` follows scp: exactly one side is written as
|
|
14
|
+
`<daemon>:<path>`, and that decides the direction. Contents are verified end
|
|
15
|
+
to end with SHA-256 and written via a temporary file plus rename, so an
|
|
16
|
+
interrupted copy never truncates an existing file.
|
|
17
|
+
- `conductor remote cp -r` copies a directory, matching scp's destination
|
|
18
|
+
rules. It packs with `tar`, moves the one tarball through the ordinary
|
|
19
|
+
transfer and unpacks on the other side, so permissions and symlinks survive
|
|
20
|
+
and the wire protocol is unchanged. Needs `tar` and `remote_exec` on the
|
|
21
|
+
target; the limit applies to the compressed size.
|
|
22
|
+
- New `remote_file` daemon capability, declinable with `remote_file: false` in
|
|
23
|
+
the config or `CONDUCTOR_REMOTE_FILE=0`, kept separate from `remote_exec` so
|
|
24
|
+
version skew fails with a clean 409 instead of a timeout.
|
|
25
|
+
- Transfers are chunked at 32 MiB with `Content-Range` and resume automatically
|
|
26
|
+
after a network blip, from the server's offset going up and from the local
|
|
27
|
+
partial file coming down. Chunking is also what keeps the server safe at the
|
|
28
|
+
raised limit: no single request is large or long-lived.
|
|
29
|
+
- The per-file limit is now 1 GiB, bounded by a global staged-bytes budget
|
|
30
|
+
(8 GiB), a per-user budget (2 GiB), a free-disk floor, and reservation by
|
|
31
|
+
declared size at creation.
|
|
32
|
+
- Bytes travel over HTTP through the backend rather than the agent WebSocket.
|
|
33
|
+
The socket only carries a small control message. See RFC 0037.
|
|
34
|
+
|
|
35
|
+
Deploying this needs the updated `web/nginx_conf`: the file-transfer routes set
|
|
36
|
+
`client_max_body_size` to 64m — a per-chunk cap, not a per-file one — and turn
|
|
37
|
+
off `proxy_request_buffering`.
|
|
38
|
+
|
|
39
|
+
### Patch Changes
|
|
40
|
+
|
|
41
|
+
- @love-moon/conductor-sdk@0.12.0
|
|
42
|
+
- @love-moon/ai-sdk@0.12.0
|
|
43
|
+
|
|
3
44
|
## 0.11.2
|
|
4
45
|
|
|
5
46
|
### Patch Changes
|
package/bin/conductor-config.js
CHANGED
|
@@ -387,6 +387,10 @@ async function main() {
|
|
|
387
387
|
"# Env: CONDUCTOR_REMOTE_EXEC",
|
|
388
388
|
"# remote_exec: true",
|
|
389
389
|
"",
|
|
390
|
+
"# Accept `conductor remote cp` file transfers for this host.",
|
|
391
|
+
"# Env: CONDUCTOR_REMOTE_FILE",
|
|
392
|
+
"# remote_file: true",
|
|
393
|
+
"",
|
|
390
394
|
"# Launch each Fire process inside a detached tmux session so it survives a",
|
|
391
395
|
"# daemon restart. Requires tmux on PATH. Env: CONDUCTOR_FIRE_TMUX_MODE",
|
|
392
396
|
"# fire_tmux_mode: false",
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* conductor remote — act on another daemon's host.
|
|
5
|
+
*
|
|
6
|
+
* conductor remote exec --target ubuntu -- bash -lc "pnpm build 2>&1 | tail -20"
|
|
7
|
+
* conductor remote cp ./build.tar.gz ubuntu:/srv/app/build.tar.gz
|
|
8
|
+
* conductor remote cp ubuntu:/var/log/conductor.log ./conductor.log
|
|
9
|
+
*
|
|
10
|
+
* Verb dispatch is hand-rolled rather than delegated to yargs on purpose:
|
|
11
|
+
* `exec` has to hand an arbitrary remote argv through untouched, and yargs'
|
|
12
|
+
* `strict()` would try to interpret the remote command's own flags.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import process from "node:process";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
import { EXIT } from "../src/remote/client.js";
|
|
20
|
+
import { runRemoteExec, showHelp as showExecHelp } from "../src/remote/exec.js";
|
|
21
|
+
import { runRemoteCp, showHelp as showCpHelp } from "../src/remote/cp.js";
|
|
22
|
+
|
|
23
|
+
export { EXIT };
|
|
24
|
+
|
|
25
|
+
const VERBS = new Map([
|
|
26
|
+
["exec", runRemoteExec],
|
|
27
|
+
["cp", runRemoteCp],
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const isMainModule = (() => {
|
|
31
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
32
|
+
const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
|
|
33
|
+
return entryFile === currentFile;
|
|
34
|
+
})();
|
|
35
|
+
|
|
36
|
+
export function showHelp(consoleImpl = console) {
|
|
37
|
+
consoleImpl.log(`conductor remote - act on another daemon's host
|
|
38
|
+
|
|
39
|
+
Usage:
|
|
40
|
+
conductor remote <verb> [options]
|
|
41
|
+
|
|
42
|
+
Verbs:
|
|
43
|
+
exec Run a command on another daemon's host
|
|
44
|
+
cp Copy a file to or from another daemon's host
|
|
45
|
+
|
|
46
|
+
Options:
|
|
47
|
+
-h, --help Show this help
|
|
48
|
+
|
|
49
|
+
Examples:
|
|
50
|
+
conductor remote exec --target ubuntu --workspace /srv/app -- git log --oneline -5
|
|
51
|
+
conductor remote cp ./build.tar.gz ubuntu:/srv/app/build.tar.gz
|
|
52
|
+
conductor remote cp ubuntu:/var/log/conductor.log ./conductor.log
|
|
53
|
+
|
|
54
|
+
For verb-specific help:
|
|
55
|
+
conductor remote exec --help
|
|
56
|
+
conductor remote cp --help
|
|
57
|
+
`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function runRemote(argv, deps = {}) {
|
|
61
|
+
const consoleImpl = deps.console || console;
|
|
62
|
+
|
|
63
|
+
if (argv.length === 0) {
|
|
64
|
+
showHelp(consoleImpl);
|
|
65
|
+
return EXIT.CLI_ERROR;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const verb = argv[0];
|
|
69
|
+
if (verb === "--help" || verb === "-h") {
|
|
70
|
+
showHelp(consoleImpl);
|
|
71
|
+
return EXIT.OK;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const handler = VERBS.get(verb);
|
|
75
|
+
if (!handler) {
|
|
76
|
+
// `--help` on a verb belongs to the verb, but a bare flag in the verb slot
|
|
77
|
+
// is a usage mistake worth naming precisely.
|
|
78
|
+
consoleImpl.error(`Error: unknown verb '${verb}'`);
|
|
79
|
+
consoleImpl.error(`Valid verbs: ${[...VERBS.keys()].join(", ")}`);
|
|
80
|
+
showHelp(consoleImpl);
|
|
81
|
+
return EXIT.CLI_ERROR;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return handler(argv.slice(1), deps);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export { showExecHelp, showCpHelp };
|
|
88
|
+
|
|
89
|
+
if (isMainModule) {
|
|
90
|
+
// `process.exitCode` rather than `process.exit()`: writes to a pipe are async,
|
|
91
|
+
// and exiting outright truncates them. Let the loop drain and end naturally.
|
|
92
|
+
process.exitCode = await runRemote(process.argv.slice(2));
|
|
93
|
+
}
|
|
@@ -182,34 +182,56 @@ export async function sendFileToTask(options) {
|
|
|
182
182
|
body.set("file", new Blob([fileBuffer], { type: mimeType }), fileName);
|
|
183
183
|
|
|
184
184
|
const content = typeof options.content === "string" ? options.content.trim() : "";
|
|
185
|
-
if (content) {
|
|
186
|
-
body.set("content", content);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
185
|
const role = typeof options.role === "string" && options.role.trim()
|
|
190
186
|
? options.role.trim().toLowerCase()
|
|
191
187
|
: "sdk";
|
|
192
|
-
|
|
188
|
+
const authHeaders = {
|
|
189
|
+
Authorization: `Bearer ${config.agentToken}`,
|
|
190
|
+
Accept: "application/json",
|
|
191
|
+
};
|
|
193
192
|
|
|
194
|
-
|
|
195
|
-
|
|
193
|
+
// Phase 1 stages the bytes. This endpoint only stores the file; the row it
|
|
194
|
+
// creates carries no message and expires within the attachment TTL.
|
|
195
|
+
const uploadUrl = new URL(`/api/tasks/${encodeURIComponent(taskId)}/attachments`, config.backendUrl);
|
|
196
|
+
const uploadResponse = await fetchImpl(String(uploadUrl), {
|
|
196
197
|
method: "POST",
|
|
197
|
-
headers:
|
|
198
|
-
Authorization: `Bearer ${config.agentToken}`,
|
|
199
|
-
Accept: "application/json",
|
|
200
|
-
},
|
|
198
|
+
headers: authHeaders,
|
|
201
199
|
body,
|
|
202
200
|
});
|
|
203
201
|
|
|
204
|
-
const
|
|
205
|
-
if (!
|
|
206
|
-
const details = formatErrorBody(
|
|
207
|
-
throw new Error(`Upload failed (${
|
|
202
|
+
const uploadText = await uploadResponse.text();
|
|
203
|
+
if (!uploadResponse.ok) {
|
|
204
|
+
const details = formatErrorBody(uploadText);
|
|
205
|
+
throw new Error(`Upload failed (${uploadResponse.status})${details ? `: ${details}` : ""}`);
|
|
206
|
+
}
|
|
207
|
+
const attachment = (uploadText ? JSON.parse(uploadText) : {}).attachment;
|
|
208
|
+
if (!attachment || !attachment.id) {
|
|
209
|
+
throw new Error("Upload succeeded but the server returned no attachment id");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Phase 2 binds the staged file to a message. Without it the attachment
|
|
213
|
+
// never reaches the chat UI and the janitor deletes it when staging expires.
|
|
214
|
+
const messageUrl = new URL(`/api/tasks/${encodeURIComponent(taskId)}/messages`, config.backendUrl);
|
|
215
|
+
const messageResponse = await fetchImpl(String(messageUrl), {
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: { ...authHeaders, "Content-Type": "application/json" },
|
|
218
|
+
body: JSON.stringify({
|
|
219
|
+
content: content || `Attached file: ${attachment.name}`,
|
|
220
|
+
role,
|
|
221
|
+
attachmentIds: [attachment.id],
|
|
222
|
+
}),
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const messageText = await messageResponse.text();
|
|
226
|
+
if (!messageResponse.ok) {
|
|
227
|
+
const details = formatErrorBody(messageText);
|
|
228
|
+
throw new Error(`Attach failed (${messageResponse.status})${details ? `: ${details}` : ""}`);
|
|
208
229
|
}
|
|
209
230
|
|
|
210
231
|
return {
|
|
211
232
|
taskId,
|
|
212
|
-
|
|
233
|
+
attachment,
|
|
234
|
+
response: messageText ? JSON.parse(messageText) : {},
|
|
213
235
|
};
|
|
214
236
|
}
|
|
215
237
|
|
package/bin/conductor.js
CHANGED
|
@@ -15,7 +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-
|
|
18
|
+
* remote - Run commands and copy files on another daemon's host
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -51,7 +51,7 @@ export function runConductorCli(args = argv, deps = {}) {
|
|
|
51
51
|
"project",
|
|
52
52
|
"issue",
|
|
53
53
|
"task",
|
|
54
|
-
"remote
|
|
54
|
+
"remote",
|
|
55
55
|
];
|
|
56
56
|
|
|
57
57
|
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
@@ -137,7 +137,7 @@ Subcommands:
|
|
|
137
137
|
project Manage Conductor projects (list/show/create/...)
|
|
138
138
|
issue Manage issues (list/show/create/update/start/done)
|
|
139
139
|
task Manage tasks (create/list/show/send/messages/schedule)
|
|
140
|
-
remote
|
|
140
|
+
remote Run commands and copy files on another daemon's host (exec/cp)
|
|
141
141
|
|
|
142
142
|
Options:
|
|
143
143
|
-h, --help Show this help message
|
|
@@ -162,7 +162,8 @@ Examples:
|
|
|
162
162
|
conductor task create --title "Refactor module" --prompt "Extract the parser" --backend codex
|
|
163
163
|
conductor task send <task-id> "please add a unit test"
|
|
164
164
|
conductor task schedule create <task-id> "follow up" --delay 10m
|
|
165
|
-
conductor remote
|
|
165
|
+
conductor remote exec --target ubuntu --workspace /home/duino/ws/holomotion ls .
|
|
166
|
+
conductor remote cp ./build.tar.gz ubuntu:/srv/app/build.tar.gz
|
|
166
167
|
|
|
167
168
|
For subcommand-specific help:
|
|
168
169
|
conductor fire --help
|
|
@@ -176,7 +177,7 @@ For subcommand-specific help:
|
|
|
176
177
|
conductor project --help
|
|
177
178
|
conductor issue --help
|
|
178
179
|
conductor task --help
|
|
179
|
-
conductor remote
|
|
180
|
+
conductor remote --help
|
|
180
181
|
|
|
181
182
|
Version: ${pkgJson.version}
|
|
182
183
|
`);
|
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.12.0",
|
|
4
|
+
"gitCommitId": "c1047e8",
|
|
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.12.0",
|
|
28
|
+
"@love-moon/conductor-sdk": "0.12.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.12.0"
|
|
42
42
|
},
|
|
43
43
|
"pnpm": {
|
|
44
44
|
"onlyBuiltDependencies": [
|
package/src/daemon.js
CHANGED
|
@@ -42,6 +42,11 @@ import {
|
|
|
42
42
|
createRemoteExecHandlers,
|
|
43
43
|
handleRemoteExecRequest,
|
|
44
44
|
} from "./remote-exec-handlers.js";
|
|
45
|
+
import {
|
|
46
|
+
REMOTE_FILE_CAPABILITY,
|
|
47
|
+
createRemoteFileHandlers,
|
|
48
|
+
handleRemoteFileRequest,
|
|
49
|
+
} from "./remote-file-handlers.js";
|
|
45
50
|
import {
|
|
46
51
|
UPDATE_DAEMON_CAPABILITY,
|
|
47
52
|
createDaemonUpdateHandlers,
|
|
@@ -288,6 +293,33 @@ function getRemoteExecEnabled(userConfig) {
|
|
|
288
293
|
return true;
|
|
289
294
|
}
|
|
290
295
|
|
|
296
|
+
// Whether this host will accept `conductor remote cp` file transfers (RFC
|
|
297
|
+
// 0037). Kept separate from `remote_exec` on purpose: the two are not distinct
|
|
298
|
+
// trust levels — anyone with exec can already `base64` a file — but a host may
|
|
299
|
+
// well want scripted commands without letting the account push bytes onto its
|
|
300
|
+
// disk, and a separate flag is the only way to say that.
|
|
301
|
+
//
|
|
302
|
+
// Resolution order:
|
|
303
|
+
// 1. CONDUCTOR_REMOTE_FILE env var ("1"/"true"/"on" enable, "0"/"false"/"off" disable)
|
|
304
|
+
// 2. remote_file boolean in the resolved Conductor config.yaml
|
|
305
|
+
// 3. Default: true
|
|
306
|
+
function getRemoteFileEnabled(userConfig) {
|
|
307
|
+
const rawEnv = process.env.CONDUCTOR_REMOTE_FILE;
|
|
308
|
+
if (typeof rawEnv === "string" && rawEnv.trim()) {
|
|
309
|
+
const normalized = rawEnv.trim().toLowerCase();
|
|
310
|
+
if (normalized === "1" || normalized === "true" || normalized === "on" || normalized === "yes") {
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
if (normalized === "0" || normalized === "false" || normalized === "off" || normalized === "no") {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (userConfig && typeof userConfig === "object" && userConfig.remote_file === false) {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
291
323
|
function normalizePlanLimitType(limitType) {
|
|
292
324
|
if (typeof limitType !== "string") {
|
|
293
325
|
return null;
|
|
@@ -1154,6 +1186,10 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1154
1186
|
// path would cost function without removing reach (RFC 0034 makes the same
|
|
1155
1187
|
// argument). An owner who wants it off can set `remote_exec: false`.
|
|
1156
1188
|
const remoteExecEnabled = getRemoteExecEnabled(userConfig);
|
|
1189
|
+
// Same reasoning for file transfer, with one addition: when the share scoped
|
|
1190
|
+
// the guest to a root, transfers are confined to it (`isPathInsideGuestRoot`,
|
|
1191
|
+
// a lexical check — misuse prevention, not a security boundary).
|
|
1192
|
+
const remoteFileEnabled = getRemoteFileEnabled(userConfig);
|
|
1157
1193
|
|
|
1158
1194
|
// Get allow_cli_list from config
|
|
1159
1195
|
const RAW_ALLOW_CLI_LIST = getRawAllowCliList(userConfig);
|
|
@@ -3764,6 +3800,11 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3764
3800
|
} else {
|
|
3765
3801
|
log("[remote-exec] Disabled by config (remote_exec: false); capability not advertised");
|
|
3766
3802
|
}
|
|
3803
|
+
if (remoteFileEnabled) {
|
|
3804
|
+
advertisedCapabilities.push(REMOTE_FILE_CAPABILITY);
|
|
3805
|
+
} else {
|
|
3806
|
+
log("[remote-file] Disabled by config (remote_file: false); capability not advertised");
|
|
3807
|
+
}
|
|
3767
3808
|
if (ptyTaskCapabilityEnabled) {
|
|
3768
3809
|
advertisedCapabilities.push("pty_task", "terminal_snapshot");
|
|
3769
3810
|
}
|
|
@@ -3831,6 +3872,13 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3831
3872
|
const remoteExecHandlers = remoteExecEnabled
|
|
3832
3873
|
? createRemoteExecHandlers({ defaultWorkspace: homeDir })
|
|
3833
3874
|
: null;
|
|
3875
|
+
const remoteFileHandlers = remoteFileEnabled
|
|
3876
|
+
? createRemoteFileHandlers({
|
|
3877
|
+
config: sdkConfig,
|
|
3878
|
+
agentHost: AGENT_NAME,
|
|
3879
|
+
guestRoot: IS_GUEST_DAEMON ? GUEST_ROOT : null,
|
|
3880
|
+
})
|
|
3881
|
+
: null;
|
|
3834
3882
|
|
|
3835
3883
|
const client = createWebSocketClient(sdkConfig, {
|
|
3836
3884
|
extraHeaders,
|
|
@@ -6334,6 +6382,50 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6334
6382
|
logError(`Unhandled remote_exec_request failure: ${error?.message || error}`);
|
|
6335
6383
|
});
|
|
6336
6384
|
}
|
|
6385
|
+
if (event.type === "remote_file_request") {
|
|
6386
|
+
// Same reason as remote exec: a transfer leaves no Task row and no
|
|
6387
|
+
// per-run log, so it would be invisible after a restart. Only the action,
|
|
6388
|
+
// the path and the byte count are recorded — never file contents, and
|
|
6389
|
+
// never the transfer token, because this log is collected by
|
|
6390
|
+
// `collect_logs`.
|
|
6391
|
+
const fileArgs = event?.payload?.args && typeof event.payload.args === "object" ? event.payload.args : {};
|
|
6392
|
+
log(
|
|
6393
|
+
`[remote-file] ${event?.payload?.action || "?"} path=${fileArgs.remotePath || fileArgs.remote_path || ""} ` +
|
|
6394
|
+
`bytes=${fileArgs.sizeBytes ?? fileArgs.size_bytes ?? "?"}`,
|
|
6395
|
+
);
|
|
6396
|
+
// Fail fast rather than letting the caller wait out its full timeout —
|
|
6397
|
+
// it has no other way to learn that this daemon will never answer.
|
|
6398
|
+
const rejectReason = !remoteFileHandlers
|
|
6399
|
+
? "remote file transfer is disabled on this daemon (remote_file: false)"
|
|
6400
|
+
: daemonShuttingDown
|
|
6401
|
+
? "daemon is shutting down"
|
|
6402
|
+
: "";
|
|
6403
|
+
if (rejectReason) {
|
|
6404
|
+
void client
|
|
6405
|
+
.sendJson({
|
|
6406
|
+
type: "remote_file_response",
|
|
6407
|
+
payload: {
|
|
6408
|
+
request_id: event?.payload?.request_id ? String(event.payload.request_id) : "",
|
|
6409
|
+
action: event?.payload?.action ? String(event.payload.action) : "",
|
|
6410
|
+
error: rejectReason,
|
|
6411
|
+
},
|
|
6412
|
+
})
|
|
6413
|
+
.catch(() => {});
|
|
6414
|
+
return;
|
|
6415
|
+
}
|
|
6416
|
+
handleRemoteFileRequest(client, remoteFileHandlers, event.payload)
|
|
6417
|
+
.then((response) => {
|
|
6418
|
+
if (response?.result) {
|
|
6419
|
+
log(
|
|
6420
|
+
`[remote-file] ${event?.payload?.action || "?"} ok path=${response.result.path || ""} ` +
|
|
6421
|
+
`bytes=${response.result.bytesWritten ?? response.result.sizeBytes ?? "?"}`,
|
|
6422
|
+
);
|
|
6423
|
+
}
|
|
6424
|
+
})
|
|
6425
|
+
.catch((error) => {
|
|
6426
|
+
logError(`Unhandled remote_file_request failure: ${error?.message || error}`);
|
|
6427
|
+
});
|
|
6428
|
+
}
|
|
6337
6429
|
if (event.type === "restart_daemon") {
|
|
6338
6430
|
void handleRestartDaemon(event.payload).catch((error) => {
|
|
6339
6431
|
logError(`Unhandled restart_daemon failure: ${error?.message || error}`);
|
|
@@ -7258,6 +7350,49 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7258
7350
|
});
|
|
7259
7351
|
}
|
|
7260
7352
|
|
|
7353
|
+
/**
|
|
7354
|
+
* Recover the workspace of a resumable session from the backend's own
|
|
7355
|
+
* session store. Used when neither a launch-config cwd nor a bound project
|
|
7356
|
+
* path is available -- the Default Project resuming a local session -- so the
|
|
7357
|
+
* task runs (and logs) where the conversation actually lives instead of an
|
|
7358
|
+
* unrelated scratch directory.
|
|
7359
|
+
*
|
|
7360
|
+
* Returns "" when the session cannot be located; callers keep their own
|
|
7361
|
+
* fallbacks. `runtimeBackend` short-circuits the alias lookup for callers
|
|
7362
|
+
* that already resolved it.
|
|
7363
|
+
*/
|
|
7364
|
+
async function resolveSessionResumeCwd({ backendType, runtimeBackend = "", sessionId }) {
|
|
7365
|
+
const normalizedSessionId = typeof sessionId === "string" ? sessionId.trim() : "";
|
|
7366
|
+
const normalizedBackend = normalizeRuntimeBackendName(backendType);
|
|
7367
|
+
// opencode has no resumable local session store to look the cwd up in.
|
|
7368
|
+
if (!normalizedSessionId || !normalizedBackend || normalizedBackend === "opencode") {
|
|
7369
|
+
return "";
|
|
7370
|
+
}
|
|
7371
|
+
try {
|
|
7372
|
+
let resumeBackend = normalizeOptionalString(runtimeBackend);
|
|
7373
|
+
if (!resumeBackend) {
|
|
7374
|
+
const configuredBackend = await resolveConfiguredRuntimeBackend(normalizedBackend, ALLOW_CLI_LIST, {
|
|
7375
|
+
configFilePath: config.CONFIG_FILE,
|
|
7376
|
+
});
|
|
7377
|
+
resumeBackend = configuredBackend?.runtimeBackend ||
|
|
7378
|
+
await normalizeRuntimeBackendAlias(normalizedBackend, { configFilePath: config.CONFIG_FILE });
|
|
7379
|
+
}
|
|
7380
|
+
const resumeContext = await (deps.resolveResumeContext || resolveResumeContext)(
|
|
7381
|
+
resumeBackend,
|
|
7382
|
+
normalizedSessionId,
|
|
7383
|
+
{
|
|
7384
|
+
cwd: process.cwd(),
|
|
7385
|
+
configFilePath: config.CONFIG_FILE,
|
|
7386
|
+
},
|
|
7387
|
+
);
|
|
7388
|
+
return normalizeOptionalString(resumeContext?.cwd) || "";
|
|
7389
|
+
} catch {
|
|
7390
|
+
// Provider lookup failed (unknown id, unreadable store): let the caller
|
|
7391
|
+
// fall back rather than failing the task here.
|
|
7392
|
+
return "";
|
|
7393
|
+
}
|
|
7394
|
+
}
|
|
7395
|
+
|
|
7261
7396
|
async function resolveRestartCwd({
|
|
7262
7397
|
taskId,
|
|
7263
7398
|
projectId,
|
|
@@ -7291,29 +7426,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7291
7426
|
return boundPath;
|
|
7292
7427
|
}
|
|
7293
7428
|
|
|
7294
|
-
const
|
|
7295
|
-
|
|
7296
|
-
|
|
7297
|
-
try {
|
|
7298
|
-
const configuredBackend = await resolveConfiguredRuntimeBackend(normalizedBackend, ALLOW_CLI_LIST, {
|
|
7299
|
-
configFilePath: config.CONFIG_FILE,
|
|
7300
|
-
});
|
|
7301
|
-
const resumeBackend = configuredBackend?.runtimeBackend ||
|
|
7302
|
-
await normalizeRuntimeBackendAlias(normalizedBackend, { configFilePath: config.CONFIG_FILE });
|
|
7303
|
-
const resumeContext = await (deps.resolveResumeContext || resolveResumeContext)(
|
|
7304
|
-
resumeBackend,
|
|
7305
|
-
normalizedSessionId,
|
|
7306
|
-
{
|
|
7307
|
-
cwd: process.cwd(),
|
|
7308
|
-
configFilePath: config.CONFIG_FILE,
|
|
7309
|
-
},
|
|
7310
|
-
);
|
|
7311
|
-
if (typeof resumeContext?.cwd === "string" && resumeContext.cwd.trim()) {
|
|
7312
|
-
return resumeContext.cwd.trim();
|
|
7313
|
-
}
|
|
7314
|
-
} catch {
|
|
7315
|
-
// ignore provider-specific fallback failure here; we'll try the remaining fallbacks
|
|
7316
|
-
}
|
|
7429
|
+
const sessionCwd = await resolveSessionResumeCwd({ backendType, sessionId });
|
|
7430
|
+
if (sessionCwd) {
|
|
7431
|
+
return sessionCwd;
|
|
7317
7432
|
}
|
|
7318
7433
|
|
|
7319
7434
|
const normalizedSessionPath =
|
|
@@ -7474,7 +7589,14 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7474
7589
|
launchConfig,
|
|
7475
7590
|
})) ||
|
|
7476
7591
|
normalizeOptionalString(launchConfig.cwd) ||
|
|
7477
|
-
boundPath
|
|
7592
|
+
boundPath ||
|
|
7593
|
+
(await resolveSessionResumeCwd({
|
|
7594
|
+
backendType: requestedBackend,
|
|
7595
|
+
runtimeBackend: effectiveBackend,
|
|
7596
|
+
sessionId:
|
|
7597
|
+
normalizeOptionalString(launchConfig?.resumeSessionId) ||
|
|
7598
|
+
normalizeOptionalString(launchConfig?.resume_session_id),
|
|
7599
|
+
}));
|
|
7478
7600
|
|
|
7479
7601
|
if (resolvedTaskWorkspace) {
|
|
7480
7602
|
taskDir = resolvedTaskWorkspace;
|
|
@@ -8567,6 +8689,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
8567
8689
|
clearInterval(tmuxLivenessTimer);
|
|
8568
8690
|
tmuxLivenessTimer = null;
|
|
8569
8691
|
}
|
|
8692
|
+
// An in-flight transfer holds an open socket and a `.part` file; abort
|
|
8693
|
+
// them so shutdown is not gated on a multi-gigabyte download finishing.
|
|
8694
|
+
remoteFileHandlers?.abortAll();
|
|
8570
8695
|
const activeProcessEntries = [...activeTaskProcesses.entries()];
|
|
8571
8696
|
const activePtyEntries = [...activePtySessions.entries()];
|
|
8572
8697
|
const activeEntries = [...activeProcessEntries, ...activePtyEntries];
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directory transfer, built on the single-file path plus `tar`.
|
|
3
|
+
*
|
|
4
|
+
* `remote cp -r` packs a directory locally (or remotely), moves the one
|
|
5
|
+
* resulting tarball through the ordinary transfer, then unpacks it on the other
|
|
6
|
+
* side. No new protocol: the wire still only ever carries one file.
|
|
7
|
+
*
|
|
8
|
+
* The remote halves run through `sh -c` with the paths passed as **positional
|
|
9
|
+
* arguments**, never interpolated into the script text. A directory literally
|
|
10
|
+
* named `; rm -rf /` is therefore just an awkward directory name.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
|
|
19
|
+
/** Where the target stages its tarball. Overridable for hosts without /tmp. */
|
|
20
|
+
export function remoteTempPath(env = process.env) {
|
|
21
|
+
const root = (env.CONDUCTOR_REMOTE_TMP || "/tmp").replace(/\/+$/, "") || "/tmp";
|
|
22
|
+
return `${root}/.conductor-rcp-${randomUUID()}.tgz`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function localTempPath() {
|
|
26
|
+
return path.join(os.tmpdir(), `.conductor-rcp-${randomUUID()}.tgz`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Unpack `$1` into `$2`, following scp: an existing destination directory
|
|
31
|
+
* receives the source *inside* it; a missing one becomes the copy itself.
|
|
32
|
+
* `$3` is the source's basename, which is the top-level entry in the tarball.
|
|
33
|
+
*/
|
|
34
|
+
export const EXTRACT_SCRIPT = `
|
|
35
|
+
set -e
|
|
36
|
+
tmp=$1; dest=$2; base=$3
|
|
37
|
+
if [ -d "$dest" ]; then
|
|
38
|
+
tar xzf "$tmp" -C "$dest"
|
|
39
|
+
else
|
|
40
|
+
parent=$(dirname "$dest")
|
|
41
|
+
mkdir -p "$parent"
|
|
42
|
+
# Unpack into a private staging dir, never straight into the parent: the
|
|
43
|
+
# tarball's top entry is the SOURCE's name, and an unrelated neighbour of
|
|
44
|
+
# the destination could already have it. Extracting in place would clobber
|
|
45
|
+
# that neighbour, and the rename below would then carry it off.
|
|
46
|
+
# Staging inside the parent keeps the final mv on one filesystem.
|
|
47
|
+
stage=$(mktemp -d "$parent/.conductor-rcp-XXXXXX")
|
|
48
|
+
tar xzf "$tmp" -C "$stage"
|
|
49
|
+
rm -rf "$dest"
|
|
50
|
+
mv "$stage/$base" "$dest"
|
|
51
|
+
rmdir "$stage"
|
|
52
|
+
fi
|
|
53
|
+
`.trim();
|
|
54
|
+
|
|
55
|
+
/** Pack directory `$1` into tarball `$2`, keeping `$1`'s own name inside. */
|
|
56
|
+
export const PACK_SCRIPT = `
|
|
57
|
+
set -e
|
|
58
|
+
src=$1; tmp=$2
|
|
59
|
+
if [ ! -d "$src" ]; then
|
|
60
|
+
echo "not a directory: $src" >&2
|
|
61
|
+
exit 1
|
|
62
|
+
fi
|
|
63
|
+
tar czf "$tmp" -C "$(dirname "$src")" "$(basename "$src")"
|
|
64
|
+
`.trim();
|
|
65
|
+
|
|
66
|
+
/** Remove the staged tarball. Never fails the transfer. */
|
|
67
|
+
export const CLEANUP_SCRIPT = 'rm -f "$1"';
|
|
68
|
+
|
|
69
|
+
/** `sh -c <script> sh <args...>` — the `sh` placeholder becomes $0. */
|
|
70
|
+
export function shellArgs(script, ...args) {
|
|
71
|
+
return ["-c", script, "sh", ...args];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function runLocal(command, args, { spawnFn = spawn } = {}) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const child = spawnFn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
77
|
+
let stderr = "";
|
|
78
|
+
child.stderr?.on("data", (chunk) => {
|
|
79
|
+
// Bounded: a pathological tar could otherwise stream forever.
|
|
80
|
+
if (stderr.length < 8192) stderr += String(chunk);
|
|
81
|
+
});
|
|
82
|
+
child.on("error", (error) =>
|
|
83
|
+
reject(new Error(`failed to run ${command}: ${error.message}`)));
|
|
84
|
+
child.on("close", (code) => {
|
|
85
|
+
if (code === 0) return resolve();
|
|
86
|
+
reject(new Error(`${command} exited ${code}${stderr.trim() ? `: ${stderr.trim()}` : ""}`));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Pack a local directory, preserving its own name as the top-level entry. */
|
|
92
|
+
export async function packLocalDirectory(sourceDir, tarball, deps = {}) {
|
|
93
|
+
await runLocal(
|
|
94
|
+
"tar",
|
|
95
|
+
["czf", tarball, "-C", path.dirname(sourceDir), path.basename(sourceDir)],
|
|
96
|
+
deps,
|
|
97
|
+
);
|
|
98
|
+
return tarball;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Unpack locally with the same scp semantics as `EXTRACT_SCRIPT`. */
|
|
102
|
+
export async function unpackLocalDirectory(tarball, destination, baseName, deps = {}) {
|
|
103
|
+
const fsp = deps.fsp || fs.promises;
|
|
104
|
+
let destStat = null;
|
|
105
|
+
try {
|
|
106
|
+
destStat = await fsp.stat(destination);
|
|
107
|
+
} catch {
|
|
108
|
+
destStat = null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (destStat?.isDirectory()) {
|
|
112
|
+
await runLocal("tar", ["xzf", tarball, "-C", destination], deps);
|
|
113
|
+
return path.join(destination, baseName);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const parent = path.dirname(destination);
|
|
117
|
+
await fsp.mkdir(parent, { recursive: true });
|
|
118
|
+
// Same reasoning as EXTRACT_SCRIPT: stage in a private directory so an
|
|
119
|
+
// existing neighbour that happens to share the source's name survives.
|
|
120
|
+
const stage = await fsp.mkdtemp(path.join(parent, ".conductor-rcp-"));
|
|
121
|
+
try {
|
|
122
|
+
await runLocal("tar", ["xzf", tarball, "-C", stage], deps);
|
|
123
|
+
await fsp.rm(destination, { recursive: true, force: true });
|
|
124
|
+
await fsp.rename(path.join(stage, baseName), destination);
|
|
125
|
+
} finally {
|
|
126
|
+
await fsp.rm(stage, { recursive: true, force: true }).catch(() => {});
|
|
127
|
+
}
|
|
128
|
+
return destination;
|
|
129
|
+
}
|