@love-moon/conductor-cli 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,136 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ // On-disk hand-off records that let a *fresh* daemon adopt the tmux Fire
5
+ // sessions its predecessor deliberately left running.
6
+ //
7
+ // In `fire_tmux_mode` daemon shutdown intentionally keeps Fires alive
8
+ // ("Daemon shutting down: leaving tmux-detached Fire task … running"), but
9
+ // everything the liveness reaper needs to classify their eventual exit —
10
+ // `logPath`, `logStartOffset`, `exitMarkerToken`, `spawnedAtMs` — lived only
11
+ // in the old process's memory. `exitMarkerToken` in particular is a
12
+ // per-spawn random nonce that cannot be recomputed, so without persisting it
13
+ // a successor daemon can never read the exit marker the predecessor's
14
+ // wrapper shell writes, and an adopted task could only ever be reported as
15
+ // "died hard". These files close that gap.
16
+ //
17
+ // Records are keyed by tmux session name because that is the only identifier
18
+ // a successor can enumerate (`tmux list-sessions`). The task id is NOT
19
+ // recoverable from a session name: `buildFireTmuxSessionPrefix` truncates it
20
+ // to 32 characters, which is shorter than a UUID.
21
+
22
+ export const FIRE_SESSION_RECORD_VERSION = 1;
23
+
24
+ export function resolveFireSessionRegistryDir(conductorHome) {
25
+ return path.join(String(conductorHome || ""), "daemon", "fire-sessions");
26
+ }
27
+
28
+ // Session names are built from a sanitized task id plus a base36 nonce, so in
29
+ // practice they are already `[A-Za-z0-9_-]+`. Re-check anyway: this value
30
+ // decides a filename, and a name carrying `/` or `..` would let a crafted
31
+ // tmux session read or delete files outside the registry directory.
32
+ function isSafeSessionName(sessionName) {
33
+ return typeof sessionName === "string" && /^[A-Za-z0-9_-]+$/.test(sessionName);
34
+ }
35
+
36
+ function recordPath(dir, sessionName) {
37
+ return path.join(dir, `${sessionName}.json`);
38
+ }
39
+
40
+ function normalizeRecord(record) {
41
+ return {
42
+ version: FIRE_SESSION_RECORD_VERSION,
43
+ taskId: String(record?.taskId || ""),
44
+ projectId: String(record?.projectId || ""),
45
+ tmuxSession: String(record?.tmuxSession || ""),
46
+ logPath: String(record?.logPath || ""),
47
+ logStartOffset: Number(record?.logStartOffset) || 0,
48
+ exitMarkerToken: String(record?.exitMarkerToken || ""),
49
+ spawnedAtMs: Number(record?.spawnedAtMs) || 0,
50
+ daemonName: String(record?.daemonName || ""),
51
+ };
52
+ }
53
+
54
+ // Write is atomic (tmp + rename) so a daemon killed mid-write cannot leave a
55
+ // truncated record that the successor would parse as "no metadata".
56
+ export function writeFireSessionRecord(dir, record, { fsImpl = fs } = {}) {
57
+ const normalized = normalizeRecord(record);
58
+ if (!normalized.tmuxSession || !normalized.taskId) return false;
59
+ if (!isSafeSessionName(normalized.tmuxSession)) return false;
60
+ fsImpl.mkdirSync(dir, { recursive: true });
61
+ const finalPath = recordPath(dir, normalized.tmuxSession);
62
+ const tmpPath = `${finalPath}.${process.pid}.tmp`;
63
+ fsImpl.writeFileSync(tmpPath, `${JSON.stringify(normalized)}\n`, "utf8");
64
+ fsImpl.renameSync(tmpPath, finalPath);
65
+ return true;
66
+ }
67
+
68
+ // Returns `null` for anything we cannot fully trust: missing file, unparsable
69
+ // JSON, unknown version, or a record whose own `tmuxSession` disagrees with
70
+ // the name it was filed under. Callers treat `null` as "adopt in degraded
71
+ // mode", never as "the session is dead".
72
+ export function readFireSessionRecord(dir, sessionName, { fsImpl = fs } = {}) {
73
+ if (!isSafeSessionName(sessionName)) return null;
74
+ let raw;
75
+ try {
76
+ raw = fsImpl.readFileSync(recordPath(dir, sessionName), "utf8");
77
+ } catch {
78
+ return null;
79
+ }
80
+ let parsed;
81
+ try {
82
+ parsed = JSON.parse(raw);
83
+ } catch {
84
+ return null;
85
+ }
86
+ if (!parsed || typeof parsed !== "object") return null;
87
+ if (Number(parsed.version) !== FIRE_SESSION_RECORD_VERSION) return null;
88
+ if (String(parsed.tmuxSession || "") !== sessionName) return null;
89
+ if (!parsed.taskId) return null;
90
+ return normalizeRecord(parsed);
91
+ }
92
+
93
+ export function deleteFireSessionRecord(dir, sessionName, { fsImpl = fs } = {}) {
94
+ if (!isSafeSessionName(sessionName)) return false;
95
+ try {
96
+ fsImpl.rmSync(recordPath(dir, sessionName), { force: true });
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ // Drop records whose tmux session no longer exists. Without this the registry
104
+ // would grow one file per Fire ever spawned: the targeted deletes on the
105
+ // reap/stop/delete paths cover the common cases, but a daemon that is
106
+ // SIGKILLed leaves its records behind with nothing to remove them.
107
+ export function pruneFireSessionRecords(dir, liveSessionNames, { fsImpl = fs } = {}) {
108
+ const live = new Set(Array.isArray(liveSessionNames) ? liveSessionNames : []);
109
+ let entries;
110
+ try {
111
+ entries = fsImpl.readdirSync(dir);
112
+ } catch {
113
+ return 0;
114
+ }
115
+ let pruned = 0;
116
+ for (const entry of entries) {
117
+ // A `.tmp` file only exists between writeFileSync and renameSync, so one
118
+ // that survives to a prune is debris from a crashed write.
119
+ if (entry.endsWith(".tmp")) {
120
+ try {
121
+ fsImpl.rmSync(path.join(dir, entry), { force: true });
122
+ pruned += 1;
123
+ } catch {
124
+ // best effort
125
+ }
126
+ continue;
127
+ }
128
+ if (!entry.endsWith(".json")) continue;
129
+ const sessionName = entry.slice(0, -".json".length);
130
+ if (live.has(sessionName)) continue;
131
+ if (deleteFireSessionRecord(dir, sessionName, { fsImpl })) {
132
+ pruned += 1;
133
+ }
134
+ }
135
+ return pruned;
136
+ }
@@ -0,0 +1,275 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { UPDATE_DAEMON_CAPABILITY } from "./daemon-update.js";
6
+
7
+ /**
8
+ * RFC 0035 — guest daemon supervision.
9
+ *
10
+ * The owner's daemon keeps one child `conductor daemon` process per accepted
11
+ * share. Each child authenticates as the *grantee*, so the backend sees an
12
+ * ordinary daemon belonging to that user and every existing ownership check
13
+ * keeps working. Nothing in the backend's hot path knows sharing exists.
14
+ *
15
+ * This module is deliberately pure-ish: path/config/env construction and the
16
+ * reconcile decision are exported as plain functions so they can be tested
17
+ * without spawning anything.
18
+ */
19
+
20
+ /** One node process per guest; this bounds the owner's machine, not the product. */
21
+ export const MAX_GUEST_DAEMONS = 3;
22
+
23
+ export const GUEST_RESTART_BASE_MS = 10_000;
24
+ export const GUEST_RESTART_MAX_MS = 5 * 60_000;
25
+
26
+ const expandHome = (value, homeDir) => {
27
+ if (typeof value !== "string" || !value.trim()) return "";
28
+ const trimmed = value.trim();
29
+ if (trimmed === "~") return homeDir;
30
+ if (trimmed.startsWith("~/")) return path.join(homeDir, trimmed.slice(2));
31
+ return path.resolve(trimmed);
32
+ };
33
+
34
+ /**
35
+ * Every path a guest instance must NOT share with its host daemon.
36
+ *
37
+ * This is the part that is easy to get wrong and expensive when you do:
38
+ * `--config-file` does *not* derive `CONDUCTOR_HOME` (`conductor-paths.js`
39
+ * resolves it from the environment only), and the daemon PID lock lives under
40
+ * `CONDUCTOR_WS`, not under `CONDUCTOR_HOME`. Two daemons sharing a workspace
41
+ * root contend for one `daemon.pid`, and `--force` on the second one will
42
+ * SIGKILL the first -- across accounts. Verified live before this was written.
43
+ */
44
+ export function resolveGuestPaths(shareId, workspaceRoot, homeDir = os.homedir()) {
45
+ const home = homeDir || "/tmp";
46
+ const conductorHome = path.join(home, ".conductor-guests", shareId);
47
+ const ws = workspaceRoot
48
+ ? path.join(expandHome(workspaceRoot, home), "ws")
49
+ : path.join(home, "conductor-guests", shareId, "ws");
50
+ return {
51
+ conductorHome,
52
+ configDir: path.join(home, ".conductor", "shares", shareId),
53
+ configPath: path.join(home, ".conductor", "shares", shareId, "config.yaml"),
54
+ workspace: ws,
55
+ fireStateDir: path.join(conductorHome, "state"),
56
+ // The root a guest's project paths must stay under. Its parent, not `ws`,
57
+ // so the guest can also see sibling dirs the owner deliberately placed.
58
+ guestRoot: workspaceRoot
59
+ ? expandHome(workspaceRoot, home)
60
+ : path.join(home, "conductor-guests", shareId),
61
+ };
62
+ }
63
+
64
+ const yamlQuote = (value) => `"${String(value).replace(/(["\\])/g, "\\$1")}"`;
65
+
66
+ /**
67
+ * The guest's config.yaml.
68
+ *
69
+ * Note what is NOT here: `envs:`. Leaving it out is the whole point of the
70
+ * feature -- the guest inherits the machine's already-logged-in AI CLIs
71
+ * (`~/.claude`, `~/.codex`). Setting per-instance `CODEX_HOME` /
72
+ * `CLAUDE_CONFIG_DIR` here would isolate credentials and defeat the purpose.
73
+ */
74
+ export function buildGuestConfigYaml({
75
+ agentToken,
76
+ backendUrl,
77
+ guestHost,
78
+ workspace,
79
+ allowCliList,
80
+ }) {
81
+ const lines = [
82
+ "# Generated by conductor daemon (RFC 0035 daemon sharing). Do not edit.",
83
+ "# Regenerated from the backend on every supervisor reconcile.",
84
+ `agent_token: ${yamlQuote(agentToken)}`,
85
+ `backend_url: ${yamlQuote(backendUrl)}`,
86
+ `daemon_name: ${yamlQuote(guestHost)}`,
87
+ `workspace: ${yamlQuote(workspace)}`,
88
+ "conductor_guest: true",
89
+ ];
90
+
91
+ // Inherited from the owner, and load-bearing: `conductor-fire` refuses a
92
+ // backend that has no `allow_cli_list` entry unless it is one of the
93
+ // command-optional SDK backends (copilot, dsh). Omitting this leaves a guest
94
+ // able to start a task and then die with
95
+ // `Unsupported backend "claude". Supported backends: copilot, dsh.` --
96
+ // i.e. unable to run the very CLIs the machine was shared for.
97
+ //
98
+ // Copying the owner's list is the right semantics, not a shortcut: the guest
99
+ // runs on the owner's machine against the owner's installed, already
100
+ // logged-in tools, so the command lines that work for the owner are exactly
101
+ // the ones that work here.
102
+ const entries = Object.entries(allowCliList || {}).filter(
103
+ ([backend, command]) => backend && typeof command === "string" && command.trim(),
104
+ );
105
+ if (entries.length > 0) {
106
+ lines.push("allow_cli_list:");
107
+ for (const [backend, command] of entries) {
108
+ lines.push(` ${backend}: ${yamlQuote(command.trim())}`);
109
+ }
110
+ }
111
+
112
+ lines.push("");
113
+ return lines.join("\n");
114
+ }
115
+
116
+ /**
117
+ * Environment for the guest child.
118
+ *
119
+ * `CONDUCTOR_AGENT_TOKEN` is deliberately deleted rather than overwritten: the
120
+ * child inherits the owner's environment, and the daemon only ignores an
121
+ * inherited token because `--config-file` was passed explicitly
122
+ * (`daemon.js` `allowEnvConfigOverrides`). Removing it means a future change to
123
+ * that precedence cannot silently make the guest run as the owner.
124
+ */
125
+ export function buildGuestEnv(baseEnv, paths, extra = {}) {
126
+ const env = { ...baseEnv };
127
+ delete env.CONDUCTOR_AGENT_TOKEN;
128
+ delete env.CONDUCTOR_BACKEND_URL;
129
+ delete env.CONDUCTOR_WS_URL;
130
+ delete env.CONDUCTOR_BACKEND_WS_URL;
131
+ delete env.CONDUCTOR_DAEMON_NAME;
132
+ delete env.CONDUCTOR_TASK_ID;
133
+ delete env.CONDUCTOR_PROJECT_ID;
134
+ return {
135
+ ...env,
136
+ CONDUCTOR_HOME: paths.conductorHome,
137
+ CONDUCTOR_WS: paths.workspace,
138
+ CONDUCTOR_FIRE_STATE_DIR: paths.fireStateDir,
139
+ CONDUCTOR_GUEST_SHARE_ID: extra.shareId || "",
140
+ // Only propagated when the owner actually picked a workspace root. An
141
+ // auto-generated one must not turn into a confinement the owner never
142
+ // chose -- see the note on GUEST_ROOT in daemon.js.
143
+ ...(extra.explicitRoot ? { CONDUCTOR_GUEST_ROOT: paths.guestRoot } : {}),
144
+ };
145
+ }
146
+
147
+ export function writeGuestConfig(paths, contents, deps = {}) {
148
+ const mkdirSync = deps.mkdirSync || fs.mkdirSync;
149
+ const writeFileSync = deps.writeFileSync || fs.writeFileSync;
150
+ mkdirSync(paths.configDir, { recursive: true, mode: 0o700 });
151
+ mkdirSync(paths.conductorHome, { recursive: true, mode: 0o700 });
152
+ mkdirSync(paths.workspace, { recursive: true });
153
+ // 0600: the file holds the grantee's credential in plaintext.
154
+ writeFileSync(paths.configPath, contents, { encoding: "utf8", mode: 0o600 });
155
+ return paths.configPath;
156
+ }
157
+
158
+ /**
159
+ * Decide what the supervisor should do, given the desired shares from the
160
+ * backend and what is currently running. Pure, so the interesting cases are
161
+ * testable without processes.
162
+ */
163
+ export function reconcileGuests(desiredShares, runningIds, max = MAX_GUEST_DAEMONS) {
164
+ const usable = (desiredShares || []).filter(
165
+ (share) => share && share.id && share.guestHost && share.agentToken,
166
+ );
167
+ const start = [];
168
+ const keep = [];
169
+ for (const share of usable) {
170
+ if (runningIds.has(share.id)) keep.push(share.id);
171
+ else if (keep.length + start.length < max) start.push(share);
172
+ }
173
+ const desiredIds = new Set(usable.map((share) => share.id));
174
+ // Anything running that the backend no longer lists as active: revoked,
175
+ // or the share moved to another daemon.
176
+ const stop = [...runningIds].filter((id) => !desiredIds.has(id));
177
+ const skipped = usable.length > max ? usable.length - max : 0;
178
+ return { start, stop, keep, skipped };
179
+ }
180
+
181
+ export function nextRestartDelayMs(failureCount) {
182
+ const exponent = Math.max(0, failureCount - 1);
183
+ return Math.min(GUEST_RESTART_BASE_MS * 2 ** exponent, GUEST_RESTART_MAX_MS);
184
+ }
185
+
186
+ /**
187
+ * A guest daemon offers the SAME capabilities as any other daemon.
188
+ *
189
+ * The dividing line is not "can the grantee execute code here" -- they always
190
+ * can, because an AI task is an arbitrary prompt handed to a CLI with a shell,
191
+ * and `pty_task`'s `custom` entrypoint takes caller-supplied command/args/cwd/env
192
+ * (`daemon.js` `entrypointType === "custom"`). Blocking the scriptable door
193
+ * while leaving the interactive one open stops no attacker and only breaks
194
+ * legitimate use.
195
+ *
196
+ * `remote_exec` was on this list for exactly that bad reason. RFC 0034 had
197
+ * already made the argument: it "adds no reach" beyond `create_pty_task`,
198
+ * except on a host whose node-pty probe failed -- which is not the case a
199
+ * blanket guest rule addresses.
200
+ *
201
+ * What a guest genuinely must not do is mutate state the OWNER depends on.
202
+ * That is a different axis, handled per-action below, not by withholding
203
+ * capabilities.
204
+ */
205
+ export const GUEST_BLOCKED_CAPABILITIES = new Set([
206
+ // `update_daemon` is the one capability that is *only* owner-state mutation:
207
+ // a global `npm install -g` plus a restart of the owner's daemon. There is no
208
+ // read-only half to keep, so it is withheld rather than refused per-action.
209
+ UPDATE_DAEMON_CAPABILITY,
210
+ ]);
211
+
212
+ export function filterGuestCapabilities(capabilities) {
213
+ return (capabilities || []).filter((cap) => !GUEST_BLOCKED_CAPABILITIES.has(cap));
214
+ }
215
+
216
+ /**
217
+ * The two actions a guest must still refuse. Neither restricts what the grantee
218
+ * can do with their own account -- both would reconfigure the owner's machine:
219
+ * - a versioned restart runs a global `npm install -g`, swapping the CLI
220
+ * binary out from under the owner's own daemon and every one of their fires;
221
+ * - `switch_account` renames over `~/.codex/auth.json`, changing the owner's
222
+ * active Codex identity everywhere on the box.
223
+ * Reads (`status`, `quota`, `list_accounts`) stay allowed: the grantee needs to
224
+ * see how much quota is left before running something heavy.
225
+ */
226
+ export function isGuestRestartAllowed(payload) {
227
+ const target = payload && typeof payload.target_version === "string"
228
+ ? payload.target_version.trim()
229
+ : "";
230
+ return !target;
231
+ }
232
+
233
+ export function isGuestAiManagerActionAllowed(action) {
234
+ return action !== "switch_account";
235
+ }
236
+
237
+ /**
238
+ * Confine a guest's project paths to its root.
239
+ *
240
+ * `validate_project_path` resolves any absolute path the caller sends and will
241
+ * `mkdirSync(recursive)` when asked; `get_project_agents` reads arbitrary
242
+ * paths too. Neither is bounded by the workspace today. This is a
243
+ * misuse-prevention boundary, not a security boundary -- the guest's AI agent
244
+ * runs a shell and can reach anything the OS user can.
245
+ */
246
+ export function isPathInsideGuestRoot(candidate, guestRoot) {
247
+ if (!guestRoot) return true;
248
+ const resolvedRoot = path.resolve(guestRoot);
249
+ const resolved = path.resolve(candidate || "");
250
+ if (resolved === resolvedRoot) return true;
251
+ return resolved.startsWith(`${resolvedRoot}${path.sep}`);
252
+ }
253
+
254
+ /**
255
+ * Guests are spawned as ordinary children, which means a `SIGKILL` or a hard
256
+ * crash of the host daemon leaves them running with live credentials, serving
257
+ * a share the owner believes is stopped. `detached: false` does not help --
258
+ * on POSIX it only shares the process group; nothing reaps the child.
259
+ *
260
+ * So the guest watches its own parent instead. `process.ppid` becomes 1 (or
261
+ * the reaper) once the host daemon is gone.
262
+ */
263
+ export function startOrphanWatchdog(options = {}) {
264
+ const intervalMs = options.intervalMs || 15_000;
265
+ const initialPpid = options.ppid ?? process.ppid;
266
+ const onOrphaned = options.onOrphaned || (() => process.exit(0));
267
+ const readPpid = options.readPpid || (() => process.ppid);
268
+
269
+ const timer = setInterval(() => {
270
+ const current = readPpid();
271
+ if (current !== initialPpid) onOrphaned(current, initialPpid);
272
+ }, intervalMs);
273
+ if (typeof timer.unref === "function") timer.unref();
274
+ return () => clearInterval(timer);
275
+ }