agentcoor 0.0.0-stage → 0.3.7

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/src/connect.js ADDED
@@ -0,0 +1,351 @@
1
+ import { open, lstat, unlink, rmdir, readlink, realpath } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { spawnSync, spawn } from "node:child_process";
4
+ import { join, dirname } from "node:path";
5
+ import { homedir, userInfo } from "node:os";
6
+ import { installPaths } from "./config.js";
7
+ import { download, MAX_API_BYTES, MAX_BINARY_BYTES } from "./http.js";
8
+ import { RELEASE_PUBLIC_KEY } from "./signature.js";
9
+ import { verifyManifest, artifactFor, verifyArtifact, readAcceptedEpoch, recordAcceptedEpoch, withInstallLock, MIN_RELEASE_EPOCH } from "./release.js";
10
+ import { ensurePrivateDir, ensureUserDir, atomicWrite, assertSafePath } from "./fsx.js";
11
+ import { updateFile, parseJsonObject } from "./harness.js";
12
+ import { replaceTomlTables, tomlString } from "./toml.js";
13
+
14
+ const AGENT_NAMES = /^agentcoor-agent-(darwin|linux)-(amd64|arm64)$/;
15
+ export const HOST_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; // gateway issues UUIDs
16
+ export const HOST_TOKEN = /^[A-Za-z0-9_-]{16,255}$/; // gateway issues base64url tokens
17
+
18
+ /** Map Node's platform/arch to the gateway's download name. Windows users should run inside WSL. */
19
+ export function agentBinaryName(platform = process.platform, arch = process.arch) {
20
+ const os = { darwin: "darwin", linux: "linux" }[platform];
21
+ if (!os) throw new Error(platform === "win32" ? "Windows is supported through WSL: run this inside your WSL shell" : `unsupported OS: ${platform}`);
22
+ const cpu = { x64: "amd64", arm64: "arm64" }[arch];
23
+ if (!cpu) throw new Error(`unsupported CPU: ${arch}`);
24
+ const name = `agentcoor-agent-${os}-${cpu}`;
25
+ if (!AGENT_NAMES.test(name)) throw new Error(`unsupported platform: ${name}`);
26
+ return name;
27
+ }
28
+
29
+ /** XML text escaping for plist <string> values. Paths are checked for control characters first. */
30
+ export const xml = (s) => assertSafePath(String(s)).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
31
+ /** systemd ExecStart quoting: double quotes, backslash escapes, and %/$ specifiers doubled. */
32
+ export const systemdQuote = (s) => `"${assertSafePath(String(s)).replace(/(["\\])/g, "\\$1").replace(/%/g, "%%").replace(/\$/g, "$$$$")}"`;
33
+
34
+ /** Service identity. AGENT_SERVICE is the terminal agent; share.js passes SHARE_SERVICE. */
35
+ export const AGENT_SERVICE = Object.freeze({ label: "com.agentcoor.agent", unit: "agentcoor-agent", description: "AgentCoor agent", log: "agent", flag: "-config", extra: [] });
36
+
37
+ export function launchdPlist({ bin, config, logs, service = AGENT_SERVICE }) {
38
+ return `<?xml version="1.0" encoding="UTF-8"?>
39
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
40
+ <plist version="1.0"><dict>
41
+ <key>Label</key><string>${xml(service.label)}</string>
42
+ <key>ProgramArguments</key><array><string>${xml(bin)}</string>${service.extra.map((a) => `<string>${xml(a)}</string>`).join("")}<string>${xml(service.flag)}</string><string>${xml(config)}</string></array>
43
+ <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
44
+ <key>StandardOutPath</key><string>${xml(join(logs, `${service.log}.log`))}</string><key>StandardErrorPath</key><string>${xml(join(logs, `${service.log}.err`))}</string>
45
+ </dict></plist>
46
+ `;
47
+ }
48
+
49
+ /**
50
+ * Capabilities the root marketplace worker keeps (docs/marketplace/AGENT-JOBS.md
51
+ * "Worker service"). Root is used only to build sandboxes: SYS_ADMIN for
52
+ * cgroup v2 subtrees, mount/pivot_root and namespaces; NET_ADMIN/NET_RAW for
53
+ * the private egress network namespace; SYS_CHROOT, SETUID/SETGID, SETPCAP,
54
+ * DAC_*, CHOWN, FOWNER, FSETID, MKNOD for runsc's gofer/sandbox setup and the
55
+ * uid-65534 job user; SYS_PTRACE for the systrap/ptrace platforms; KILL to tear
56
+ * sandboxes down; SYS_RESOURCE for RLIMIT_NPROC; SETFCAP because runsc maps
57
+ * uid 0 into the gofer's user namespace, which Linux 5.12+ refuses without it
58
+ * (measured on infra-server: "gofer: fork/exec /proc/self/exe: operation not
59
+ * permitted" under this unit until SETFCAP was added). Everything else (modules,
60
+ * raw I/O, clock, boot, BPF, MAC, audit control, ...) is dropped.
61
+ */
62
+ export const WORKER_CAPABILITIES = Object.freeze(["CAP_SYS_ADMIN", "CAP_NET_ADMIN", "CAP_NET_RAW", "CAP_SYS_CHROOT", "CAP_SETUID", "CAP_SETGID", "CAP_SETPCAP",
63
+ "CAP_DAC_OVERRIDE", "CAP_DAC_READ_SEARCH", "CAP_CHOWN", "CAP_FOWNER", "CAP_FSETID", "CAP_MKNOD", "CAP_SYS_PTRACE", "CAP_KILL", "CAP_SYS_RESOURCE", "CAP_SETFCAP"]);
64
+ export const SYSTEM_UNIT_DIR = "/etc/systemd/system";
65
+
66
+ /** System-level unit (root, survives reboot without a login) with the capability bound above. */
67
+ export function systemdSystemUnit({ bin, config, service = AGENT_SERVICE }) {
68
+ return `[Unit]\nDescription=${service.description}\nWants=network-online.target\nAfter=network-online.target\n[Service]\nType=simple\nExecStart=${[bin, ...service.extra, service.flag].map(systemdQuote).join(" ")} ${systemdQuote(config)}\nRestart=always\nRestartSec=3\nUMask=0077\nNoNewPrivileges=yes\nCapabilityBoundingSet=${WORKER_CAPABILITIES.join(" ")}\nProtectSystem=full\nProtectKernelModules=yes\nProtectClock=yes\nRestrictRealtime=yes\nLockPersonality=yes\n[Install]\nWantedBy=multi-user.target\n`;
69
+ }
70
+
71
+ export function systemdUnit({ bin, config, service = AGENT_SERVICE }) {
72
+ return `[Unit]\nDescription=${service.description}\nAfter=network-online.target\n[Service]\nExecStart=${[bin, ...service.extra, service.flag].map(systemdQuote).join(" ")} ${systemdQuote(config)}\nRestart=always\nRestartSec=3\n[Install]\nWantedBy=default.target\n`;
73
+ }
74
+
75
+ /** Secrets that must never reach a child process (launchctl, systemctl, claude, the agent itself). */
76
+ export const SECRET_ENV = ["AGENTCOOR_HOST_TOKEN", "AGENTCOOR_API_KEY"];
77
+ export function childEnv(env = process.env) {
78
+ const e = { ...env };
79
+ for (const k of SECRET_ENV) delete e[k];
80
+ return e;
81
+ }
82
+ const has = (cmd) => spawnSync("sh", ["-c", 'command -v "$1"', "sh", cmd], { stdio: "ignore", env: childEnv() }).status === 0;
83
+ const run = (cmd, args, opts = {}) => spawnSync(cmd, args, { stdio: "ignore", env: childEnv(), ...opts });
84
+ /** Run a command and capture stdout: { status, stdout }. */
85
+ export const capture = (cmd, args) => {
86
+ const r = spawnSync(cmd, args, { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", env: childEnv() });
87
+ return { status: r.status, stdout: r.stdout || "" };
88
+ };
89
+ const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
90
+
91
+ /**
92
+ * Download and verify the signed manifest (signature, expiry, epoch >= embedded
93
+ * minimum and >= the highest epoch accepted on this machine), then the binary
94
+ * (size and SHA-256 from the manifest). Throws on any failure.
95
+ */
96
+ export async function fetchVerifiedAgent(origin, name, { publicKey = RELEASE_PUBLIC_KEY, fetcher = download, minEpoch = MIN_RELEASE_EPOCH, acceptedEpoch = 0, now = Date.now() } = {}) {
97
+ const [manifestBuf, sig] = await Promise.all([
98
+ fetcher(`${origin}/download/release.json`, { maxBytes: MAX_API_BYTES }),
99
+ fetcher(`${origin}/download/release.json.sig`, { maxBytes: MAX_API_BYTES }).catch((e) => { throw new Error(`release.json.sig could not be downloaded (${e.message}): refusing to install an unsigned release`); }),
100
+ ]);
101
+ const manifest = verifyManifest(manifestBuf, sig, { publicKey, minEpoch, acceptedEpoch, now });
102
+ const entry = artifactFor(manifest, name);
103
+ const binary = await fetcher(`${origin}/download/${name}`, { maxBytes: Math.min(MAX_BINARY_BYTES, entry.size) });
104
+ const digest = verifyArtifact(binary, entry);
105
+ return { binary, digest, manifest };
106
+ }
107
+
108
+ export async function connect({ origin, hostId, hostToken, log = console.log, env = process.env }) {
109
+ if (!hostId || !hostToken) throw new Error("a host ID and host token are required: in the dashboard open Hosts -> Connect a machine, then run\n npx agentcoor connect --host-id <id>\n(the token is read from a hidden prompt, --token-file, stdin, or AGENTCOOR_HOST_TOKEN)");
110
+ if (!HOST_ID.test(hostId)) throw new Error("the host ID contains unexpected characters; copy it again from the dashboard");
111
+ if (!HOST_TOKEN.test(hostToken)) throw new Error("the host token contains unexpected characters; copy it again from the dashboard");
112
+ for (const k of SECRET_ENV) { delete env[k]; delete process.env[k]; }
113
+ const name = agentBinaryName();
114
+ const p = installPaths(env);
115
+ for (const d of [p.base, dirname(p.bin), p.state, p.logs]) await ensurePrivateDir(d);
116
+
117
+ // One installer at a time per install dir: state read, manifest verification,
118
+ // epoch update, binary replacement and service restart all happen under the lock,
119
+ // so a concurrent older install can neither lower the epoch nor win the rename.
120
+ await withInstallLock(p.base, async () => {
121
+ log(`==> Downloading ${name}`);
122
+ const acceptedEpoch = await readAcceptedEpoch(p.base);
123
+ const { binary, digest, manifest } = await fetchVerifiedAgent(origin, name, { acceptedEpoch });
124
+ log(`==> Verified signed release ${manifest.version} (epoch ${manifest.epoch}) and SHA-256 ${digest}`);
125
+ await recordAcceptedEpoch(p.base, manifest.epoch);
126
+
127
+ await stopLegacy({ p, env, log });
128
+ await atomicWrite(p.bin, binary, { mode: 0o755, sha256: digest });
129
+ await atomicWrite(p.config, JSON.stringify({ gateway_url: origin, host_id: hostId, token: hostToken, state_path: join(p.state, "sessions.json") }) + "\n", { mode: 0o600 });
130
+ log(`==> Configuration written to ${p.config}`);
131
+
132
+ await startService({ bin: p.bin, config: p.config, logs: p.logs, state: p.state, log, env });
133
+ });
134
+ await connectHarnesses({ bin: p.bin, config: p.config, log, env });
135
+ log("==> Done. This machine will appear under Hosts in the AgentCoor dashboard within a few seconds.");
136
+ return { bin: p.bin, config: p.config };
137
+ }
138
+
139
+ export const pidFile = (state) => join(state, "agent.pid");
140
+
141
+ /** Is `pid` a running process executing one of `bins`? Linux: /proc/<pid>/exe; elsewhere: ps. */
142
+ async function runsOurBinary(pid, bins) {
143
+ if (process.platform === "linux") {
144
+ const exe = await readlink(`/proc/${pid}/exe`).catch(() => null);
145
+ return !!exe && bins.some((b) => exe === b || exe === `${b} (deleted)`);
146
+ }
147
+ const r = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8", env: childEnv() });
148
+ const cmd = (r.stdout || "").trim();
149
+ return r.status === 0 && bins.some((b) => cmd === b || cmd.startsWith(`${b} `));
150
+ }
151
+
152
+ const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } };
153
+
154
+ /**
155
+ * Does `pid` run exactly the binary now at `bin`? Linux: /proc/<pid>/exe must equal
156
+ * `bin` (a process still mapping a replaced binary shows "<bin> (deleted)" and fails).
157
+ * Elsewhere: the ps command line starts with `bin`.
158
+ */
159
+ export async function runsCurrentBinary(pid, bin, platform = process.platform) {
160
+ if (!Number.isSafeInteger(pid) || pid < 2) return false;
161
+ if (platform === "linux") {
162
+ const exe = await readlink(`/proc/${pid}/exe`).catch(() => null);
163
+ return !!exe && (exe === bin || exe === await realpath(bin).catch(() => null));
164
+ }
165
+ const cmd = capture("ps", ["-o", "command=", "-p", String(pid)]).stdout.trim();
166
+ return cmd === bin || cmd.startsWith(`${bin} `);
167
+ }
168
+
169
+ /** Stop a detached agent recorded in a pidfile, only after verifying the process runs our binary. */
170
+ export async function stopPidfileAgent(pidPath, bins, log = () => {}) {
171
+ let fh;
172
+ try { fh = await open(pidPath, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0)); } catch { return false; }
173
+ let pid;
174
+ try {
175
+ const st = await fh.stat();
176
+ if (!st.isFile() || st.size > 32 || (typeof process.getuid === "function" && st.uid !== process.getuid())) return false;
177
+ const text = (await fh.readFile("utf8")).trim();
178
+ if (!/^[1-9][0-9]{0,9}$/.test(text)) return false;
179
+ pid = Number(text);
180
+ } finally { await fh.close(); }
181
+ let stopped = false;
182
+ if (pid !== process.pid && await runsOurBinary(pid, bins)) {
183
+ try { process.kill(pid, "SIGTERM"); stopped = true; } catch {}
184
+ // Wait for it to exit so the new agent never runs alongside the old one.
185
+ for (let i = 0; stopped && i < 50 && alive(pid); i++) await sleepMs(100);
186
+ if (stopped && alive(pid) && await runsOurBinary(pid, bins)) { try { process.kill(pid, "SIGKILL"); } catch {} }
187
+ if (stopped) log(`==> Stopped the earlier background agent (pid ${pid})`);
188
+ }
189
+ await unlink(pidPath).catch(() => {});
190
+ return stopped;
191
+ }
192
+
193
+ /**
194
+ * Earlier CLI builds installed the agent under ~/.agentcoor. Session state is NOT
195
+ * migrated (a planted state file could make the agent run commands on restore):
196
+ * the old detached agent is stopped (pidfile, verified to be our binary), the old
197
+ * binary and its config (which holds a host token) are removed, and the user is
198
+ * told to reconnect. Only the default ~/.agentcoor is considered, never $AGENTCOOR_HOME.
199
+ */
200
+ export async function stopLegacy({ p, env, log = () => {} }) {
201
+ await stopPidfileAgent(pidFile(p.state), [p.bin], log);
202
+ const old = join(env.HOME || homedir(), ".agentcoor");
203
+ if (old === p.base) return false;
204
+ const st = await lstat(old).catch(() => null);
205
+ if (!st || !st.isDirectory() || (typeof process.getuid === "function" && st.uid !== process.getuid())) return false;
206
+ const oldBin = join(old, "bin", "agentcoor-agent");
207
+ if (!(await lstat(oldBin).catch(() => null))) return false;
208
+ await stopPidfileAgent(join(old, "state", "agent.pid"), [oldBin], log);
209
+ for (const f of [oldBin, join(old, "agent.json")]) await unlink(f).catch(() => {});
210
+ await rmdir(join(old, "bin")).catch(() => {});
211
+ log(`==> Removed the earlier agent install in ${old}. Its session state was not carried over: reconnect any sessions you need from the dashboard.`);
212
+ return true;
213
+ }
214
+
215
+ /**
216
+ * Install and (re)start the agent service, then prove the new binary is what runs.
217
+ * An upgrade must replace a running old agent: systemd `enable --now` is a no-op for
218
+ * an active unit, so this uses `restart`; launchd jobs are booted out, bootstrapped
219
+ * and kickstarted (-k kills any instance). Afterwards the service's main PID must be
220
+ * executing the freshly installed `bin`, or the install fails.
221
+ * `ops` (platform, run, capture, runsBinary, sleep) exists for tests.
222
+ */
223
+ export async function startService({ bin, config, logs, state, log = () => {}, env = process.env, service = AGENT_SERVICE, system = false }, ops = {}) {
224
+ const o = { platform: process.platform, run, capture, runsBinary: runsCurrentBinary, sleep: sleepMs, has, tries: 20, systemUnitDir: SYSTEM_UNIT_DIR, ...ops };
225
+ const ok = (cmd, args) => o.run(cmd, args).status === 0;
226
+ const home = env.HOME || homedir();
227
+ const waitFor = async (check, what) => {
228
+ for (let i = 0; i < o.tries; i++) { if (await check()) return; await o.sleep(250); }
229
+ throw new Error(`${what}: the new agent binary is not the one running; check the agent logs in ${logs}`);
230
+ };
231
+ if (o.platform === "darwin") {
232
+ const label = service.label;
233
+ const plist = join(home, "Library", "LaunchAgents", `${label}.plist`);
234
+ await ensureUserDir(dirname(plist));
235
+ await atomicWrite(plist, launchdPlist({ bin, config, logs, service }), { mode: 0o644 });
236
+ const target = `gui/${process.getuid()}/${label}`;
237
+ const launchdPid = () => { const r = o.capture("launchctl", ["print", target]); const m = r.status === 0 && /^\s*pid = (\d+)\s*$/m.exec(r.stdout); return m ? Number(m[1]) : 0; };
238
+ const oldPid = launchdPid();
239
+ o.run("launchctl", ["bootout", target]);
240
+ await waitFor(() => o.capture("launchctl", ["print", target]).status !== 0, "launchctl bootout did not finish");
241
+ let booted = false;
242
+ for (let i = 0; i < 5 && !booted; i++) { booted = ok("launchctl", ["bootstrap", `gui/${process.getuid()}`, plist]); if (!booted) await o.sleep(500); }
243
+ if (!booted) throw new Error("launchctl bootstrap failed");
244
+ ok("launchctl", ["kickstart", "-k", target]);
245
+ await waitFor(async () => { const pid = launchdPid(); return pid > 0 && pid !== oldPid && await o.runsBinary(pid, bin, "darwin"); }, "launchd restart");
246
+ log(`==> Agent restarted (launchd: ${label}, pid ${launchdPid()})`);
247
+ return;
248
+ }
249
+ // Root (sudo) on Linux: a system unit, so the worker survives reboot with no
250
+ // user session. systemd must be running (show-environment answers).
251
+ if (system && o.has("systemctl") && ok("systemctl", ["show-environment"])) {
252
+ const svc = `${service.unit}.service`;
253
+ const unit = join(o.systemUnitDir, svc);
254
+ await atomicWrite(unit, systemdSystemUnit({ bin, config, service }), { mode: 0o644 });
255
+ if (!ok("systemctl", ["daemon-reload"])) throw new Error("systemctl daemon-reload failed");
256
+ if (!ok("systemctl", ["enable", svc])) throw new Error(`systemctl enable ${svc} failed`);
257
+ if (!ok("systemctl", ["restart", svc])) throw new Error(`systemctl restart ${svc} failed; see journalctl -u ${svc}`);
258
+ let pid = 0;
259
+ await waitFor(async () => {
260
+ if (!ok("systemctl", ["is-active", "--quiet", svc])) return false;
261
+ pid = Number(o.capture("systemctl", ["show", "-p", "MainPID", "--value", svc]).stdout.trim()) || 0;
262
+ return pid > 1 && await o.runsBinary(pid, bin, "linux");
263
+ }, `systemctl restart ${svc}`);
264
+ log(`==> Worker restarted (systemd system unit ${unit}, pid ${pid}); it starts at boot`);
265
+ return;
266
+ }
267
+ if (system) log(`warning: running as root but systemd is not available; the worker runs detached and will NOT survive a reboot. Start it from your init system: ${[bin, ...service.extra, service.flag, config].join(" ")}`);
268
+ if (!system && o.has("systemctl") && ok("systemctl", ["--user", "show-environment"])) {
269
+ const svc = `${service.unit}.service`;
270
+ const unit = join(env.XDG_CONFIG_HOME || join(home, ".config"), "systemd", "user", svc);
271
+ await ensureUserDir(dirname(unit));
272
+ await atomicWrite(unit, systemdUnit({ bin, config, service }), { mode: 0o644 });
273
+ if (!ok("systemctl", ["--user", "daemon-reload"])) throw new Error("systemctl --user daemon-reload failed");
274
+ if (!ok("systemctl", ["--user", "enable", svc])) throw new Error("systemctl --user enable failed");
275
+ if (!ok("systemctl", ["--user", "restart", svc])) throw new Error("systemctl --user restart failed");
276
+ let pid = 0;
277
+ await waitFor(async () => {
278
+ if (!ok("systemctl", ["--user", "is-active", "--quiet", svc])) return false;
279
+ pid = Number(o.capture("systemctl", ["--user", "show", "-p", "MainPID", "--value", svc]).stdout.trim()) || 0;
280
+ return pid > 1 && await o.runsBinary(pid, bin, "linux");
281
+ }, "systemctl --user restart");
282
+ log(`==> Agent restarted (systemd --user: ${service.unit}, pid ${pid})`);
283
+ if (o.has("loginctl")) o.run("loginctl", ["enable-linger", userInfo().username]);
284
+ return;
285
+ }
286
+ // No service manager: stop the earlier detached process of this service (pidfile,
287
+ // verified to run our binary, waited for) so two copies never share one credential
288
+ // (`list-agent` then `list-tools` reuse the worker); then start the new one and
289
+ // check it is running the new binary.
290
+ const pidPath = service === AGENT_SERVICE ? pidFile(state) : join(state, `${service.log}.pid`);
291
+ await (o.stopPidfile || stopPidfileAgent)(pidPath, [bin], log);
292
+ const out = await open(join(logs, `${service.log}.log`), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | (constants.O_NOFOLLOW || 0), 0o600);
293
+ const child = (o.spawn || spawn)(bin, [...service.extra, service.flag, config], { detached: true, stdio: ["ignore", out.fd, out.fd], env: childEnv(env) });
294
+ child.unref();
295
+ await out.close();
296
+ await atomicWrite(pidPath, `${child.pid}\n`, { mode: 0o600 });
297
+ await o.sleep(500);
298
+ if (!(await o.runsBinary(child.pid, bin, o.platform))) throw new Error(`the agent exited right after starting; see ${join(logs, `${service.log}.log`)}`);
299
+ log(`==> Agent started in the background (pid ${child.pid}; no service manager found, re-run after reboot)`);
300
+ }
301
+
302
+ export const HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Notification", "Stop", "SessionEnd", "PermissionRequest", "SubagentStart", "SubagentStop"];
303
+
304
+ const isOurHook = (h) => typeof h?.command === "string" && /(^|\/)agentcoor-agent$/.test(h.command) && Array.isArray(h.args) && h.args.includes("-report-claude-hook");
305
+
306
+ /** Replace every AgentCoor lifecycle hook (from any install location) with exactly one per event. */
307
+ export function mergeClaudeHooks(text, { bin, config }) {
308
+ const data = parseJsonObject(text, "~/.claude/settings.json");
309
+ if (data.hooks !== undefined && (typeof data.hooks !== "object" || Array.isArray(data.hooks) || data.hooks === null)) throw new Error("~/.claude/settings.json has an unexpected \"hooks\" value; nothing was changed");
310
+ const hooks = (data.hooks ||= {});
311
+ for (const ev of HOOK_EVENTS) {
312
+ if (hooks[ev] !== undefined && !Array.isArray(hooks[ev])) throw new Error(`~/.claude/settings.json has a non-array "hooks.${ev}" value; nothing was changed`);
313
+ const entries = hooks[ev] || [];
314
+ const cleaned = [];
315
+ for (const e of entries) {
316
+ if (!Array.isArray(e?.hooks)) { cleaned.push(e); continue; }
317
+ const rest = e.hooks.filter((h) => !isOurHook(h));
318
+ if (rest.length) cleaned.push(rest.length === e.hooks.length ? e : { ...e, hooks: rest });
319
+ }
320
+ cleaned.push({ hooks: [{ type: "command", command: bin, args: ["-config", config, "-report-claude-hook"], timeout: 5 }] });
321
+ hooks[ev] = cleaned;
322
+ }
323
+ return JSON.stringify(data, null, 2) + "\n";
324
+ }
325
+
326
+ export const codexMcpToml = ({ bin, config }) => `
327
+ # Added by agentcoor connect
328
+ [mcp_servers.agentcoor]
329
+ command = ${tomlString(bin)}
330
+ args = ["-config", ${tomlString(config)}, "-mcp"]
331
+ `;
332
+
333
+ /** Optional: connect installed harnesses, mirroring install.sh. Failures never abort the install and never clobber a file. */
334
+ async function connectHarnesses({ bin, config, log, env }) {
335
+ const home = env.HOME || homedir();
336
+ if (has("claude")) {
337
+ run("claude", ["mcp", "remove", "--scope", "user", "agentcoor"]);
338
+ if (run("claude", ["mcp", "add", "--scope", "user", "agentcoor", "--", bin, "-config", config, "-mcp"]).status === 0) log("==> Claude Code: AgentCoor tools added");
339
+ try {
340
+ await updateFile(join(home, ".claude", "settings.json"), (t) => mergeClaudeHooks(t, { bin, config }));
341
+ log("==> Claude Code: lifecycle hooks installed");
342
+ } catch (e) { log(`==> Claude Code: hooks not installed (${e.message})`); }
343
+ }
344
+ if (has("codex")) {
345
+ try {
346
+ const r = await updateFile(join(home, ".codex", "config.toml"), (t) => replaceTomlTables(t, ["mcp_servers.agentcoor"], codexMcpToml({ bin, config })));
347
+ log(r ? "==> Codex: AgentCoor tools added" : "==> Codex: already configured");
348
+ } catch (e) { log(`==> Codex: not configured (${e.message})`); }
349
+ }
350
+ }
351
+
package/src/fsx.js ADDED
@@ -0,0 +1,125 @@
1
+ // Filesystem helpers that refuse to follow attacker-controlled links and never
2
+ // leave a predictable temporary file behind.
3
+ import { open, mkdir, lstat, stat, chmod, rename, unlink, readFile, realpath } from "node:fs/promises";
4
+ import { constants } from "node:fs";
5
+ import { randomBytes, createHash } from "node:crypto";
6
+ import { dirname, basename, join } from "node:path";
7
+
8
+ const uid = () => (typeof process.getuid === "function" ? process.getuid() : null);
9
+ const CTRL = /[\u0000-\u001f\u007f]/;
10
+
11
+ /** Reject paths with control characters (newlines would inject into service files). */
12
+ export function assertSafePath(p, what = "path") {
13
+ if (typeof p !== "string" || !p || CTRL.test(p)) throw new Error(`refusing to use a ${what} containing control characters: ${JSON.stringify(p)}`);
14
+ return p;
15
+ }
16
+
17
+ function checkOwnedDir(st, p) {
18
+ if (st.isSymbolicLink()) throw new Error(`refusing to use ${p}: it is a symbolic link`);
19
+ if (!st.isDirectory()) throw new Error(`refusing to use ${p}: not a directory`);
20
+ if (uid() !== null && st.uid !== uid()) throw new Error(`refusing to use ${p}: owned by uid ${st.uid}, not ${uid()}`);
21
+ }
22
+
23
+ /**
24
+ * Create (or adopt) a private directory: it must be a real directory owned by
25
+ * the current user, not a symlink, and ends up mode 0700 even if it existed.
26
+ */
27
+ export async function ensurePrivateDir(p) {
28
+ assertSafePath(p, "directory");
29
+ await mkdir(p, { recursive: true, mode: 0o700 });
30
+ checkOwnedDir(await lstat(p), p);
31
+ await chmod(p, 0o700);
32
+ return p;
33
+ }
34
+
35
+ /** Ensure a directory we write into but do not own the policy of (e.g. ~/.claude): owned by us, not group/world writable. */
36
+ export async function ensureUserDir(p) {
37
+ assertSafePath(p, "directory");
38
+ await mkdir(p, { recursive: true, mode: 0o700 });
39
+ const st = await stat(p); // a symlinked dotfile directory is acceptable if it resolves to our own directory
40
+ if (!st.isDirectory()) throw new Error(`refusing to use ${p}: not a directory`);
41
+ if (uid() !== null && st.uid !== uid()) throw new Error(`refusing to use ${p}: owned by uid ${st.uid}, not ${uid()}`);
42
+ if (st.mode & 0o022) throw new Error(`refusing to write into ${p}: it is group- or world-writable`);
43
+ return p;
44
+ }
45
+
46
+ /** Read a file; only a missing file is treated as absent (returns null). Every other error propagates. */
47
+ export async function readIfExists(p) {
48
+ try { return await readFile(p, "utf8"); } catch (e) { if (e.code === "ENOENT") return null; throw e; }
49
+ }
50
+
51
+ /**
52
+ * Inode, mtime, size and content of a regular file, read through one
53
+ * O_NOFOLLOW descriptor; null when missing. Used to detect concurrent edits.
54
+ */
55
+ export async function snapshot(p) {
56
+ let fh;
57
+ try { fh = await open(p, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0)); }
58
+ catch (e) { if (e.code === "ENOENT") return null; throw e; }
59
+ try {
60
+ const st = await fh.stat();
61
+ if (!st.isFile()) throw new Error(`refusing to edit ${p}: not a regular file`);
62
+ if (st.size > 16 * 1024 * 1024) throw new Error(`refusing to edit ${p}: too large`);
63
+ const text = await fh.readFile("utf8");
64
+ return { dev: st.dev, ino: st.ino, mtimeMs: st.mtimeMs, size: st.size, text };
65
+ } finally { await fh.close(); }
66
+ }
67
+
68
+ export function sameSnapshot(a, b) {
69
+ if (!a || !b) return a === b;
70
+ return a.dev === b.dev && a.ino === b.ino && a.mtimeMs === b.mtimeMs && a.size === b.size && a.text === b.text;
71
+ }
72
+
73
+ /** Open a brand-new file exclusively without following links. */
74
+ export async function openExclusive(p, mode) {
75
+ return open(p, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), mode);
76
+ }
77
+
78
+ const randName = (target) => join(dirname(target), `.${basename(target)}.${randomBytes(12).toString("hex")}.tmp`);
79
+
80
+ /**
81
+ * Atomically replace `target` with `data`: random O_EXCL|O_NOFOLLOW temporary in
82
+ * the same directory, write, re-read through the same descriptor and hash the
83
+ * exact bytes on disk (compared against `sha256` when given), fsync, rename,
84
+ * fsync the directory. Returns the hex SHA-256 of what was written.
85
+ */
86
+ export async function atomicWrite(target, data, { mode = 0o600, sha256 } = {}) {
87
+ assertSafePath(target, "file path");
88
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(String(data));
89
+ const tmp = randName(target);
90
+ const fh = await openExclusive(tmp, mode);
91
+ let ok = false;
92
+ try {
93
+ await fh.writeFile(buf);
94
+ await fh.chmod(mode);
95
+ const back = Buffer.alloc(buf.length);
96
+ const { bytesRead } = await fh.read(back, 0, buf.length, 0);
97
+ const got = createHash("sha256").update(back.subarray(0, bytesRead)).digest("hex");
98
+ const want = sha256 || createHash("sha256").update(buf).digest("hex");
99
+ if (bytesRead !== buf.length || got !== want) throw new Error(`integrity check failed while writing ${target}`);
100
+ await fh.sync();
101
+ await fh.close();
102
+ const cur = await lstat(target).catch((e) => { if (e.code === "ENOENT") return null; throw e; });
103
+ if (cur && cur.isDirectory()) throw new Error(`refusing to replace directory ${target}`);
104
+ await rename(tmp, target);
105
+ ok = true;
106
+ const dh = await open(dirname(target), "r").catch(() => null);
107
+ if (dh) { await dh.sync().catch(() => {}); await dh.close(); }
108
+ return got;
109
+ } finally {
110
+ if (!ok) { await fh.close().catch(() => {}); await unlink(tmp).catch(() => {}); }
111
+ }
112
+ }
113
+
114
+ /** Write a 0600 backup copy of `content` next to `target` under an exclusive random name. */
115
+ export async function writeBackup(target, content, now = new Date()) {
116
+ const p = `${target}.agentcoor-backup-${now.toISOString().replace(/[:.]/g, "-")}-${randomBytes(6).toString("hex")}`;
117
+ const fh = await openExclusive(p, 0o600);
118
+ try { await fh.writeFile(content); await fh.sync(); } finally { await fh.close(); }
119
+ return p;
120
+ }
121
+
122
+ /** Resolve an existing harness config file through dotfile symlinks; a missing file stays as given. */
123
+ export async function resolveTarget(p) {
124
+ try { return await realpath(p); } catch (e) { if (e.code === "ENOENT") return p; throw e; }
125
+ }
package/src/harness.js ADDED
@@ -0,0 +1,138 @@
1
+ import { access } from "node:fs/promises";
2
+ import { join, dirname } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { baseUrls, homeDir } from "./config.js";
5
+ import { writeBackup, atomicWrite, ensurePrivateDir, ensureUserDir, resolveTarget, snapshot, sameSnapshot } from "./fsx.js";
6
+ import { replaceTomlTables, tomlString } from "./toml.js";
7
+
8
+ export const HARNESSES = ["claude", "codex", "openclaude", "cursor", "env"];
9
+ export const TIERS = ["agentcoor-auto", "agentcoor-fast", "agentcoor-compute"];
10
+
11
+ const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
12
+ const home = (env) => env.HOME || homedir();
13
+
14
+ export function envExports(origin, key) {
15
+ const u = baseUrls(origin);
16
+ return [
17
+ `export ANTHROPIC_BASE_URL=${shq(u.anthropic)}`,
18
+ `export ANTHROPIC_API_KEY=${shq(key)}`,
19
+ `export OPENAI_BASE_URL=${shq(u.openai)}`,
20
+ `export OPENAI_API_KEY=${shq(key)}`,
21
+ `export AGENTCOOR_API_KEY=${shq(key)}`,
22
+ ].join("\n") + "\n";
23
+ }
24
+
25
+ export const CODEX_MODEL_TABLES = ["model_providers.agentcoor", "profiles.agentcoor"];
26
+
27
+ export function codexToml(origin) {
28
+ const u = baseUrls(origin);
29
+ return `
30
+ # Added by agentcoor setup codex. Use with: codex --profile agentcoor
31
+ [model_providers.agentcoor]
32
+ name = "AgentCoor"
33
+ base_url = ${tomlString(u.openai)}
34
+ env_key = "AGENTCOOR_API_KEY"
35
+ wire_api = "chat"
36
+
37
+ [profiles.agentcoor]
38
+ model_provider = "agentcoor"
39
+ model = "agentcoor-auto"
40
+ `;
41
+ }
42
+
43
+ /**
44
+ * Build the plan for a harness: text to print, and optionally a file to write.
45
+ * `displayKey` is what appears in printed text (masked unless --show-key); `key` is what is written to files.
46
+ */
47
+ export function plan(harness, { origin, key, displayKey, env = process.env }) {
48
+ const u = baseUrls(origin);
49
+ switch (harness) {
50
+ case "claude":
51
+ return {
52
+ print: `# Claude Code\nexport ANTHROPIC_BASE_URL=${u.anthropic}\nexport ANTHROPIC_API_KEY=${displayKey}\n\n` +
53
+ `Claude model names map automatically: opus -> agentcoor-fast, haiku -> agentcoor-compute, others -> agentcoor-auto.\n` +
54
+ `With --write, these are stored in the "env" block of ~/.claude/settings.json.\n`,
55
+ file: join(home(env), ".claude", "settings.json"),
56
+ merge: (text) => {
57
+ const data = parseJsonObject(text, "~/.claude/settings.json");
58
+ if (data.env !== undefined && (data.env === null || typeof data.env !== "object" || Array.isArray(data.env))) throw new Error('~/.claude/settings.json has a non-object "env" value; nothing was changed');
59
+ data.env = { ...(data.env || {}), ANTHROPIC_BASE_URL: u.anthropic, ANTHROPIC_API_KEY: key };
60
+ return JSON.stringify(data, null, 2) + "\n";
61
+ },
62
+ };
63
+ case "openclaude":
64
+ return {
65
+ print: `# OpenClaude\nexport ANTHROPIC_BASE_URL=${u.anthropic}\nexport ANTHROPIC_API_KEY=${displayKey}\n\n` +
66
+ `With --write, exports are saved to ~/.agentcoor/env.sh; add "source ~/.agentcoor/env.sh" to your shell profile.\n`,
67
+ file: join(homeDir(env), "env.sh"), private: true,
68
+ merge: () => envExports(origin, key),
69
+ };
70
+ case "env":
71
+ return {
72
+ print: envExports(origin, displayKey).replaceAll(`'${displayKey}'`, displayKey) +
73
+ `\nWith --write, these are saved to ~/.agentcoor/env.sh; add "source ~/.agentcoor/env.sh" to your shell profile.\n`,
74
+ file: join(homeDir(env), "env.sh"), private: true,
75
+ merge: () => envExports(origin, key),
76
+ };
77
+ case "codex":
78
+ return {
79
+ print: `# Codex (~/.codex/config.toml)${codexToml(origin)}\nThen: export AGENTCOOR_API_KEY=${displayKey}\n codex --profile agentcoor\n`,
80
+ file: join(home(env), ".codex", "config.toml"),
81
+ merge: (text) => replaceTomlTables(text, CODEX_MODEL_TABLES, codexToml(origin)),
82
+ };
83
+ case "cursor":
84
+ return {
85
+ print: `# Cursor\nSettings -> Models -> API Keys:\n OpenAI API Key: ${displayKey}\n Override OpenAI Base URL: ${u.openai}\n` +
86
+ `Then add a custom model named one of: ${TIERS.join(", ")}.\n(Cursor stores this in its own settings; --write is not supported.)\n`,
87
+ };
88
+ default:
89
+ throw new Error(`unknown harness "${harness}" (choose: ${HARNESSES.join(", ")})`);
90
+ }
91
+ }
92
+
93
+ /** Parse a JSON object; an empty/missing file is {}, anything malformed aborts (never silently overwritten). */
94
+ export function parseJsonObject(text, label) {
95
+ if (text === null || text === undefined || text.trim() === "") return {};
96
+ let data;
97
+ try { data = JSON.parse(text); } catch (e) { throw new Error(`${label} is not valid JSON (${e.message}); fix or move it, then retry. Nothing was changed.`); }
98
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error(`${label} is not a JSON object; nothing was changed`);
99
+ return data;
100
+ }
101
+
102
+ /**
103
+ * Read-modify-write a config file safely: only ENOENT counts as empty, `merge`
104
+ * may throw to abort (malformed input), the previous content is backed up to an
105
+ * exclusive random 0600 file, and the new content replaces it atomically.
106
+ * Immediately before replacing, the file's inode, mtime and content are compared
107
+ * with what was read; if another process changed it in between, the merge is
108
+ * redone on the fresh content (up to `retries` times) and then aborted. This
109
+ * narrows, but does not close, the window: an edit landing after the final
110
+ * comparison and before the rename can still be overwritten (see docs/SECURITY.md,
111
+ * known limitations); the backup file keeps the previous content.
112
+ * Returns {path, backup} or null when nothing changed.
113
+ */
114
+ export async function updateFile(file, merge, { privateDir = false, now = new Date(), retries = 3, beforeReplace } = {}) {
115
+ if (privateDir) await ensurePrivateDir(dirname(file)); else await ensureUserDir(dirname(file));
116
+ const target = await resolveTarget(file);
117
+ for (let attempt = 0; attempt <= retries; attempt++) {
118
+ const before = await snapshot(target);
119
+ const existing = before ? before.text : null;
120
+ const next = merge(existing ?? "");
121
+ if (next === null || next === existing) return null;
122
+ if (beforeReplace) await beforeReplace(attempt);
123
+ if (!sameSnapshot(before, await snapshot(target))) continue;
124
+ const backup = existing ? await writeBackup(target, existing, now) : null;
125
+ if (!sameSnapshot(before, await snapshot(target))) continue;
126
+ await atomicWrite(target, next, { mode: 0o600 });
127
+ return { path: target, backup };
128
+ }
129
+ throw new Error(`${target} kept changing while it was being updated; nothing was changed. Retry when other tools are idle.`);
130
+ }
131
+
132
+ /** Write a plan's file, backing up any existing file first. Returns {path, backup} or null if nothing changed. */
133
+ export async function applyPlan(p, now = new Date()) {
134
+ if (!p.file) throw new Error("this harness has no config file to write; follow the printed steps");
135
+ return updateFile(p.file, p.merge, { privateDir: !!p.private, now });
136
+ }
137
+
138
+ export async function exists(p) { try { await access(p); return true; } catch { return false; } }