@primusclaw/connector 0.1.2 → 0.1.4
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/README.md +50 -10
- package/dist/cli.js +857 -154
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/commands.ts
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { parseArgs } from "node:util";
|
|
4
|
+
import fs16 from "node:fs";
|
|
5
|
+
import os6 from "node:os";
|
|
6
|
+
import path17 from "node:path";
|
|
7
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
8
|
+
import { parseArgs as parseArgs2 } from "node:util";
|
|
9
9
|
|
|
10
10
|
// src/audit.ts
|
|
11
11
|
import fs from "node:fs";
|
|
@@ -335,11 +335,11 @@ var RevokedError = class extends Error {
|
|
|
335
335
|
}
|
|
336
336
|
};
|
|
337
337
|
var HttpError = class extends Error {
|
|
338
|
-
constructor(
|
|
339
|
-
super(`${
|
|
340
|
-
this.status =
|
|
338
|
+
constructor(status2, code, path18) {
|
|
339
|
+
super(`${path18} answered ${status2} ${code}`);
|
|
340
|
+
this.status = status2;
|
|
341
341
|
this.code = code;
|
|
342
|
-
this.path =
|
|
342
|
+
this.path = path18;
|
|
343
343
|
}
|
|
344
344
|
status;
|
|
345
345
|
code;
|
|
@@ -363,8 +363,8 @@ var ClawClient = class {
|
|
|
363
363
|
...extra
|
|
364
364
|
};
|
|
365
365
|
}
|
|
366
|
-
async send(method,
|
|
367
|
-
const url = this.o.server +
|
|
366
|
+
async send(method, path18, body, signal) {
|
|
367
|
+
const url = this.o.server + path18;
|
|
368
368
|
for (let attempt = 0; ; attempt++) {
|
|
369
369
|
const headers = await this.headers(body === void 0 ? {} : { "content-type": "application/json" });
|
|
370
370
|
let res;
|
|
@@ -376,7 +376,7 @@ var ClawClient = class {
|
|
|
376
376
|
signal: signal ?? AbortSignal.timeout(this.o.requestTimeoutMs ?? 2e4)
|
|
377
377
|
});
|
|
378
378
|
} catch (e) {
|
|
379
|
-
throw new TransportError(`${method} ${
|
|
379
|
+
throw new TransportError(`${method} ${path18}: ${errMessage(e)}`);
|
|
380
380
|
}
|
|
381
381
|
if (res.status !== 401) return res;
|
|
382
382
|
const err = await errorCode(res);
|
|
@@ -385,12 +385,12 @@ var ClawClient = class {
|
|
|
385
385
|
this.o.tokens.invalidate();
|
|
386
386
|
continue;
|
|
387
387
|
}
|
|
388
|
-
throw new HttpError(401, err,
|
|
388
|
+
throw new HttpError(401, err, path18);
|
|
389
389
|
}
|
|
390
390
|
}
|
|
391
|
-
async json(method,
|
|
392
|
-
const res = await this.send(method,
|
|
393
|
-
if (!res.ok) throw new HttpError(res.status, await errorCode(res),
|
|
391
|
+
async json(method, path18, body) {
|
|
392
|
+
const res = await this.send(method, path18, body);
|
|
393
|
+
if (!res.ok) throw new HttpError(res.status, await errorCode(res), path18);
|
|
394
394
|
const parsed = await res.json().catch(() => null);
|
|
395
395
|
return isObject(parsed) && isObject(parsed.data) ? parsed.data : {};
|
|
396
396
|
}
|
|
@@ -406,6 +406,17 @@ var ClawClient = class {
|
|
|
406
406
|
const d = await this.json("POST", `/v1/remote-agents/connectors/${cid}/events`, { batch_id: batchId, events });
|
|
407
407
|
return { acked: isObject(d.acked) ? d.acked : {}, rejected: Array.isArray(d.rejected) ? d.rejected : [] };
|
|
408
408
|
}
|
|
409
|
+
/**
|
|
410
|
+
* Revoke connector `cid` at Claw, for `logout`: Claw sends it nothing more and closes its sessions. A key is a user's
|
|
411
|
+
* credential and uses the user-side DELETE; a device sign-in's access token is refused there, and uses the
|
|
412
|
+
* connector-side route made for this. Throws {@link HttpError} 404 `not_found` when Claw has no live row by that id
|
|
413
|
+
* for this sign-in (already revoked, or another account's).
|
|
414
|
+
*/
|
|
415
|
+
async revoke(cid, kind) {
|
|
416
|
+
const id = encodeURIComponent(cid);
|
|
417
|
+
if (kind === "key") await this.json("DELETE", `/v1/remote-agents/connectors/${id}`);
|
|
418
|
+
else await this.json("POST", `/v1/remote-agents/connectors/${id}/revoke`);
|
|
419
|
+
}
|
|
409
420
|
/**
|
|
410
421
|
* Hold the command stream. Yields every frame (a `ping` too, since it is
|
|
411
422
|
* proof of life). Ends normally when the server closes it; throws
|
|
@@ -428,21 +439,21 @@ var ClawClient = class {
|
|
|
428
439
|
};
|
|
429
440
|
try {
|
|
430
441
|
arm();
|
|
431
|
-
const
|
|
442
|
+
const path18 = `/v1/remote-agents/connectors/${cid}/commands`;
|
|
432
443
|
const headers = await this.headers({ accept: "text/event-stream", ...opts.lastEventId ? { "last-event-id": String(opts.lastEventId) } : {} });
|
|
433
444
|
let res;
|
|
434
445
|
try {
|
|
435
|
-
res = await this.f(this.o.server +
|
|
446
|
+
res = await this.f(this.o.server + path18, { headers, signal: stop.signal });
|
|
436
447
|
} catch (e) {
|
|
437
|
-
throw new TransportError(timedOut ? `no response for ${opts.watchdogMs} ms` : `GET ${
|
|
448
|
+
throw new TransportError(timedOut ? `no response for ${opts.watchdogMs} ms` : `GET ${path18}: ${errMessage(e)}`);
|
|
438
449
|
}
|
|
439
450
|
if (res.status === 401) {
|
|
440
451
|
const err = await errorCode(res);
|
|
441
452
|
if (err === "revoked") throw new RevokedError();
|
|
442
453
|
this.o.tokens.invalidate();
|
|
443
|
-
throw new HttpError(401, err,
|
|
454
|
+
throw new HttpError(401, err, path18);
|
|
444
455
|
}
|
|
445
|
-
if (!res.ok || !res.body) throw new HttpError(res.status, await errorCode(res),
|
|
456
|
+
if (!res.ok || !res.body) throw new HttpError(res.status, await errorCode(res), path18);
|
|
446
457
|
try {
|
|
447
458
|
for await (const frame of readSse(res.body, arm)) yield frame;
|
|
448
459
|
} catch (e) {
|
|
@@ -1307,7 +1318,7 @@ function hooksStatus(configDir) {
|
|
|
1307
1318
|
}
|
|
1308
1319
|
|
|
1309
1320
|
// src/connector.ts
|
|
1310
|
-
import
|
|
1321
|
+
import fs14 from "node:fs";
|
|
1311
1322
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1312
1323
|
|
|
1313
1324
|
// src/attach.ts
|
|
@@ -1859,8 +1870,106 @@ function fitResumable(base, items, limit = REMOTE_AGENT_LIMITS.maxCapabilitiesBy
|
|
|
1859
1870
|
}
|
|
1860
1871
|
}
|
|
1861
1872
|
|
|
1862
|
-
// src/
|
|
1873
|
+
// src/health.ts
|
|
1874
|
+
import fs11 from "node:fs";
|
|
1875
|
+
import path12 from "node:path";
|
|
1876
|
+
|
|
1877
|
+
// src/paths.ts
|
|
1878
|
+
import os4 from "node:os";
|
|
1863
1879
|
import path11 from "node:path";
|
|
1880
|
+
function resolveDirs(env = process.env, home = os4.homedir()) {
|
|
1881
|
+
const one = env.CLAW_CONNECTOR_HOME?.trim();
|
|
1882
|
+
if (one) return { config: path11.join(one, "config"), state: path11.join(one, "state") };
|
|
1883
|
+
const cfg = env.XDG_CONFIG_HOME?.trim() || path11.join(home, ".config");
|
|
1884
|
+
const st = env.XDG_STATE_HOME?.trim() || path11.join(home, ".local", "state");
|
|
1885
|
+
return { config: path11.join(cfg, "claw-connector"), state: path11.join(st, "claw-connector") };
|
|
1886
|
+
}
|
|
1887
|
+
var configFile = (d) => path11.join(d.config, "agents.toml");
|
|
1888
|
+
var credentialsFile = (d) => path11.join(d.config, "credentials.json");
|
|
1889
|
+
var identityFile = (d) => path11.join(d.state, "connector.json");
|
|
1890
|
+
var sessionsFile = (d) => path11.join(d.state, "sessions.json");
|
|
1891
|
+
var childrenFile = (d) => path11.join(d.state, "children.json");
|
|
1892
|
+
var pidFile = (d) => path11.join(d.state, "connector.pid");
|
|
1893
|
+
var pauseFile = (d) => path11.join(d.state, "paused");
|
|
1894
|
+
var auditFile = (d) => path11.join(d.state, "audit.jsonl");
|
|
1895
|
+
var outboxDir = (d) => path11.join(d.state, "outbox");
|
|
1896
|
+
var logDir = (d) => path11.join(d.state, "logs");
|
|
1897
|
+
var disconnectsFile = (d) => path11.join(d.state, "disconnects.jsonl");
|
|
1898
|
+
var needsLoginFile = (d) => path11.join(d.state, "needs-login.json");
|
|
1899
|
+
var supervisorPidFile = (d) => path11.join(d.state, "supervisor.pid");
|
|
1900
|
+
var runLogFile = (d) => path11.join(d.state, "run.log");
|
|
1901
|
+
var keepaliveFile = (d) => path11.join(d.state, "keepalive.sh");
|
|
1902
|
+
var serviceFile = (d) => path11.join(d.state, "service.json");
|
|
1903
|
+
|
|
1904
|
+
// src/health.ts
|
|
1905
|
+
var MAX_BYTES = 128 * 1024;
|
|
1906
|
+
function recordDisconnect(dirs, d) {
|
|
1907
|
+
const file = disconnectsFile(dirs);
|
|
1908
|
+
try {
|
|
1909
|
+
fs11.mkdirSync(path12.dirname(file), { recursive: true, mode: 448 });
|
|
1910
|
+
const fd = fs11.openSync(file, "a+", 384);
|
|
1911
|
+
let lines = null;
|
|
1912
|
+
try {
|
|
1913
|
+
fs11.writeSync(fd, JSON.stringify({ at: d.at ?? (/* @__PURE__ */ new Date()).toISOString(), reason: d.reason, ...d.detail ? { detail: d.detail.slice(0, 300) } : {} }) + "\n");
|
|
1914
|
+
const size = fs11.fstatSync(fd).size;
|
|
1915
|
+
if (size > MAX_BYTES) {
|
|
1916
|
+
const buf = Buffer.alloc(size);
|
|
1917
|
+
let got = 0;
|
|
1918
|
+
while (got < size) {
|
|
1919
|
+
const n = fs11.readSync(fd, buf, got, size - got, got);
|
|
1920
|
+
if (n === 0) break;
|
|
1921
|
+
got += n;
|
|
1922
|
+
}
|
|
1923
|
+
lines = buf.subarray(0, got).toString("utf8").split("\n").filter(Boolean);
|
|
1924
|
+
}
|
|
1925
|
+
} finally {
|
|
1926
|
+
fs11.closeSync(fd);
|
|
1927
|
+
}
|
|
1928
|
+
if (lines) {
|
|
1929
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
1930
|
+
fs11.writeFileSync(tmp, lines.slice(Math.floor(lines.length / 2)).join("\n") + "\n", { mode: 384 });
|
|
1931
|
+
fs11.renameSync(tmp, file);
|
|
1932
|
+
}
|
|
1933
|
+
} catch {
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
function recentDisconnects(dirs, n = 1) {
|
|
1937
|
+
try {
|
|
1938
|
+
const out = [];
|
|
1939
|
+
for (const line of fs11.readFileSync(disconnectsFile(dirs), "utf8").split("\n").filter(Boolean).slice(-n)) {
|
|
1940
|
+
try {
|
|
1941
|
+
const v = JSON.parse(line);
|
|
1942
|
+
if (isObject(v) && typeof v.at === "string" && typeof v.reason === "string") out.push({ at: v.at, reason: v.reason, ...typeof v.detail === "string" ? { detail: v.detail } : {} });
|
|
1943
|
+
} catch {
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
return out;
|
|
1947
|
+
} catch {
|
|
1948
|
+
return [];
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
function markNeedsLogin(dirs, reason) {
|
|
1952
|
+
try {
|
|
1953
|
+
fs11.mkdirSync(dirs.state, { recursive: true, mode: 448 });
|
|
1954
|
+
fs11.writeFileSync(needsLoginFile(dirs), JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), reason: reason.slice(0, 300) }), { mode: 384 });
|
|
1955
|
+
} catch {
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
function needsLogin(dirs) {
|
|
1959
|
+
try {
|
|
1960
|
+
const v = JSON.parse(fs11.readFileSync(needsLoginFile(dirs), "utf8"));
|
|
1961
|
+
if (isObject(v) && typeof v.reason === "string") return { at: typeof v.at === "string" ? v.at : "", reason: v.reason };
|
|
1962
|
+
return { at: "", reason: "unreadable needs-login.json" };
|
|
1963
|
+
} catch (e) {
|
|
1964
|
+
return e.code === "ENOENT" ? null : { at: "", reason: "unreadable needs-login.json" };
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
function clearNeedsLogin(dirs) {
|
|
1968
|
+
fs11.rmSync(needsLoginFile(dirs), { force: true });
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// src/session.ts
|
|
1972
|
+
import path13 from "node:path";
|
|
1864
1973
|
|
|
1865
1974
|
// src/permissions.ts
|
|
1866
1975
|
import { randomUUID } from "node:crypto";
|
|
@@ -2206,7 +2315,7 @@ var Session = class {
|
|
|
2206
2315
|
sessionId: this.id,
|
|
2207
2316
|
cwd: this.cwd,
|
|
2208
2317
|
hooks: this.hooks(),
|
|
2209
|
-
logFile:
|
|
2318
|
+
logFile: path13.join(this.env.logDir, `adapter-${this.agent.id}.log`),
|
|
2210
2319
|
version: this.env.version
|
|
2211
2320
|
}).catch((e) => {
|
|
2212
2321
|
throw new SessionRefusal("adapter_start_failed", errMessage(e));
|
|
@@ -2502,16 +2611,16 @@ var Session = class {
|
|
|
2502
2611
|
* delivers it again instead of losing it.
|
|
2503
2612
|
*/
|
|
2504
2613
|
enqueuePrompt(text, cmdId, started = () => {
|
|
2505
|
-
}) {
|
|
2614
|
+
}, cmdSeq = null) {
|
|
2506
2615
|
if (this.closed) {
|
|
2507
|
-
this.rejectCommand("prompt", cmdId, "session_closed");
|
|
2616
|
+
this.rejectCommand("prompt", cmdId, "session_closed", cmdSeq);
|
|
2508
2617
|
return started();
|
|
2509
2618
|
}
|
|
2510
2619
|
if (this.queue.length >= MAX_QUEUED_PROMPTS) {
|
|
2511
|
-
this.rejectCommand("prompt", cmdId, "too_many_queued_prompts");
|
|
2620
|
+
this.rejectCommand("prompt", cmdId, "too_many_queued_prompts", cmdSeq);
|
|
2512
2621
|
return started();
|
|
2513
2622
|
}
|
|
2514
|
-
this.queue.push({ text, cmdId, started });
|
|
2623
|
+
this.queue.push({ text, cmdId, cmdSeq, started });
|
|
2515
2624
|
if (!this.turnRunning) void this.pump();
|
|
2516
2625
|
}
|
|
2517
2626
|
/** Prompts that will now never run (the process is being stopped): they count as handled. */
|
|
@@ -2526,21 +2635,22 @@ var Session = class {
|
|
|
2526
2635
|
try {
|
|
2527
2636
|
while (this.queue.length && !this.closed) {
|
|
2528
2637
|
const next = this.queue.shift();
|
|
2529
|
-
await this.runTurn(next.text, next.started, next.cmdId);
|
|
2638
|
+
await this.runTurn(next.text, next.started, next.cmdId, next.cmdSeq);
|
|
2530
2639
|
}
|
|
2531
2640
|
} finally {
|
|
2532
2641
|
this.turnRunning = false;
|
|
2533
2642
|
this.touch();
|
|
2534
2643
|
}
|
|
2535
2644
|
}
|
|
2536
|
-
|
|
2645
|
+
/** Every `turn_end` of the turn names its prompt: `cmd_id`, and `cmd_seq` (a number, which no credential scrub ever masks). */
|
|
2646
|
+
async runTurn(text, started, cmdId, cmdSeq) {
|
|
2537
2647
|
if (this.agent.attach) {
|
|
2538
2648
|
const refused = await this.preflight();
|
|
2539
2649
|
if (refused) {
|
|
2540
2650
|
started();
|
|
2541
2651
|
this.audit("prompt_refused", { reason: refused.reason, detail: refused.detail.slice(0, 300) });
|
|
2542
|
-
this.rejectCommand("prompt", cmdId, refused.reason);
|
|
2543
|
-
this.emit("turn_end", { stopReason: "error", error: refused.reason });
|
|
2652
|
+
this.rejectCommand("prompt", cmdId, refused.reason, cmdSeq);
|
|
2653
|
+
this.emit("turn_end", { stopReason: "error", error: refused.reason, cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
2544
2654
|
return;
|
|
2545
2655
|
}
|
|
2546
2656
|
}
|
|
@@ -2548,7 +2658,7 @@ var Session = class {
|
|
|
2548
2658
|
await this.ensureAdapter();
|
|
2549
2659
|
} catch (e) {
|
|
2550
2660
|
started();
|
|
2551
|
-
this.emit("turn_end", { stopReason: "error", error: e instanceof SessionRefusal ? e.reason : "adapter_unavailable" });
|
|
2661
|
+
this.emit("turn_end", { stopReason: "error", error: e instanceof SessionRefusal ? e.reason : "adapter_unavailable", cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
2552
2662
|
return this.refuse(e);
|
|
2553
2663
|
}
|
|
2554
2664
|
const a = this.adapter;
|
|
@@ -2560,10 +2670,10 @@ var Session = class {
|
|
|
2560
2670
|
started();
|
|
2561
2671
|
const res = await reply;
|
|
2562
2672
|
const usage = isObject(res.usage) ? res.usage : void 0;
|
|
2563
|
-
this.emit("turn_end", { stopReason: typeof res.stopReason === "string" ? res.stopReason : "end_turn", ...usage ? { usage } : {} });
|
|
2673
|
+
this.emit("turn_end", { stopReason: typeof res.stopReason === "string" ? res.stopReason : "end_turn", ...usage ? { usage } : {}, cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
2564
2674
|
} catch (e) {
|
|
2565
2675
|
this.audit("turn_failed", { error: errMessage(e) });
|
|
2566
|
-
this.emit("turn_end", { stopReason: "error", error: errMessage(e).slice(0, 300) });
|
|
2676
|
+
this.emit("turn_end", { stopReason: "error", error: errMessage(e).slice(0, 300), cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
2567
2677
|
} finally {
|
|
2568
2678
|
this.markTranscript();
|
|
2569
2679
|
if (watch) {
|
|
@@ -2632,15 +2742,20 @@ var Session = class {
|
|
|
2632
2742
|
this.emit("session_resumed", { acp_session_id: this.acpId, modes: this.shownModes(), currentMode: this.currentMode, unattended: this.unattended });
|
|
2633
2743
|
this.touch();
|
|
2634
2744
|
}
|
|
2635
|
-
/**
|
|
2745
|
+
/**
|
|
2746
|
+
* `cancel`: stop the turn, drop what was queued, answer every waiting permission request `cancelled`. Each prompt dropped
|
|
2747
|
+
* unrun is refused by name (`command_rejected`, reason `cancelled`, with its `cmd_id` and `cmd_seq`): it never gets a `turn_end`.
|
|
2748
|
+
*/
|
|
2636
2749
|
async cancel() {
|
|
2637
|
-
const
|
|
2750
|
+
const dropped = this.queue.map((q) => [q.cmdId, q.cmdSeq]);
|
|
2751
|
+
const queued = dropped.length;
|
|
2638
2752
|
this.dropQueued();
|
|
2753
|
+
for (const [cmdId, cmdSeq] of dropped) this.rejectCommand("prompt", cmdId, "cancelled", cmdSeq);
|
|
2639
2754
|
const wasRunning = this.turnRunning;
|
|
2640
2755
|
if (wasRunning && this.adapter && this.acpId) await this.adapter.notify("session/cancel", { sessionId: this.acpId }).catch(() => void 0);
|
|
2641
2756
|
for (const actionId of this.book.cancelAll()) this.emit("permission_resolved", { actionId, optionId: null, outcome: "cancelled", by: "cancel" });
|
|
2642
2757
|
this.audit("cancelled", { turn_running: wasRunning, queued_dropped: queued });
|
|
2643
|
-
if (!wasRunning) this.emit("turn_end", { stopReason: "cancelled" });
|
|
2758
|
+
if (!wasRunning) this.emit("turn_end", { stopReason: "cancelled", cmd_id: null, cmd_seq: null });
|
|
2644
2759
|
}
|
|
2645
2760
|
// --- ending ----------------------------------------------------------------------------
|
|
2646
2761
|
/** The adapter's process goes away; the session stays and resumes on demand. */
|
|
@@ -2675,9 +2790,10 @@ var Session = class {
|
|
|
2675
2790
|
this.audit("session_refused", { reason: r.reason, message: r.message.slice(0, 300) });
|
|
2676
2791
|
await this.close(r.reason);
|
|
2677
2792
|
}
|
|
2678
|
-
|
|
2793
|
+
/** A command refused; a prompt's refusal names it by `cmd_seq` too, as its turn_end would. */
|
|
2794
|
+
rejectCommand(type, cmdId, reason, cmdSeq = null) {
|
|
2679
2795
|
this.audit("command_rejected", { type, cmd_id: cmdId, reason });
|
|
2680
|
-
this.emit("command_rejected", { type, cmd_id: cmdId, reason });
|
|
2796
|
+
this.emit("command_rejected", { type, cmd_id: cmdId, reason, ...cmdSeq !== null ? { cmd_seq: cmdSeq } : {} });
|
|
2681
2797
|
}
|
|
2682
2798
|
};
|
|
2683
2799
|
function mapAdapterError(e) {
|
|
@@ -2703,6 +2819,7 @@ function limitStrings(value, max) {
|
|
|
2703
2819
|
|
|
2704
2820
|
// src/manager.ts
|
|
2705
2821
|
var MAX_ACTIVE_SESSIONS = 10;
|
|
2822
|
+
var cmdSeqOf = (cmd) => Number.isSafeInteger(cmd.cmd_seq) ? cmd.cmd_seq : null;
|
|
2706
2823
|
var SessionManager = class {
|
|
2707
2824
|
constructor(env) {
|
|
2708
2825
|
this.env = env;
|
|
@@ -2771,10 +2888,10 @@ var SessionManager = class {
|
|
|
2771
2888
|
switch (type) {
|
|
2772
2889
|
case "prompt":
|
|
2773
2890
|
if (typeof cmd.text !== "string" || cmd.text === "") {
|
|
2774
|
-
s.rejectCommand("prompt", cmdId, "text_required");
|
|
2891
|
+
s.rejectCommand("prompt", cmdId, "text_required", cmdSeqOf(cmd));
|
|
2775
2892
|
return false;
|
|
2776
2893
|
}
|
|
2777
|
-
s.enqueuePrompt(cmd.text, cmdId, handled);
|
|
2894
|
+
s.enqueuePrompt(cmd.text, cmdId, handled, cmdSeqOf(cmd));
|
|
2778
2895
|
return true;
|
|
2779
2896
|
case "cancel":
|
|
2780
2897
|
await s.cancel();
|
|
@@ -2906,8 +3023,8 @@ var SessionManager = class {
|
|
|
2906
3023
|
};
|
|
2907
3024
|
|
|
2908
3025
|
// src/outbox.ts
|
|
2909
|
-
import
|
|
2910
|
-
import
|
|
3026
|
+
import fs12 from "node:fs";
|
|
3027
|
+
import path14 from "node:path";
|
|
2911
3028
|
var DROPPABLE_TYPES = /* @__PURE__ */ new Set([
|
|
2912
3029
|
"agent_message_chunk",
|
|
2913
3030
|
"agent_thought_chunk",
|
|
@@ -2934,35 +3051,35 @@ var Outbox = class {
|
|
|
2934
3051
|
this.dir = opts.dir;
|
|
2935
3052
|
this.maxBytes = opts.maxBytesPerSession ?? 5 * 1024 * 1024;
|
|
2936
3053
|
this.now = opts.now ?? Date.now;
|
|
2937
|
-
|
|
3054
|
+
fs12.mkdirSync(this.dir, { recursive: true, mode: 448 });
|
|
2938
3055
|
this.load();
|
|
2939
3056
|
}
|
|
2940
|
-
eventsFile = (sid) =>
|
|
2941
|
-
metaFile = (sid) =>
|
|
3057
|
+
eventsFile = (sid) => path14.join(this.dir, `${safeName(sid)}.jsonl`);
|
|
3058
|
+
metaFile = (sid) => path14.join(this.dir, `${safeName(sid)}.meta.json`);
|
|
2942
3059
|
writeMeta(sid) {
|
|
2943
3060
|
const tmp = this.metaFile(sid) + ".tmp";
|
|
2944
|
-
|
|
2945
|
-
|
|
3061
|
+
fs12.writeFileSync(tmp, JSON.stringify(this.meta.get(sid)), { mode: 384 });
|
|
3062
|
+
fs12.renameSync(tmp, this.metaFile(sid));
|
|
2946
3063
|
}
|
|
2947
3064
|
/** Rebuild the queue from disk. The highest sequence number ever used is the larger of the meta file and the log itself. */
|
|
2948
3065
|
load() {
|
|
2949
3066
|
const loaded = [];
|
|
2950
3067
|
const bases = /* @__PURE__ */ new Set();
|
|
2951
|
-
for (const name of
|
|
3068
|
+
for (const name of fs12.readdirSync(this.dir)) {
|
|
2952
3069
|
if (name.endsWith(".meta.json")) bases.add(name.slice(0, -".meta.json".length));
|
|
2953
3070
|
else if (name.endsWith(".jsonl")) bases.add(name.slice(0, -".jsonl".length));
|
|
2954
3071
|
}
|
|
2955
3072
|
for (const base of bases) {
|
|
2956
3073
|
let meta = { lastSeq: 0, acked: 0 };
|
|
2957
3074
|
try {
|
|
2958
|
-
meta = JSON.parse(
|
|
3075
|
+
meta = JSON.parse(fs12.readFileSync(path14.join(this.dir, `${base}.meta.json`), "utf8"));
|
|
2959
3076
|
} catch {
|
|
2960
3077
|
}
|
|
2961
3078
|
let sid = null;
|
|
2962
|
-
const file =
|
|
3079
|
+
const file = path14.join(this.dir, `${base}.jsonl`);
|
|
2963
3080
|
let text = "";
|
|
2964
3081
|
try {
|
|
2965
|
-
text =
|
|
3082
|
+
text = fs12.readFileSync(file, "utf8");
|
|
2966
3083
|
} catch {
|
|
2967
3084
|
}
|
|
2968
3085
|
for (const line of text.split("\n")) {
|
|
@@ -2980,8 +3097,8 @@ var Outbox = class {
|
|
|
2980
3097
|
if (sid === null) {
|
|
2981
3098
|
sid = base;
|
|
2982
3099
|
try {
|
|
2983
|
-
if (this.now() -
|
|
2984
|
-
for (const f of [`${base}.meta.json`, `${base}.jsonl`])
|
|
3100
|
+
if (this.now() - fs12.statSync(path14.join(this.dir, `${base}.meta.json`)).mtimeMs > TOMBSTONE_MS) {
|
|
3101
|
+
for (const f of [`${base}.meta.json`, `${base}.jsonl`]) fs12.rmSync(path14.join(this.dir, f), { force: true });
|
|
2985
3102
|
continue;
|
|
2986
3103
|
}
|
|
2987
3104
|
} catch {
|
|
@@ -3003,7 +3120,7 @@ var Outbox = class {
|
|
|
3003
3120
|
meta.lastSeq = ev.seq;
|
|
3004
3121
|
this.meta.set(sessionId, meta);
|
|
3005
3122
|
const line = JSON.stringify(ev);
|
|
3006
|
-
|
|
3123
|
+
fs12.appendFileSync(this.eventsFile(sessionId), line + "\n", { mode: 384 });
|
|
3007
3124
|
if (ev.seq === 1) this.writeMeta(sessionId);
|
|
3008
3125
|
this.queue.push(ev);
|
|
3009
3126
|
this.bytes.set(sessionId, (this.bytes.get(sessionId) ?? 0) + line.length);
|
|
@@ -3032,8 +3149,8 @@ var Outbox = class {
|
|
|
3032
3149
|
this.writeMeta(sessionId);
|
|
3033
3150
|
const mine = this.queue.filter((e) => e.session_id === sessionId);
|
|
3034
3151
|
const tmp = this.eventsFile(sessionId) + ".tmp";
|
|
3035
|
-
|
|
3036
|
-
|
|
3152
|
+
fs12.writeFileSync(tmp, mine.map((e) => JSON.stringify(e) + "\n").join(""), { mode: 384 });
|
|
3153
|
+
fs12.renameSync(tmp, this.eventsFile(sessionId));
|
|
3037
3154
|
}
|
|
3038
3155
|
/** The oldest events not yet acknowledged, in the order they were appended. */
|
|
3039
3156
|
pending(max) {
|
|
@@ -3071,7 +3188,7 @@ var Outbox = class {
|
|
|
3071
3188
|
this.writeMeta(sid);
|
|
3072
3189
|
const left = this.queue.filter((e) => e.session_id === sid);
|
|
3073
3190
|
if (left.length === 0) {
|
|
3074
|
-
|
|
3191
|
+
fs12.writeFileSync(this.eventsFile(sid), "", { mode: 384 });
|
|
3075
3192
|
this.bytes.set(sid, 0);
|
|
3076
3193
|
if (this.closed.has(sid)) this.forget(sid);
|
|
3077
3194
|
} else if (left.length < this.queue.length / 2 || meta.acked > 1e3) {
|
|
@@ -3090,48 +3207,27 @@ var Outbox = class {
|
|
|
3090
3207
|
* repeat. What stays is the small meta file, a tombstone, removed after {@link TOMBSTONE_MS}.
|
|
3091
3208
|
*/
|
|
3092
3209
|
forget(sid) {
|
|
3093
|
-
|
|
3210
|
+
fs12.rmSync(this.eventsFile(sid), { force: true });
|
|
3094
3211
|
if (this.meta.has(sid)) this.writeMeta(sid);
|
|
3095
3212
|
this.bytes.delete(sid);
|
|
3096
3213
|
this.closed.delete(sid);
|
|
3097
3214
|
}
|
|
3098
3215
|
};
|
|
3099
3216
|
|
|
3100
|
-
// src/paths.ts
|
|
3101
|
-
import os4 from "node:os";
|
|
3102
|
-
import path13 from "node:path";
|
|
3103
|
-
function resolveDirs(env = process.env, home = os4.homedir()) {
|
|
3104
|
-
const one = env.CLAW_CONNECTOR_HOME?.trim();
|
|
3105
|
-
if (one) return { config: path13.join(one, "config"), state: path13.join(one, "state") };
|
|
3106
|
-
const cfg = env.XDG_CONFIG_HOME?.trim() || path13.join(home, ".config");
|
|
3107
|
-
const st = env.XDG_STATE_HOME?.trim() || path13.join(home, ".local", "state");
|
|
3108
|
-
return { config: path13.join(cfg, "claw-connector"), state: path13.join(st, "claw-connector") };
|
|
3109
|
-
}
|
|
3110
|
-
var configFile = (d) => path13.join(d.config, "agents.toml");
|
|
3111
|
-
var credentialsFile = (d) => path13.join(d.config, "credentials.json");
|
|
3112
|
-
var identityFile = (d) => path13.join(d.state, "connector.json");
|
|
3113
|
-
var sessionsFile = (d) => path13.join(d.state, "sessions.json");
|
|
3114
|
-
var childrenFile = (d) => path13.join(d.state, "children.json");
|
|
3115
|
-
var pidFile = (d) => path13.join(d.state, "connector.pid");
|
|
3116
|
-
var pauseFile = (d) => path13.join(d.state, "paused");
|
|
3117
|
-
var auditFile = (d) => path13.join(d.state, "audit.jsonl");
|
|
3118
|
-
var outboxDir = (d) => path13.join(d.state, "outbox");
|
|
3119
|
-
var logDir = (d) => path13.join(d.state, "logs");
|
|
3120
|
-
|
|
3121
3217
|
// src/session-store.ts
|
|
3122
|
-
import
|
|
3123
|
-
import
|
|
3218
|
+
import fs13 from "node:fs";
|
|
3219
|
+
import path15 from "node:path";
|
|
3124
3220
|
var SessionStore = class {
|
|
3125
3221
|
constructor(file) {
|
|
3126
3222
|
this.file = file;
|
|
3127
3223
|
this.pendingFile = `${file}.pending`;
|
|
3128
3224
|
try {
|
|
3129
|
-
const raw = JSON.parse(
|
|
3225
|
+
const raw = JSON.parse(fs13.readFileSync(this.pendingFile, "utf8"));
|
|
3130
3226
|
if (Array.isArray(raw)) this.pending = raw.filter((p) => p && typeof p.session_id === "string" && typeof p.action_id === "string");
|
|
3131
3227
|
} catch {
|
|
3132
3228
|
}
|
|
3133
3229
|
try {
|
|
3134
|
-
const raw = JSON.parse(
|
|
3230
|
+
const raw = JSON.parse(fs13.readFileSync(file, "utf8"));
|
|
3135
3231
|
if (raw && typeof raw === "object" && !Array.isArray(raw)) this.recs = raw;
|
|
3136
3232
|
} catch {
|
|
3137
3233
|
}
|
|
@@ -3174,19 +3270,19 @@ var SessionStore = class {
|
|
|
3174
3270
|
return out;
|
|
3175
3271
|
}
|
|
3176
3272
|
savePending() {
|
|
3177
|
-
|
|
3273
|
+
fs13.mkdirSync(path15.dirname(this.pendingFile), { recursive: true, mode: 448 });
|
|
3178
3274
|
const tmp = `${this.pendingFile}.${process.pid}.tmp`;
|
|
3179
|
-
|
|
3180
|
-
|
|
3275
|
+
fs13.writeFileSync(tmp, JSON.stringify(this.pending), { mode: 384 });
|
|
3276
|
+
fs13.renameSync(tmp, this.pendingFile);
|
|
3181
3277
|
}
|
|
3182
3278
|
ids() {
|
|
3183
3279
|
return Object.keys(this.recs);
|
|
3184
3280
|
}
|
|
3185
3281
|
save() {
|
|
3186
|
-
|
|
3282
|
+
fs13.mkdirSync(path15.dirname(this.file), { recursive: true, mode: 448 });
|
|
3187
3283
|
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
3188
|
-
|
|
3189
|
-
|
|
3284
|
+
fs13.writeFileSync(tmp, JSON.stringify(this.recs), { mode: 384 });
|
|
3285
|
+
fs13.renameSync(tmp, this.file);
|
|
3190
3286
|
}
|
|
3191
3287
|
};
|
|
3192
3288
|
|
|
@@ -3202,7 +3298,9 @@ var DEFAULT_TIMING = {
|
|
|
3202
3298
|
pausePollMs: 1e3,
|
|
3203
3299
|
catalogMs: 12e4,
|
|
3204
3300
|
attachCliMs: 8e3,
|
|
3205
|
-
recentWriteMs: 1e4
|
|
3301
|
+
recentWriteMs: 1e4,
|
|
3302
|
+
authFailLimit: 4,
|
|
3303
|
+
authFailWindowMs: 12e4
|
|
3206
3304
|
};
|
|
3207
3305
|
var CommandLedger = class {
|
|
3208
3306
|
inflight = /* @__PURE__ */ new Set();
|
|
@@ -3242,6 +3340,7 @@ var CommandLedger = class {
|
|
|
3242
3340
|
return this.highest;
|
|
3243
3341
|
}
|
|
3244
3342
|
};
|
|
3343
|
+
var isAuthRefusal = (e) => e instanceof HttpError && (e.status === 401 || e.status === 403);
|
|
3245
3344
|
function isTransient(e) {
|
|
3246
3345
|
return e instanceof TransientAuthError || e instanceof TransportError || e instanceof HttpError && (e.status >= 500 || e.status === 429);
|
|
3247
3346
|
}
|
|
@@ -3294,6 +3393,13 @@ var Connector = class {
|
|
|
3294
3393
|
/** The connector row itself was revoked (not just the sign-in): recorded in connector.json for `login`. */
|
|
3295
3394
|
rowRevoked = false;
|
|
3296
3395
|
paused = false;
|
|
3396
|
+
/** The credential was refused `authFailLimit` times in a row (see {@link ConnectorTiming.authFailLimit}). */
|
|
3397
|
+
authFailed = false;
|
|
3398
|
+
authFailures = 0;
|
|
3399
|
+
authFirstAt = 0;
|
|
3400
|
+
authWhy = "";
|
|
3401
|
+
/** The last event on the command stream, to tell why the server ended it (`superseded`, `token_expired`, a release). */
|
|
3402
|
+
lastFrame = "";
|
|
3297
3403
|
wakeSender = null;
|
|
3298
3404
|
wakeHeartbeat = null;
|
|
3299
3405
|
streamCtl = null;
|
|
@@ -3326,7 +3432,7 @@ var Connector = class {
|
|
|
3326
3432
|
// --- identity -------------------------------------------------------------------
|
|
3327
3433
|
readIdentity() {
|
|
3328
3434
|
try {
|
|
3329
|
-
const raw = JSON.parse(
|
|
3435
|
+
const raw = JSON.parse(fs14.readFileSync(identityFile(this.d.dirs), "utf8"));
|
|
3330
3436
|
if (isObject(raw) && raw.server === this.d.server && typeof raw.connector_id === "string") {
|
|
3331
3437
|
return { server: raw.server, connector_id: raw.connector_id, cursor: typeof raw.cursor === "number" ? raw.cursor : 0, handled: Array.isArray(raw.handled) ? raw.handled.filter((x) => typeof x === "string") : [] };
|
|
3332
3438
|
}
|
|
@@ -3337,9 +3443,9 @@ var Connector = class {
|
|
|
3337
3443
|
writeIdentity() {
|
|
3338
3444
|
const st = { server: this.d.server, connector_id: this.cid, cursor: this.ledger.cursor, handled: this.ledger.recentIds, ...this.rowRevoked ? { revoked: true } : {} };
|
|
3339
3445
|
const file = identityFile(this.d.dirs);
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3446
|
+
fs14.mkdirSync(this.d.dirs.state, { recursive: true, mode: 448 });
|
|
3447
|
+
fs14.writeFileSync(`${file}.tmp`, JSON.stringify(st), { mode: 384 });
|
|
3448
|
+
fs14.renameSync(`${file}.tmp`, file);
|
|
3343
3449
|
}
|
|
3344
3450
|
/** Use the connector id this machine already has, or register a new one. */
|
|
3345
3451
|
async identify(forceNew = false) {
|
|
@@ -3424,12 +3530,29 @@ var Connector = class {
|
|
|
3424
3530
|
this.streamCtl?.abort();
|
|
3425
3531
|
await this.manager.killAll("revoked");
|
|
3426
3532
|
}
|
|
3533
|
+
/** One more refusal of the credential; the run ends once `authFailLimit` come in a row. Returns whether it ended. */
|
|
3534
|
+
async authRefused(e) {
|
|
3535
|
+
if (this.authFailures++ === 0) this.authFirstAt = Date.now();
|
|
3536
|
+
if (this.authFailed) return true;
|
|
3537
|
+
if (this.authFailures < this.timing.authFailLimit || Date.now() - this.authFirstAt < this.timing.authFailWindowMs) return false;
|
|
3538
|
+
this.authFailed = true;
|
|
3539
|
+
this.authWhy = errMessage(e);
|
|
3540
|
+
this.d.log.error("Claw keeps refusing this sign-in: stopping every agent; run login again", { why: this.authWhy, refusals: this.authFailures });
|
|
3541
|
+
this.d.audit.record("auth_failed", { why: this.authWhy });
|
|
3542
|
+
this.ctl.abort();
|
|
3543
|
+
this.streamCtl?.abort();
|
|
3544
|
+
await this.manager.killAll("stopped");
|
|
3545
|
+
return true;
|
|
3546
|
+
}
|
|
3547
|
+
get authFailure() {
|
|
3548
|
+
return this.authWhy;
|
|
3549
|
+
}
|
|
3427
3550
|
isRevocation(e) {
|
|
3428
3551
|
return e instanceof RevokedError || e instanceof GrantRevokedError;
|
|
3429
3552
|
}
|
|
3430
3553
|
async pauseWatcher() {
|
|
3431
3554
|
while (!this.ctl.signal.aborted) {
|
|
3432
|
-
const want =
|
|
3555
|
+
const want = fs14.existsSync(pauseFile(this.d.dirs));
|
|
3433
3556
|
if (want && !this.paused) {
|
|
3434
3557
|
this.paused = true;
|
|
3435
3558
|
this.d.audit.record("paused");
|
|
@@ -3500,14 +3623,17 @@ var Connector = class {
|
|
|
3500
3623
|
if (this.paused) continue;
|
|
3501
3624
|
try {
|
|
3502
3625
|
await this.heartbeat();
|
|
3626
|
+
this.authFailures = 0;
|
|
3503
3627
|
this.writeIdentity();
|
|
3504
3628
|
} catch (e) {
|
|
3505
3629
|
if (this.isRevocation(e)) return void await this.onRevoked(e);
|
|
3630
|
+
if (isAuthRefusal(e) && await this.authRefused(e)) return;
|
|
3506
3631
|
this.d.log.warn("heartbeat failed", { error: errMessage(e) });
|
|
3507
3632
|
}
|
|
3508
3633
|
}
|
|
3509
3634
|
}
|
|
3510
3635
|
onFrame(f, reconnect) {
|
|
3636
|
+
if (f.event) this.lastFrame = f.event;
|
|
3511
3637
|
switch (f.event) {
|
|
3512
3638
|
case "ready":
|
|
3513
3639
|
if (reconnect) this.manager.resendPending();
|
|
@@ -3533,7 +3659,7 @@ var Connector = class {
|
|
|
3533
3659
|
this.d.audit.record("command_dropped", { reason: "malformed_frame", id: f.id });
|
|
3534
3660
|
return;
|
|
3535
3661
|
}
|
|
3536
|
-
const cmd = data;
|
|
3662
|
+
const cmd = { ...data, cmd_seq: seq };
|
|
3537
3663
|
if (this.ledger.seen(seq, cmd.cmd_id)) return;
|
|
3538
3664
|
this.ledger.begin(seq, cmd.cmd_id);
|
|
3539
3665
|
void this.manager.handle(cmd, () => {
|
|
@@ -3556,17 +3682,26 @@ var Connector = class {
|
|
|
3556
3682
|
try {
|
|
3557
3683
|
const sig = AbortSignal.any([this.ctl.signal, this.streamCtl.signal]);
|
|
3558
3684
|
let got = false;
|
|
3685
|
+
this.lastFrame = "";
|
|
3559
3686
|
for await (const f of this.d.client.commands(this.cid, { lastEventId: this.ledger.highestSeen || void 0, signal: sig, watchdogMs: this.timing.watchdogMs })) {
|
|
3560
3687
|
if (!got) {
|
|
3561
3688
|
got = true;
|
|
3562
3689
|
attempt = 0;
|
|
3690
|
+
this.authFailures = 0;
|
|
3563
3691
|
}
|
|
3564
3692
|
this.onFrame(f, reconnect);
|
|
3565
3693
|
if (f.event === "ready") reconnect = true;
|
|
3566
3694
|
}
|
|
3695
|
+
if (!this.ctl.signal.aborted && !this.paused) {
|
|
3696
|
+
const why = this.lastFrame === "superseded" || this.lastFrame === "token_expired" ? this.lastFrame : "the server closed the stream (a Claw release or restart)";
|
|
3697
|
+
this.d.log.info("command stream ended by the server; reconnecting", { why });
|
|
3698
|
+
recordDisconnect(this.d.dirs, { reason: "server_closed", detail: why });
|
|
3699
|
+
}
|
|
3567
3700
|
} catch (e) {
|
|
3568
3701
|
if (this.isRevocation(e)) return void await this.onRevoked(e);
|
|
3569
3702
|
if (this.ctl.signal.aborted) break;
|
|
3703
|
+
if (isAuthRefusal(e) && await this.authRefused(e)) return;
|
|
3704
|
+
recordDisconnect(this.d.dirs, { reason: "stream_lost", detail: errMessage(e) });
|
|
3570
3705
|
if (e instanceof HttpError && e.status === 404) {
|
|
3571
3706
|
try {
|
|
3572
3707
|
await this.identify(true);
|
|
@@ -3585,18 +3720,34 @@ var Connector = class {
|
|
|
3585
3720
|
}
|
|
3586
3721
|
/** Run until stopped or revoked. Always leaves no adapter process behind. */
|
|
3587
3722
|
async run() {
|
|
3588
|
-
|
|
3723
|
+
fs14.mkdirSync(this.d.dirs.state, { recursive: true, mode: 448 });
|
|
3589
3724
|
await this.registry.killAll();
|
|
3590
3725
|
for (const p of this.store.takePending()) {
|
|
3591
3726
|
this.d.audit.record("permission_orphaned", { session_id: p.session_id, action_id: p.action_id });
|
|
3592
3727
|
this.emit(p.session_id, "permission_resolved", { actionId: p.action_id, optionId: null, outcome: "cancelled", by: "connector_restart" });
|
|
3593
3728
|
}
|
|
3594
3729
|
const pidf = pidFile(this.d.dirs);
|
|
3595
|
-
|
|
3596
|
-
|
|
3730
|
+
let leftover = false;
|
|
3731
|
+
try {
|
|
3732
|
+
fs14.readFileSync(pidf);
|
|
3733
|
+
leftover = true;
|
|
3734
|
+
} catch {
|
|
3735
|
+
}
|
|
3736
|
+
if (leftover) {
|
|
3737
|
+
let last;
|
|
3738
|
+
try {
|
|
3739
|
+
last = fs14.statSync(identityFile(this.d.dirs)).mtime.toISOString();
|
|
3740
|
+
} catch {
|
|
3741
|
+
}
|
|
3742
|
+
recordDisconnect(this.d.dirs, { reason: "process_died", detail: `the previous run ended without shutting down (killed, crashed, or the machine restarted)${last ? `; last heartbeat ${last}` : ""}`, ...last ? { at: last } : {} });
|
|
3743
|
+
}
|
|
3744
|
+
fs14.writeFileSync(pidf, JSON.stringify({ pid: process.pid, start: processStart(process.pid) ?? "" }), { mode: 384 });
|
|
3745
|
+
this.paused = fs14.existsSync(pauseFile(this.d.dirs));
|
|
3746
|
+
let result = "stopped";
|
|
3747
|
+
let failure = "";
|
|
3597
3748
|
try {
|
|
3598
3749
|
const up = await this.startup();
|
|
3599
|
-
if (up !== "ok") return up;
|
|
3750
|
+
if (up !== "ok") return result = up;
|
|
3600
3751
|
this.d.audit.record("connector_online", {
|
|
3601
3752
|
connector_id: this.cid,
|
|
3602
3753
|
agents: this.d.config.agents.map((a) => a.id),
|
|
@@ -3604,11 +3755,17 @@ var Connector = class {
|
|
|
3604
3755
|
unattended_agents: this.d.config.agents.filter((a) => a.unattended).map((a) => a.id)
|
|
3605
3756
|
});
|
|
3606
3757
|
await Promise.all([this.sender(), this.heartbeater(), this.streamLoop(), this.pauseWatcher()]);
|
|
3758
|
+
result = this.revoked ? "revoked" : this.authFailed ? "auth_failed" : "stopped";
|
|
3759
|
+
} catch (e) {
|
|
3760
|
+
failure = errMessage(e);
|
|
3761
|
+
throw e;
|
|
3607
3762
|
} finally {
|
|
3608
3763
|
await this.manager.killAll(this.revoked ? "revoked" : "stopped").catch(() => void 0);
|
|
3609
|
-
|
|
3764
|
+
const reason = this.revoked ? "revoked" : this.authFailed ? "auth_failed" : failure ? "failed" : this.paused ? "stopped_while_paused" : result;
|
|
3765
|
+
recordDisconnect(this.d.dirs, { reason, detail: this.authWhy || failure || void 0 });
|
|
3766
|
+
fs14.rmSync(pidf, { force: true });
|
|
3610
3767
|
}
|
|
3611
|
-
return
|
|
3768
|
+
return result;
|
|
3612
3769
|
}
|
|
3613
3770
|
/** For tests: wait until every event is acknowledged. */
|
|
3614
3771
|
async drained(timeoutMs = 5e3) {
|
|
@@ -3621,11 +3778,432 @@ var Connector = class {
|
|
|
3621
3778
|
}
|
|
3622
3779
|
};
|
|
3623
3780
|
|
|
3781
|
+
// src/service.ts
|
|
3782
|
+
import { spawn as spawn2, spawnSync } from "node:child_process";
|
|
3783
|
+
import fs15 from "node:fs";
|
|
3784
|
+
import os5 from "node:os";
|
|
3785
|
+
import path16 from "node:path";
|
|
3786
|
+
import { fileURLToPath } from "node:url";
|
|
3787
|
+
import { parseArgs } from "node:util";
|
|
3788
|
+
var SUPERVISORS = ["systemd", "launchd", "script"];
|
|
3789
|
+
var UNIT = "claw-connector.service";
|
|
3790
|
+
var LABEL = "com.primusclaw.connector";
|
|
3791
|
+
var RESTART_SEC = 5;
|
|
3792
|
+
var NEEDS_PERSON = 78;
|
|
3793
|
+
var CARRIED_ENV = [
|
|
3794
|
+
"PATH",
|
|
3795
|
+
"CLAW_CONNECTOR_HOME",
|
|
3796
|
+
"XDG_CONFIG_HOME",
|
|
3797
|
+
"XDG_STATE_HOME",
|
|
3798
|
+
"CLAUDE_CONFIG_DIR",
|
|
3799
|
+
"CODEX_HOME",
|
|
3800
|
+
"NODE_EXTRA_CA_CERTS",
|
|
3801
|
+
"SSL_CERT_FILE",
|
|
3802
|
+
"HTTPS_PROXY",
|
|
3803
|
+
"https_proxy",
|
|
3804
|
+
"HTTP_PROXY",
|
|
3805
|
+
"http_proxy",
|
|
3806
|
+
"NO_PROXY",
|
|
3807
|
+
"no_proxy",
|
|
3808
|
+
"LANG"
|
|
3809
|
+
];
|
|
3810
|
+
var SECRET_NAME = /KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL/i;
|
|
3811
|
+
function deps(ctx) {
|
|
3812
|
+
const d = ctx.service ?? {};
|
|
3813
|
+
return {
|
|
3814
|
+
platform: d.platform ?? process.platform,
|
|
3815
|
+
home: d.home ?? os5.homedir(),
|
|
3816
|
+
uid: d.uid ?? (process.getuid?.() ?? 0),
|
|
3817
|
+
user: d.user ?? (ctx.env.USER || os5.userInfo().username),
|
|
3818
|
+
exec: d.exec ?? ((cmd, args) => {
|
|
3819
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
|
|
3820
|
+
return { code: r.error ? null : r.status, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
|
|
3821
|
+
}),
|
|
3822
|
+
node: d.node ?? ctx.node ?? process.execPath,
|
|
3823
|
+
cli: d.cli ?? fileURLToPath(import.meta.url),
|
|
3824
|
+
systemdBooted: d.systemdBooted ?? (() => fs15.existsSync("/run/systemd/system"))
|
|
3825
|
+
};
|
|
3826
|
+
}
|
|
3827
|
+
var systemdUnitPath = (ctx, d) => path16.join(ctx.env.XDG_CONFIG_HOME?.trim() || path16.join(d.home, ".config"), "systemd", "user", UNIT);
|
|
3828
|
+
var launchdPlistPath = (d) => path16.join(d.home, "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
3829
|
+
function detectSupervisor(d) {
|
|
3830
|
+
if (d.platform === "darwin") return "launchd";
|
|
3831
|
+
if (!d.systemdBooted()) return "script";
|
|
3832
|
+
const state = d.exec("systemctl", ["--user", "is-system-running"]).out.trim();
|
|
3833
|
+
return ["running", "degraded", "starting", "initializing"].includes(state) ? "systemd" : "script";
|
|
3834
|
+
}
|
|
3835
|
+
var hasUserinfo = (v) => {
|
|
3836
|
+
try {
|
|
3837
|
+
const u = new URL(v);
|
|
3838
|
+
return u.username !== "" || u.password !== "";
|
|
3839
|
+
} catch {
|
|
3840
|
+
return /\/\/[^/]*@/.test(v);
|
|
3841
|
+
}
|
|
3842
|
+
};
|
|
3843
|
+
function carriedEnv(env, extra = []) {
|
|
3844
|
+
const vars = [];
|
|
3845
|
+
const refused = [];
|
|
3846
|
+
const withheld = [];
|
|
3847
|
+
for (const name of /* @__PURE__ */ new Set([...CARRIED_ENV, ...extra])) {
|
|
3848
|
+
if (!CARRIED_ENV.includes(name) && SECRET_NAME.test(name)) {
|
|
3849
|
+
refused.push(name);
|
|
3850
|
+
continue;
|
|
3851
|
+
}
|
|
3852
|
+
const v = env[name];
|
|
3853
|
+
if (v === void 0 || v === "") continue;
|
|
3854
|
+
if (/proxy$/i.test(name) && hasUserinfo(v) && !extra.includes(name)) {
|
|
3855
|
+
withheld.push(name);
|
|
3856
|
+
continue;
|
|
3857
|
+
}
|
|
3858
|
+
vars.push([name, v]);
|
|
3859
|
+
}
|
|
3860
|
+
return { vars, refused, withheld };
|
|
3861
|
+
}
|
|
3862
|
+
var sdEnv = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
|
|
3863
|
+
var sdQuote = (s) => sdEnv(s).replace(/\$/g, "$$$$");
|
|
3864
|
+
var xml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
3865
|
+
var sh = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
3866
|
+
function systemdUnit(d, vars) {
|
|
3867
|
+
return `# Written by "claw-connector service install"; remove with "claw-connector service uninstall".
|
|
3868
|
+
[Unit]
|
|
3869
|
+
Description=Claw connector (claw-connector run)
|
|
3870
|
+
Wants=network-online.target
|
|
3871
|
+
After=network-online.target
|
|
3872
|
+
# Never give up: a machine that was offline for an hour must still come back by itself.
|
|
3873
|
+
StartLimitIntervalSec=0
|
|
3874
|
+
|
|
3875
|
+
[Service]
|
|
3876
|
+
Type=simple
|
|
3877
|
+
ExecStart=${sdQuote(d.node)} ${sdQuote(d.cli)} run --supervised
|
|
3878
|
+
${vars.map(([k, v]) => `Environment=${sdEnv(`${k}=${v}`)}`).join("\n")}
|
|
3879
|
+
Restart=on-failure
|
|
3880
|
+
RestartSec=${RESTART_SEC}
|
|
3881
|
+
# 78: revoked, sign-in refused, or no usable config. Restarting cannot help; "claw-connector login" can.
|
|
3882
|
+
RestartPreventExitStatus=${NEEDS_PERSON}
|
|
3883
|
+
# "claw-connector stop" and "systemctl --user stop" both end it cleanly (exit 0), which is not restarted.
|
|
3884
|
+
KillMode=mixed
|
|
3885
|
+
TimeoutStopSec=20
|
|
3886
|
+
|
|
3887
|
+
[Install]
|
|
3888
|
+
WantedBy=default.target
|
|
3889
|
+
`;
|
|
3890
|
+
}
|
|
3891
|
+
function launchdPlist(d, vars, log) {
|
|
3892
|
+
const env = [...vars, ["CLAW_CONNECTOR_SUPERVISOR", "launchd"]];
|
|
3893
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
3894
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3895
|
+
<!-- Written by "claw-connector service install"; remove with "claw-connector service uninstall". -->
|
|
3896
|
+
<plist version="1.0">
|
|
3897
|
+
<dict>
|
|
3898
|
+
<key>Label</key><string>${LABEL}</string>
|
|
3899
|
+
<key>ProgramArguments</key>
|
|
3900
|
+
<array><string>${xml(d.node)}</string><string>${xml(d.cli)}</string><string>run</string><string>--supervised</string></array>
|
|
3901
|
+
<key>EnvironmentVariables</key>
|
|
3902
|
+
<dict>
|
|
3903
|
+
${env.map(([k, v]) => ` <key>${xml(k)}</key><string>${xml(v)}</string>`).join("\n")}
|
|
3904
|
+
</dict>
|
|
3905
|
+
<key>RunAtLoad</key><true/>
|
|
3906
|
+
<!-- Restarted after any exit but a clean one. After a revocation a restarted run sees needs-login.json and exits 0. -->
|
|
3907
|
+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
3908
|
+
<key>ThrottleInterval</key><integer>${RESTART_SEC}</integer>
|
|
3909
|
+
<key>StandardOutPath</key><string>${xml(log)}</string>
|
|
3910
|
+
<key>StandardErrorPath</key><string>${xml(log)}</string>
|
|
3911
|
+
</dict>
|
|
3912
|
+
</plist>
|
|
3913
|
+
`;
|
|
3914
|
+
}
|
|
3915
|
+
function keepaliveScript(d, vars) {
|
|
3916
|
+
return `#!/bin/sh
|
|
3917
|
+
# Written by "claw-connector service install --supervisor script". Idempotent: starts the connector's supervisor
|
|
3918
|
+
# unless it is already running, and does nothing after a revocation until "claw-connector login".
|
|
3919
|
+
# Call it from the container's startup hook and every 5 minutes, for example:
|
|
3920
|
+
# @reboot sh ${"$"}HOME/.../keepalive.sh */5 * * * * sh ${"$"}HOME/.../keepalive.sh
|
|
3921
|
+
${vars.map(([k, v]) => `export ${k}=${sh(v)}`).join("\n")}
|
|
3922
|
+
exec ${sh(d.node)} ${sh(d.cli)} service ensure
|
|
3923
|
+
`;
|
|
3924
|
+
}
|
|
3925
|
+
function readPidRec(file) {
|
|
3926
|
+
try {
|
|
3927
|
+
const raw = JSON.parse(fs15.readFileSync(file, "utf8"));
|
|
3928
|
+
if (isObject(raw) && typeof raw.pid === "number" && typeof raw.start === "string" && raw.start !== "") return { pid: raw.pid, start: raw.start };
|
|
3929
|
+
} catch {
|
|
3930
|
+
}
|
|
3931
|
+
return null;
|
|
3932
|
+
}
|
|
3933
|
+
function supervisorPid(ctx) {
|
|
3934
|
+
const r = readPidRec(supervisorPidFile(ctx.dirs));
|
|
3935
|
+
return r && processStart(r.pid) === r.start ? r.pid : null;
|
|
3936
|
+
}
|
|
3937
|
+
async function supervise(ctx, o = {}) {
|
|
3938
|
+
const d = deps(ctx);
|
|
3939
|
+
const [cmd, args] = o.command ?? [d.node, [d.cli, "run", "--supervised"]];
|
|
3940
|
+
const restartMs = o.restartMs ?? RESTART_SEC * 1e3;
|
|
3941
|
+
const pidf = supervisorPidFile(ctx.dirs);
|
|
3942
|
+
const other = supervisorPid(ctx);
|
|
3943
|
+
if (other !== null && other !== process.pid) {
|
|
3944
|
+
ctx.err(`a supervisor is already running (pid ${other})`);
|
|
3945
|
+
return 1;
|
|
3946
|
+
}
|
|
3947
|
+
fs15.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
3948
|
+
fs15.writeFileSync(pidf, JSON.stringify({ pid: process.pid, start: processStart(process.pid) ?? "" }), { mode: 384 });
|
|
3949
|
+
let stopping2 = false;
|
|
3950
|
+
let child = null;
|
|
3951
|
+
const wake = new AbortController();
|
|
3952
|
+
const onSig = (sig) => {
|
|
3953
|
+
stopping2 = true;
|
|
3954
|
+
wake.abort();
|
|
3955
|
+
child?.kill(sig);
|
|
3956
|
+
};
|
|
3957
|
+
const sigs = ["SIGTERM", "SIGINT", "SIGHUP"];
|
|
3958
|
+
const handlers = sigs.map((s) => [s, () => onSig(s)]);
|
|
3959
|
+
for (const [s, h] of handlers) process.on(s, h);
|
|
3960
|
+
o.signal?.addEventListener("abort", () => onSig("SIGTERM"), { once: true });
|
|
3961
|
+
try {
|
|
3962
|
+
for (let round = 0; !stopping2; round++) {
|
|
3963
|
+
child = spawn2(cmd, args, { stdio: ["ignore", "inherit", "inherit"], env: ctx.env });
|
|
3964
|
+
const c = child;
|
|
3965
|
+
const { code, signal } = await new Promise((resolve) => {
|
|
3966
|
+
c.once("exit", (code2, signal2) => resolve({ code: code2, signal: signal2 }));
|
|
3967
|
+
c.once("error", (e) => {
|
|
3968
|
+
ctx.err(`supervisor: cannot start the connector: ${errMessage(e)}`);
|
|
3969
|
+
resolve({ code: 1, signal: null });
|
|
3970
|
+
});
|
|
3971
|
+
});
|
|
3972
|
+
child = null;
|
|
3973
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
3974
|
+
if (stopping2) return 0;
|
|
3975
|
+
if (code === 0) {
|
|
3976
|
+
ctx.err(`${at} supervisor: the connector stopped on purpose (exit 0); not restarting`);
|
|
3977
|
+
return 0;
|
|
3978
|
+
}
|
|
3979
|
+
if (code === NEEDS_PERSON) {
|
|
3980
|
+
ctx.err(`${at} supervisor: the connector needs a new sign-in (exit 78); not restarting until: claw-connector login`);
|
|
3981
|
+
return NEEDS_PERSON;
|
|
3982
|
+
}
|
|
3983
|
+
const how = signal ? `killed by ${signal}` : `exit ${code}`;
|
|
3984
|
+
if (signal) recordDisconnect(ctx.dirs, { reason: "process_died", detail: `the connector was ${how}; restarted by the supervisor` });
|
|
3985
|
+
ctx.err(`${at} supervisor: the connector ended (${how}); restarting in ${restartMs / 1e3} s`);
|
|
3986
|
+
await sleep(restartMs, wake.signal);
|
|
3987
|
+
}
|
|
3988
|
+
return 0;
|
|
3989
|
+
} finally {
|
|
3990
|
+
for (const [s, h] of handlers) process.off(s, h);
|
|
3991
|
+
const rec = readPidRec(pidf);
|
|
3992
|
+
if (rec?.pid === process.pid) fs15.rmSync(pidf, { force: true });
|
|
3993
|
+
}
|
|
3994
|
+
}
|
|
3995
|
+
async function cmdSupervise(_args, ctx) {
|
|
3996
|
+
return supervise(ctx);
|
|
3997
|
+
}
|
|
3998
|
+
async function ensure(ctx, d, h) {
|
|
3999
|
+
const sup = supervisorPid(ctx);
|
|
4000
|
+
if (sup !== null) {
|
|
4001
|
+
ctx.out(`supervisor running (pid ${sup})`);
|
|
4002
|
+
return 0;
|
|
4003
|
+
}
|
|
4004
|
+
const problem = h.preflight(ctx);
|
|
4005
|
+
if (problem) {
|
|
4006
|
+
ctx.err(`not starting: ${problem}`);
|
|
4007
|
+
return NEEDS_PERSON;
|
|
4008
|
+
}
|
|
4009
|
+
const run2 = h.runningPid(ctx);
|
|
4010
|
+
if (run2 !== null) {
|
|
4011
|
+
ctx.out(`the connector is running (pid ${run2}) without the supervisor; leaving it`);
|
|
4012
|
+
return 0;
|
|
4013
|
+
}
|
|
4014
|
+
fs15.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
4015
|
+
const log = fs15.openSync(runLogFile(ctx.dirs), "a", 384);
|
|
4016
|
+
try {
|
|
4017
|
+
const p = spawn2(d.node, [d.cli, "supervise"], { detached: true, cwd: d.home, stdio: ["ignore", log, log], env: ctx.env });
|
|
4018
|
+
p.unref();
|
|
4019
|
+
ctx.out(`started the supervisor (pid ${p.pid}); log: ${runLogFile(ctx.dirs)}`);
|
|
4020
|
+
} finally {
|
|
4021
|
+
fs15.closeSync(log);
|
|
4022
|
+
}
|
|
4023
|
+
return 0;
|
|
4024
|
+
}
|
|
4025
|
+
async function cmdService(args, ctx, h) {
|
|
4026
|
+
const [sub, ...rest] = args;
|
|
4027
|
+
const usage = "usage: claw-connector service install|uninstall|status [--supervisor systemd|launchd|script] [--env NAME ...]";
|
|
4028
|
+
if (!["install", "uninstall", "status", "ensure"].includes(sub ?? "")) {
|
|
4029
|
+
ctx.err(usage);
|
|
4030
|
+
return 2;
|
|
4031
|
+
}
|
|
4032
|
+
const { values } = parseArgs({ args: rest, strict: true, options: { supervisor: { type: "string" }, env: { type: "string", multiple: true }, json: { type: "boolean" } } });
|
|
4033
|
+
const d = deps(ctx);
|
|
4034
|
+
if (sub === "ensure") return ensure(ctx, d, h);
|
|
4035
|
+
let sup;
|
|
4036
|
+
if (values.supervisor !== void 0) {
|
|
4037
|
+
if (!SUPERVISORS.includes(values.supervisor)) {
|
|
4038
|
+
ctx.err(usage);
|
|
4039
|
+
return 2;
|
|
4040
|
+
}
|
|
4041
|
+
sup = values.supervisor;
|
|
4042
|
+
} else {
|
|
4043
|
+
sup = sub === "install" ? detectSupervisor(d) : installedSupervisor(ctx) ?? detectSupervisor(d);
|
|
4044
|
+
}
|
|
4045
|
+
if (sub === "install") return install(ctx, d, h, sup, values.env ?? []);
|
|
4046
|
+
if (sub === "uninstall") return uninstall(ctx, d, sup);
|
|
4047
|
+
return status(ctx, d, h, values.json === true);
|
|
4048
|
+
}
|
|
4049
|
+
function installedSupervisor(ctx) {
|
|
4050
|
+
try {
|
|
4051
|
+
const raw = JSON.parse(fs15.readFileSync(serviceFile(ctx.dirs), "utf8"));
|
|
4052
|
+
if (isObject(raw) && typeof raw.supervisor === "string" && SUPERVISORS.includes(raw.supervisor)) return raw.supervisor;
|
|
4053
|
+
} catch {
|
|
4054
|
+
}
|
|
4055
|
+
return null;
|
|
4056
|
+
}
|
|
4057
|
+
function startAfterLogin(ctx, h) {
|
|
4058
|
+
const sup = installedSupervisor(ctx);
|
|
4059
|
+
if (!sup) return;
|
|
4060
|
+
const d = deps(ctx);
|
|
4061
|
+
if (h.runningPid(ctx) !== null) return;
|
|
4062
|
+
let r;
|
|
4063
|
+
if (sup === "systemd") r = d.exec("systemctl", ["--user", "restart", UNIT]);
|
|
4064
|
+
else if (sup === "launchd") r = d.exec("launchctl", ["kickstart", "-k", `gui/${d.uid}/${LABEL}`]);
|
|
4065
|
+
else {
|
|
4066
|
+
void ensure(ctx, d, h);
|
|
4067
|
+
return;
|
|
4068
|
+
}
|
|
4069
|
+
ctx.out(r.code === 0 ? `started the ${sup} service again` : `could not start the ${sup} service (${r.out || `exit ${r.code}`}); run: claw-connector service install`);
|
|
4070
|
+
}
|
|
4071
|
+
function writeFile600(file, body, mode = 384) {
|
|
4072
|
+
fs15.mkdirSync(path16.dirname(file), { recursive: true, mode: 448 });
|
|
4073
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
4074
|
+
fs15.writeFileSync(tmp, body, { mode });
|
|
4075
|
+
fs15.renameSync(tmp, file);
|
|
4076
|
+
}
|
|
4077
|
+
async function install(ctx, d, h, sup, extra) {
|
|
4078
|
+
const problem = h.preflight(ctx);
|
|
4079
|
+
if (problem) {
|
|
4080
|
+
ctx.err(`cannot install the service yet: ${problem}`);
|
|
4081
|
+
return NEEDS_PERSON;
|
|
4082
|
+
}
|
|
4083
|
+
if (!fs15.existsSync(d.cli)) {
|
|
4084
|
+
ctx.err(`${d.cli} does not exist (a source checkout needs "npm run build" first)`);
|
|
4085
|
+
return 1;
|
|
4086
|
+
}
|
|
4087
|
+
const { vars, refused, withheld } = carriedEnv(ctx.env, extra);
|
|
4088
|
+
for (const r of refused) ctx.err(`--env ${r}: not carried into the service (it looks like a credential)`);
|
|
4089
|
+
for (const w of withheld) ctx.err(`${w}: not carried into the service because it holds a password; if the connector needs it, install again with --env ${w} (it is then written into the 0600 unit file)`);
|
|
4090
|
+
const manual = h.runningPid(ctx);
|
|
4091
|
+
if (manual !== null && supervisorPid(ctx) === null) {
|
|
4092
|
+
await h.stop(ctx);
|
|
4093
|
+
ctx.out(`stopped the connector that was running by hand (pid ${manual}) so the service takes over`);
|
|
4094
|
+
}
|
|
4095
|
+
ctx.out(`carrying into the service: ${vars.map(([k]) => k).join(", ") || "nothing"}`);
|
|
4096
|
+
if (sup === "systemd") {
|
|
4097
|
+
const unit = systemdUnitPath(ctx, d);
|
|
4098
|
+
writeFile600(unit, systemdUnit(d, vars));
|
|
4099
|
+
recordInstalled(ctx, "systemd");
|
|
4100
|
+
if (!d.systemdBooted()) {
|
|
4101
|
+
ctx.err("systemd is not this machine's init (no /run/systemd/system): use --supervisor script");
|
|
4102
|
+
return 1;
|
|
4103
|
+
}
|
|
4104
|
+
for (const a of [["--user", "daemon-reload"], ["--user", "enable", UNIT], ["--user", "restart", UNIT]]) {
|
|
4105
|
+
const r = d.exec("systemctl", a);
|
|
4106
|
+
if (r.code !== 0) {
|
|
4107
|
+
ctx.err(`systemctl ${a.join(" ")} failed: ${r.out || `exit ${r.code}`}`);
|
|
4108
|
+
return 1;
|
|
4109
|
+
}
|
|
4110
|
+
}
|
|
4111
|
+
const st = d.exec("systemctl", ["--user", "is-active", UNIT]).out.trim();
|
|
4112
|
+
if (st !== "active" && st !== "activating") {
|
|
4113
|
+
ctx.err(`the unit did not start (is-active: ${st || "nothing"}); see: journalctl --user -u ${UNIT}`);
|
|
4114
|
+
return 1;
|
|
4115
|
+
}
|
|
4116
|
+
ctx.out(`installed ${unit} and started it (systemctl --user status ${UNIT}; logs: journalctl --user -u ${UNIT})`);
|
|
4117
|
+
const linger = d.exec("loginctl", ["show-user", d.user, "-p", "Linger", "--value"]);
|
|
4118
|
+
if (linger.out.trim() !== "yes") ctx.out(`to keep it running when you are not logged in and start it at boot, run once: loginctl enable-linger ${d.user}`);
|
|
4119
|
+
} else if (sup === "launchd") {
|
|
4120
|
+
const plist = launchdPlistPath(d);
|
|
4121
|
+
writeFile600(plist, launchdPlist(d, vars, runLogFile(ctx.dirs)));
|
|
4122
|
+
recordInstalled(ctx, "launchd");
|
|
4123
|
+
d.exec("launchctl", ["bootout", `gui/${d.uid}/${LABEL}`]);
|
|
4124
|
+
const r = d.exec("launchctl", ["bootstrap", `gui/${d.uid}`, plist]);
|
|
4125
|
+
if (r.code !== 0) {
|
|
4126
|
+
ctx.err(`launchctl bootstrap failed: ${r.out || `exit ${r.code}`}`);
|
|
4127
|
+
return 1;
|
|
4128
|
+
}
|
|
4129
|
+
ctx.out(`installed ${plist} and started it (launchctl print gui/${d.uid}/${LABEL}; log: ${runLogFile(ctx.dirs)})`);
|
|
4130
|
+
} else {
|
|
4131
|
+
const script = keepaliveFile(ctx.dirs);
|
|
4132
|
+
writeFile600(script, keepaliveScript(d, vars), 448);
|
|
4133
|
+
recordInstalled(ctx, "script");
|
|
4134
|
+
const code = await ensure(ctx, d, h);
|
|
4135
|
+
if (code !== 0) return code;
|
|
4136
|
+
ctx.out(`wrote ${script}. This machine has no systemd: the supervisor restarts the connector 5 s after it dies, but`);
|
|
4137
|
+
ctx.out("nothing starts the supervisor after a reboot or a container restart. Add both of these (crontab -e, or your container's startup hook):");
|
|
4138
|
+
ctx.out(` @reboot sh ${script}`);
|
|
4139
|
+
ctx.out(` */5 * * * * sh ${script}`);
|
|
4140
|
+
}
|
|
4141
|
+
return 0;
|
|
4142
|
+
}
|
|
4143
|
+
function recordInstalled(ctx, sup) {
|
|
4144
|
+
writeFile600(serviceFile(ctx.dirs), JSON.stringify({ supervisor: sup, installed_at: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
4145
|
+
}
|
|
4146
|
+
async function uninstall(ctx, d, sup) {
|
|
4147
|
+
fs15.rmSync(serviceFile(ctx.dirs), { force: true });
|
|
4148
|
+
if (sup === "systemd") {
|
|
4149
|
+
const unit = systemdUnitPath(ctx, d);
|
|
4150
|
+
d.exec("systemctl", ["--user", "disable", "--now", UNIT]);
|
|
4151
|
+
const had = fs15.existsSync(unit);
|
|
4152
|
+
fs15.rmSync(unit, { force: true });
|
|
4153
|
+
d.exec("systemctl", ["--user", "daemon-reload"]);
|
|
4154
|
+
ctx.out(had ? `stopped and removed ${unit}` : `no unit at ${unit}`);
|
|
4155
|
+
} else if (sup === "launchd") {
|
|
4156
|
+
const plist = launchdPlistPath(d);
|
|
4157
|
+
d.exec("launchctl", ["bootout", `gui/${d.uid}/${LABEL}`]);
|
|
4158
|
+
const had = fs15.existsSync(plist);
|
|
4159
|
+
fs15.rmSync(plist, { force: true });
|
|
4160
|
+
ctx.out(had ? `stopped and removed ${plist}` : `no LaunchAgent at ${plist}`);
|
|
4161
|
+
} else {
|
|
4162
|
+
const pid = supervisorPid(ctx);
|
|
4163
|
+
if (pid !== null) {
|
|
4164
|
+
process.kill(pid, "SIGTERM");
|
|
4165
|
+
const end = Date.now() + 2e4;
|
|
4166
|
+
while (Date.now() < end && isAlive(pid) && processStart(pid) !== null) await sleep(100);
|
|
4167
|
+
}
|
|
4168
|
+
const script = keepaliveFile(ctx.dirs);
|
|
4169
|
+
const had = fs15.existsSync(script);
|
|
4170
|
+
fs15.rmSync(script, { force: true });
|
|
4171
|
+
ctx.out(`${pid !== null ? `stopped the supervisor (pid ${pid}) and the connector; ` : ""}${had ? `removed ${script}` : "no keep-alive script"}. Remove the @reboot and */5 lines that call it.`);
|
|
4172
|
+
}
|
|
4173
|
+
return 0;
|
|
4174
|
+
}
|
|
4175
|
+
function status(ctx, d, h, json) {
|
|
4176
|
+
const sup = installedSupervisor(ctx);
|
|
4177
|
+
let active = null;
|
|
4178
|
+
if (sup === "systemd") active = d.exec("systemctl", ["--user", "is-active", UNIT]).out.trim() || null;
|
|
4179
|
+
else if (sup === "launchd") active = d.exec("launchctl", ["print", `gui/${d.uid}/${LABEL}`]).code === 0 ? "loaded" : "not loaded";
|
|
4180
|
+
else if (sup === "script") {
|
|
4181
|
+
const p = supervisorPid(ctx);
|
|
4182
|
+
active = p !== null ? `supervisor pid ${p}` : "supervisor not running";
|
|
4183
|
+
}
|
|
4184
|
+
const st = {
|
|
4185
|
+
installed: sup,
|
|
4186
|
+
active,
|
|
4187
|
+
running: h.runningPid(ctx),
|
|
4188
|
+
needs_login: needsLogin(ctx.dirs),
|
|
4189
|
+
recent_disconnects: recentDisconnects(ctx.dirs, 5)
|
|
4190
|
+
};
|
|
4191
|
+
if (json) {
|
|
4192
|
+
ctx.out(JSON.stringify(st, null, 2));
|
|
4193
|
+
return 0;
|
|
4194
|
+
}
|
|
4195
|
+
ctx.out(`service: ${sup ? `${sup} (${active})` : "not installed; run: claw-connector service install"}`);
|
|
4196
|
+
ctx.out(`connector: ${st.running !== null ? `running (pid ${st.running})` : "not running"}`);
|
|
4197
|
+
if (st.needs_login) ctx.out(`problem: needs a new sign-in: ${st.needs_login.reason}; run: claw-connector login`);
|
|
4198
|
+
for (const x of st.recent_disconnects) ctx.out(`down: ${x.at} ${x.reason}${x.detail ? ` (${x.detail})` : ""}`);
|
|
4199
|
+
return 0;
|
|
4200
|
+
}
|
|
4201
|
+
|
|
3624
4202
|
// src/version.ts
|
|
3625
|
-
var VERSION = "0.1.
|
|
4203
|
+
var VERSION = "0.1.4";
|
|
3626
4204
|
|
|
3627
4205
|
// src/commands.ts
|
|
3628
|
-
var EXIT = { ok: 0, failure: 1, usage: 2, revoked:
|
|
4206
|
+
var EXIT = { ok: 0, failure: 1, usage: 2, needsPerson: 78, revoked: 78 };
|
|
3629
4207
|
var HELP = `claw-connector ${VERSION}: connect the coding agents on this machine to Claw (outbound HTTPS only).
|
|
3630
4208
|
|
|
3631
4209
|
Usage: claw-connector <command> [options]
|
|
@@ -3641,19 +4219,24 @@ Usage: claw-connector <command> [options]
|
|
|
3641
4219
|
directories, else $CLAUDE_CONFIG_DIR or ~/.claude); only its own entries are ever changed
|
|
3642
4220
|
login sign in (device flow); --server URL --identity-url URL [--name HOST]
|
|
3643
4221
|
development only: --key <api key> (or CLAW_CONNECTOR_KEY)
|
|
3644
|
-
run connect and serve (Ctrl-C or SIGTERM stops it and every agent it started)
|
|
3645
|
-
|
|
4222
|
+
run connect and serve (Ctrl-C or SIGTERM stops it and every agent it started). Exit code 78:
|
|
4223
|
+
revoked, sign-in refused, or no usable config; starting it again will not help
|
|
4224
|
+
service install|uninstall|status [--supervisor systemd|launchd|script] [--env NAME ...]
|
|
4225
|
+
keep "run" up: started at login/boot, restarted 5 s after it dies, never restarted
|
|
4226
|
+
after exit 78. systemd --user on Linux, launchd on macOS; "script" (containers without
|
|
4227
|
+
systemd) writes a keep-alive script for a startup hook and a 5-minute cron line
|
|
4228
|
+
status [--json] what is running, signed in, paused, the last disconnect and why
|
|
3646
4229
|
pause stop every agent now and stay offline until "resume"
|
|
3647
4230
|
resume undo "pause"
|
|
3648
4231
|
stop stop the running connector and every agent it started
|
|
3649
|
-
logout stop, revoke the sign-in and delete
|
|
4232
|
+
logout stop, revoke this connector at Claw and the sign-in, and delete the sign-in
|
|
3650
4233
|
--version, --help
|
|
3651
4234
|
|
|
3652
4235
|
Files: ${"$"}XDG_CONFIG_HOME/claw-connector (agents.toml, credentials.json) and ${"$"}XDG_STATE_HOME/claw-connector
|
|
3653
4236
|
(audit.jsonl, logs, outbox); set CLAW_CONNECTOR_HOME to keep everything under one directory.
|
|
3654
4237
|
`;
|
|
3655
4238
|
function loadCfg(ctx) {
|
|
3656
|
-
return loadConfig(configFile(ctx.dirs), { hostname: ctx.hostname ??
|
|
4239
|
+
return loadConfig(configFile(ctx.dirs), { hostname: ctx.hostname ?? os6.hostname() });
|
|
3657
4240
|
}
|
|
3658
4241
|
var noAsk = (ctx, cfg) => noAskWarnings(cfg.agents, ctx.env, { managedFile: ctx.claudeManagedSettings, euid: ctx.euid });
|
|
3659
4242
|
function serverUrl(ctx, flag, cfg, creds) {
|
|
@@ -3681,8 +4264,14 @@ async function main(argv, ctx) {
|
|
|
3681
4264
|
return cmdHooks(rest, ctx);
|
|
3682
4265
|
case "login":
|
|
3683
4266
|
return await cmdLogin(rest, ctx);
|
|
3684
|
-
case "run":
|
|
3685
|
-
|
|
4267
|
+
case "run": {
|
|
4268
|
+
const code = await cmdRun(rest, ctx);
|
|
4269
|
+
return code === EXIT.needsPerson && ctx.env.CLAW_CONNECTOR_SUPERVISOR === "launchd" ? EXIT.ok : code;
|
|
4270
|
+
}
|
|
4271
|
+
case "service":
|
|
4272
|
+
return await cmdService(rest, ctx, serviceHelpers);
|
|
4273
|
+
case "supervise":
|
|
4274
|
+
return await cmdSupervise(rest, ctx);
|
|
3686
4275
|
case "status":
|
|
3687
4276
|
return cmdStatus(rest, ctx);
|
|
3688
4277
|
case "pause":
|
|
@@ -3724,16 +4313,16 @@ function parseApproval(values) {
|
|
|
3724
4313
|
return out;
|
|
3725
4314
|
}
|
|
3726
4315
|
async function cmdInit(args, ctx) {
|
|
3727
|
-
const { values } =
|
|
4316
|
+
const { values } = parseArgs2({ args, strict: true, options: { approval: { type: "string", multiple: true }, "no-hooks": { type: "boolean" } } });
|
|
3728
4317
|
const approval = parseApproval(values.approval ?? []);
|
|
3729
4318
|
if (typeof approval === "string") {
|
|
3730
4319
|
ctx.err(approval);
|
|
3731
4320
|
return EXIT.usage;
|
|
3732
4321
|
}
|
|
3733
4322
|
const file = configFile(ctx.dirs);
|
|
3734
|
-
|
|
4323
|
+
fs16.mkdirSync(path17.dirname(file), { recursive: true, mode: 448 });
|
|
3735
4324
|
try {
|
|
3736
|
-
|
|
4325
|
+
fs16.writeFileSync(file, sampleConfig(approval), { mode: 384, flag: "wx" });
|
|
3737
4326
|
ctx.out(`wrote ${file}: edit the roots (the directories an agent may work in), then run: claw-connector login`);
|
|
3738
4327
|
const { unattended, parked } = sampleApprovals(approval);
|
|
3739
4328
|
ctx.out(unattended.length ? `no per-step approval for: ${unattended.join(", ")}. Those agents write files and run commands without asking; the phone shows such a session as unattended. To go back, set approval = "ask" on the agent.` : `every agent in this file asks before it writes a file or runs a command. To let phone sessions run like your terminal instead: claw-connector init --approval never (on a new file), or set approval = "never" on an agent.`);
|
|
@@ -3744,7 +4333,7 @@ async function cmdInit(args, ctx) {
|
|
|
3744
4333
|
}
|
|
3745
4334
|
if (!values["no-hooks"]) {
|
|
3746
4335
|
const dir = defaultClaudeDir(ctx);
|
|
3747
|
-
if (!
|
|
4336
|
+
if (!fs16.existsSync(dir)) ctx.out(`no Claude Code directory at ${dir}; once you use Claude Code, run: claw-connector hooks install`);
|
|
3748
4337
|
else installForInit(ctx, dir);
|
|
3749
4338
|
}
|
|
3750
4339
|
return EXIT.ok;
|
|
@@ -3756,19 +4345,19 @@ function installForInit(ctx, dir) {
|
|
|
3756
4345
|
ctx.err(`hooks: ${errMessage(e)} (everything else is set up; fix it and run: claw-connector hooks install)`);
|
|
3757
4346
|
}
|
|
3758
4347
|
}
|
|
3759
|
-
var defaultClaudeDir = (ctx) =>
|
|
4348
|
+
var defaultClaudeDir = (ctx) => path17.resolve(ctx.env.CLAUDE_CONFIG_DIR?.trim() || path17.join(os6.homedir(), ".claude"));
|
|
3760
4349
|
function claudeDirs(ctx, flag) {
|
|
3761
|
-
if (flag !== void 0) return [
|
|
4350
|
+
if (flag !== void 0) return [path17.resolve(flag)];
|
|
3762
4351
|
let dirs = [];
|
|
3763
4352
|
try {
|
|
3764
|
-
dirs = loadCfg(ctx).agents.filter((a) => a.attach).map((a) =>
|
|
4353
|
+
dirs = loadCfg(ctx).agents.filter((a) => a.attach).map((a) => path17.resolve(a.env.CLAUDE_CONFIG_DIR ?? ctx.env.CLAUDE_CONFIG_DIR?.trim() ?? path17.join(os6.homedir(), ".claude")));
|
|
3765
4354
|
} catch {
|
|
3766
4355
|
}
|
|
3767
4356
|
return dirs.length ? [...new Set(dirs)] : [defaultClaudeDir(ctx)];
|
|
3768
4357
|
}
|
|
3769
4358
|
function hookScript(ctx) {
|
|
3770
|
-
const script = ctx.hookScript ??
|
|
3771
|
-
if (!
|
|
4359
|
+
const script = ctx.hookScript ?? fileURLToPath2(new URL("./terminal-hook.js", import.meta.url));
|
|
4360
|
+
if (!fs16.existsSync(script)) throw new HooksError(`${script} does not exist (a source checkout needs "npm run build" first)`);
|
|
3772
4361
|
return script;
|
|
3773
4362
|
}
|
|
3774
4363
|
function installOne(ctx, dir) {
|
|
@@ -3781,14 +4370,14 @@ function cmdHooks(args, ctx) {
|
|
|
3781
4370
|
ctx.err("usage: claw-connector hooks install|uninstall|status [--config-dir DIR]");
|
|
3782
4371
|
return EXIT.usage;
|
|
3783
4372
|
}
|
|
3784
|
-
const { values } =
|
|
4373
|
+
const { values } = parseArgs2({ args: rest, strict: true, options: { "config-dir": { type: "string" }, json: { type: "boolean" } } });
|
|
3785
4374
|
const dirs = claudeDirs(ctx, values["config-dir"]);
|
|
3786
4375
|
let code = EXIT.ok;
|
|
3787
4376
|
const report = [];
|
|
3788
4377
|
for (const dir of dirs) {
|
|
3789
4378
|
try {
|
|
3790
4379
|
if (sub === "install") {
|
|
3791
|
-
if (values["config-dir"] === void 0 && !
|
|
4380
|
+
if (values["config-dir"] === void 0 && !fs16.existsSync(dir)) {
|
|
3792
4381
|
ctx.err(`no Claude Code directory at ${dir}; pass --config-dir to name one`);
|
|
3793
4382
|
code = EXIT.failure;
|
|
3794
4383
|
continue;
|
|
@@ -3813,14 +4402,14 @@ function forgetRevokedIdentity(ctx) {
|
|
|
3813
4402
|
const file = identityFile(ctx.dirs);
|
|
3814
4403
|
let revoked = false;
|
|
3815
4404
|
try {
|
|
3816
|
-
const raw = JSON.parse(
|
|
4405
|
+
const raw = JSON.parse(fs16.readFileSync(file, "utf8"));
|
|
3817
4406
|
revoked = isObject(raw) && raw.revoked === true;
|
|
3818
4407
|
} catch {
|
|
3819
4408
|
}
|
|
3820
|
-
if (revoked)
|
|
4409
|
+
if (revoked) fs16.rmSync(file, { force: true });
|
|
3821
4410
|
}
|
|
3822
4411
|
async function cmdLogin(args, ctx) {
|
|
3823
|
-
const { values } =
|
|
4412
|
+
const { values } = parseArgs2({
|
|
3824
4413
|
args,
|
|
3825
4414
|
strict: true,
|
|
3826
4415
|
options: { server: { type: "string" }, "identity-url": { type: "string" }, key: { type: "string" }, name: { type: "string" } }
|
|
@@ -3841,7 +4430,9 @@ async function cmdLogin(args, ctx) {
|
|
|
3841
4430
|
if (key) {
|
|
3842
4431
|
saveCredentials(file, { v: 1, server, kind: "key", key, saved_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
3843
4432
|
forgetRevokedIdentity(ctx);
|
|
4433
|
+
clearNeedsLogin(ctx.dirs);
|
|
3844
4434
|
ctx.out("saved a development key. This is for trying things out: a device sign-in is the normal way (omit --key).");
|
|
4435
|
+
startAfterLogin(ctx, serviceHelpers);
|
|
3845
4436
|
return EXIT.ok;
|
|
3846
4437
|
}
|
|
3847
4438
|
const identityRaw = (values["identity-url"] ?? ctx.env.CLAW_IDENTITY_URL ?? cfg?.identityUrl ?? "").trim();
|
|
@@ -3850,7 +4441,7 @@ async function cmdLogin(args, ctx) {
|
|
|
3850
4441
|
ctx.err("login needs --identity-url <identity service URL> (or CLAW_IDENTITY_URL, or identity_url = in agents.toml)");
|
|
3851
4442
|
return EXIT.usage;
|
|
3852
4443
|
}
|
|
3853
|
-
const deviceName = values.name ?? cfg?.name ?? ctx.hostname ??
|
|
4444
|
+
const deviceName = values.name ?? cfg?.name ?? ctx.hostname ?? os6.hostname();
|
|
3854
4445
|
const tok = await deviceLogin({ identityUrl, deviceName, fetch: ctx.fetch, print: ctx.out });
|
|
3855
4446
|
saveCredentials(file, {
|
|
3856
4447
|
v: 1,
|
|
@@ -3863,27 +4454,47 @@ async function cmdLogin(args, ctx) {
|
|
|
3863
4454
|
saved_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3864
4455
|
});
|
|
3865
4456
|
forgetRevokedIdentity(ctx);
|
|
3866
|
-
ctx.
|
|
4457
|
+
clearNeedsLogin(ctx.dirs);
|
|
4458
|
+
ctx.out(`signed in as ${deviceName}. Next: claw-connector service install (or run)`);
|
|
4459
|
+
startAfterLogin(ctx, serviceHelpers);
|
|
3867
4460
|
return EXIT.ok;
|
|
3868
4461
|
}
|
|
3869
4462
|
function runningPid(ctx) {
|
|
3870
4463
|
try {
|
|
3871
|
-
const raw = JSON.parse(
|
|
4464
|
+
const raw = JSON.parse(fs16.readFileSync(pidFile(ctx.dirs), "utf8"));
|
|
3872
4465
|
if (isObject(raw) && typeof raw.pid === "number" && typeof raw.start === "string" && raw.start !== "" && processStart(raw.pid) === raw.start) return raw.pid;
|
|
3873
4466
|
} catch {
|
|
3874
4467
|
}
|
|
3875
4468
|
return null;
|
|
3876
4469
|
}
|
|
3877
|
-
async function cmdRun(
|
|
3878
|
-
|
|
4470
|
+
async function cmdRun(args, ctx) {
|
|
4471
|
+
parseArgs2({ args, strict: true, options: { supervised: { type: "boolean" } } });
|
|
4472
|
+
{
|
|
4473
|
+
const nl = needsLogin(ctx.dirs);
|
|
4474
|
+
if (nl) {
|
|
4475
|
+
ctx.err(`not starting: ${nl.reason}${nl.at ? ` (${nl.at})` : ""}. Sign in again (claw-connector login); the service then starts it.`);
|
|
4476
|
+
return EXIT.needsPerson;
|
|
4477
|
+
}
|
|
4478
|
+
}
|
|
4479
|
+
let cfg;
|
|
4480
|
+
let creds;
|
|
4481
|
+
try {
|
|
4482
|
+
cfg = loadCfg(ctx);
|
|
4483
|
+
creds = loadCredentials(credentialsFile(ctx.dirs));
|
|
4484
|
+
} catch (e) {
|
|
4485
|
+
if (e instanceof ConfigError || e instanceof CredentialsError) {
|
|
4486
|
+
ctx.err(e.message);
|
|
4487
|
+
return EXIT.needsPerson;
|
|
4488
|
+
}
|
|
4489
|
+
throw e;
|
|
4490
|
+
}
|
|
3879
4491
|
if (cfg.agents.length === 0) {
|
|
3880
4492
|
ctx.err("agents.toml defines no agents; add an [[agents]] table");
|
|
3881
|
-
return EXIT.
|
|
4493
|
+
return EXIT.needsPerson;
|
|
3882
4494
|
}
|
|
3883
|
-
const creds = loadCredentials(credentialsFile(ctx.dirs));
|
|
3884
4495
|
if (!creds) {
|
|
3885
4496
|
ctx.err("not signed in: run claw-connector login");
|
|
3886
|
-
return EXIT.
|
|
4497
|
+
return EXIT.needsPerson;
|
|
3887
4498
|
}
|
|
3888
4499
|
const running = runningPid(ctx);
|
|
3889
4500
|
if (running !== null) {
|
|
@@ -3902,12 +4513,23 @@ async function cmdRun(_args, ctx) {
|
|
|
3902
4513
|
try {
|
|
3903
4514
|
const result = await connector2.run();
|
|
3904
4515
|
if (result === "revoked") {
|
|
4516
|
+
markNeedsLogin(ctx.dirs, "this connector was revoked");
|
|
3905
4517
|
ctx.err("this connector was revoked: every agent was stopped. Run login again to connect it anew.");
|
|
3906
4518
|
return EXIT.revoked;
|
|
3907
4519
|
}
|
|
4520
|
+
if (result === "auth_failed") {
|
|
4521
|
+
markNeedsLogin(ctx.dirs, `Claw refused the sign-in (${connector2.authFailure})`);
|
|
4522
|
+
ctx.err(`Claw keeps refusing this sign-in (${connector2.authFailure}): every agent was stopped. Run login again.`);
|
|
4523
|
+
return EXIT.needsPerson;
|
|
4524
|
+
}
|
|
3908
4525
|
return EXIT.ok;
|
|
3909
4526
|
} catch (e) {
|
|
3910
|
-
if (e
|
|
4527
|
+
if (isAuthRefusal(e) || e instanceof RevokedError || e instanceof GrantRevokedError || e instanceof CredentialsError) {
|
|
4528
|
+
markNeedsLogin(ctx.dirs, e instanceof GrantRevokedError || e instanceof RevokedError ? e.message : `Claw refused the sign-in (${errMessage(e)})`);
|
|
4529
|
+
ctx.err(`error: ${errMessage(e)}. Run login again.`);
|
|
4530
|
+
return EXIT.needsPerson;
|
|
4531
|
+
}
|
|
4532
|
+
if (e instanceof HttpError || e instanceof TransportError) {
|
|
3911
4533
|
ctx.err(`error: ${e.message}`);
|
|
3912
4534
|
return EXIT.failure;
|
|
3913
4535
|
}
|
|
@@ -3915,7 +4537,7 @@ async function cmdRun(_args, ctx) {
|
|
|
3915
4537
|
}
|
|
3916
4538
|
}
|
|
3917
4539
|
function cmdStatus(args, ctx) {
|
|
3918
|
-
const { values } =
|
|
4540
|
+
const { values } = parseArgs2({ args, strict: true, options: { json: { type: "boolean" } } });
|
|
3919
4541
|
let creds = null;
|
|
3920
4542
|
let credsProblem = null;
|
|
3921
4543
|
try {
|
|
@@ -3937,15 +4559,15 @@ function cmdStatus(args, ctx) {
|
|
|
3937
4559
|
}
|
|
3938
4560
|
let connectorId = null;
|
|
3939
4561
|
try {
|
|
3940
|
-
const raw = JSON.parse(
|
|
4562
|
+
const raw = JSON.parse(fs16.readFileSync(identityFile(ctx.dirs), "utf8"));
|
|
3941
4563
|
if (isObject(raw) && typeof raw.connector_id === "string") connectorId = raw.connector_id;
|
|
3942
4564
|
} catch {
|
|
3943
4565
|
}
|
|
3944
4566
|
const reg = new ChildRegistry(childrenFile(ctx.dirs));
|
|
3945
|
-
const
|
|
4567
|
+
const status2 = {
|
|
3946
4568
|
version: VERSION,
|
|
3947
4569
|
running: runningPid(ctx),
|
|
3948
|
-
paused:
|
|
4570
|
+
paused: fs16.existsSync(pauseFile(ctx.dirs)),
|
|
3949
4571
|
signed_in: creds ? { kind: creds.kind, server: creds.server, device_name: creds.device_name ?? null } : null,
|
|
3950
4572
|
connector_id: connectorId,
|
|
3951
4573
|
agents,
|
|
@@ -3954,23 +4576,28 @@ function cmdStatus(args, ctx) {
|
|
|
3954
4576
|
adapters: reg.list().filter((r) => reg.isOurs(r)).map((r) => ({ pid: r.pid, agent: r.agent_id, session: r.session_id })),
|
|
3955
4577
|
problems: [credsProblem, configProblem].filter((x) => !!x),
|
|
3956
4578
|
/** Agents whose sessions will be refused as things stand (Claude's own settings start them in a mode that never asks). */
|
|
3957
|
-
warnings
|
|
4579
|
+
warnings,
|
|
4580
|
+
/** Why the connection last went down, and whether only a new sign-in can bring it back. */
|
|
4581
|
+
last_disconnect: recentDisconnects(ctx.dirs, 1)[0] ?? null,
|
|
4582
|
+
needs_login: needsLogin(ctx.dirs)
|
|
3958
4583
|
};
|
|
3959
4584
|
if (values.json) {
|
|
3960
|
-
ctx.out(JSON.stringify(
|
|
4585
|
+
ctx.out(JSON.stringify(status2, null, 2));
|
|
3961
4586
|
return EXIT.ok;
|
|
3962
4587
|
}
|
|
3963
|
-
ctx.out(`connector: ${
|
|
4588
|
+
ctx.out(`connector: ${status2.running ? `running (pid ${status2.running})` : "not running"}${status2.paused ? ", PAUSED" : ""}`);
|
|
3964
4589
|
ctx.out(`signed in: ${creds ? `${creds.kind} to ${creds.server}` : "no"}`);
|
|
3965
4590
|
ctx.out(`agents: ${agents.map((a) => unattended.includes(a) ? `${a} (no per-step approval)` : a).join(", ") || "none configured"}`);
|
|
3966
|
-
ctx.out(`adapters: ${
|
|
3967
|
-
|
|
3968
|
-
|
|
4591
|
+
ctx.out(`adapters: ${status2.adapters.length ? status2.adapters.map((a) => `${a.agent} pid ${a.pid}`).join(", ") : "none running"}`);
|
|
4592
|
+
if (status2.last_disconnect) ctx.out(`last down: ${status2.last_disconnect.at} ${status2.last_disconnect.reason}${status2.last_disconnect.detail ? ` (${status2.last_disconnect.detail})` : ""}`);
|
|
4593
|
+
if (status2.needs_login) ctx.out(`problem: needs a new sign-in: ${status2.needs_login.reason}; run: claw-connector login`);
|
|
4594
|
+
for (const p of status2.problems) ctx.out(`problem: ${p}`);
|
|
4595
|
+
for (const w of status2.warnings) ctx.out(`warning: ${w}`);
|
|
3969
4596
|
return EXIT.ok;
|
|
3970
4597
|
}
|
|
3971
4598
|
async function cmdPause(ctx) {
|
|
3972
|
-
|
|
3973
|
-
|
|
4599
|
+
fs16.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
4600
|
+
fs16.writeFileSync(pauseFile(ctx.dirs), (/* @__PURE__ */ new Date()).toISOString() + "\n", { mode: 384 });
|
|
3974
4601
|
if (runningPid(ctx) === null) {
|
|
3975
4602
|
const left = await new ChildRegistry(childrenFile(ctx.dirs)).killAll();
|
|
3976
4603
|
ctx.out(`paused (the connector is not running${left.killed.length ? `; stopped ${left.killed.length} leftover agent process group(s)` : ""})`);
|
|
@@ -3980,7 +4607,7 @@ async function cmdPause(ctx) {
|
|
|
3980
4607
|
return EXIT.ok;
|
|
3981
4608
|
}
|
|
3982
4609
|
function cmdResume(ctx) {
|
|
3983
|
-
|
|
4610
|
+
fs16.rmSync(pauseFile(ctx.dirs), { force: true });
|
|
3984
4611
|
ctx.out(runningPid(ctx) === null ? "resumed; start it with: claw-connector run" : "resumed: the running connector reconnects now");
|
|
3985
4612
|
return EXIT.ok;
|
|
3986
4613
|
}
|
|
@@ -3999,32 +4626,108 @@ async function cmdStop(ctx, quiet = false) {
|
|
|
3999
4626
|
if (!quiet) ctx.out(pid === null ? `no connector running${left.killed.length ? `; stopped ${left.killed.length} leftover agent process group(s)` : ""}` : `stopped the connector${left.killed.length ? ` and ${left.killed.length} leftover agent process group(s)` : ""}`);
|
|
4000
4627
|
return EXIT.ok;
|
|
4001
4628
|
}
|
|
4629
|
+
function storedConnector(ctx) {
|
|
4630
|
+
try {
|
|
4631
|
+
const raw = JSON.parse(fs16.readFileSync(identityFile(ctx.dirs), "utf8"));
|
|
4632
|
+
if (isObject(raw) && typeof raw.server === "string" && typeof raw.connector_id === "string") {
|
|
4633
|
+
return { server: raw.server, connector_id: raw.connector_id, revoked: raw.revoked === true };
|
|
4634
|
+
}
|
|
4635
|
+
} catch {
|
|
4636
|
+
}
|
|
4637
|
+
return null;
|
|
4638
|
+
}
|
|
4639
|
+
var revokeByHand = (server, cid) => `curl -fsS -X DELETE -H "Authorization: Bearer <your Claw API key>" ${server}/v1/remote-agents/connectors/${encodeURIComponent(cid)}`;
|
|
4002
4640
|
async function cmdLogout(ctx) {
|
|
4003
4641
|
await cmdStop(ctx, true);
|
|
4004
4642
|
const file = credentialsFile(ctx.dirs);
|
|
4005
4643
|
let creds = null;
|
|
4644
|
+
let credsProblem = null;
|
|
4006
4645
|
try {
|
|
4007
4646
|
creds = loadCredentials(file);
|
|
4008
|
-
} catch {
|
|
4647
|
+
} catch (e) {
|
|
4648
|
+
credsProblem = errMessage(e);
|
|
4649
|
+
}
|
|
4650
|
+
const row = storedConnector(ctx);
|
|
4651
|
+
let latest = creds;
|
|
4652
|
+
let notRevoked = null;
|
|
4653
|
+
let rowGone = false;
|
|
4654
|
+
if (row && !row.revoked) {
|
|
4655
|
+
if (!creds) {
|
|
4656
|
+
notRevoked = credsProblem ? `the sign-in on this machine is unreadable (${credsProblem})` : "this machine is not signed in";
|
|
4657
|
+
} else {
|
|
4658
|
+
let server = null;
|
|
4659
|
+
try {
|
|
4660
|
+
let cfg = null;
|
|
4661
|
+
try {
|
|
4662
|
+
cfg = loadCfg(ctx);
|
|
4663
|
+
} catch (e) {
|
|
4664
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
4665
|
+
}
|
|
4666
|
+
server = serverUrl(ctx, void 0, cfg, creds);
|
|
4667
|
+
} catch {
|
|
4668
|
+
}
|
|
4669
|
+
if (server !== row.server) {
|
|
4670
|
+
notRevoked = `it was registered at ${row.server}, but this sign-in is for ${server ?? "no usable server"}, so nothing was sent to either`;
|
|
4671
|
+
} else try {
|
|
4672
|
+
const tokens = providerFor(creds, (c) => {
|
|
4673
|
+
latest = c;
|
|
4674
|
+
saveCredentials(file, c);
|
|
4675
|
+
}, { fetch: ctx.fetch });
|
|
4676
|
+
const client2 = new ClawClient({ server: row.server, tokens, fetch: ctx.fetch, requestTimeoutMs: 15e3 });
|
|
4677
|
+
await client2.revoke(row.connector_id, creds.kind);
|
|
4678
|
+
rowGone = true;
|
|
4679
|
+
ctx.out(`revoked connector ${row.connector_id} at ${row.server}: Claw sends it nothing more`);
|
|
4680
|
+
} catch (e) {
|
|
4681
|
+
if (e instanceof RevokedError || e instanceof HttpError && e.status === 404 && e.code === "not_found") {
|
|
4682
|
+
rowGone = true;
|
|
4683
|
+
ctx.out(`Claw has no live connector ${row.connector_id} for this sign-in (already revoked, or registered by another account)`);
|
|
4684
|
+
} else {
|
|
4685
|
+
notRevoked = errMessage(e);
|
|
4686
|
+
}
|
|
4687
|
+
}
|
|
4688
|
+
}
|
|
4009
4689
|
}
|
|
4010
|
-
if (
|
|
4690
|
+
if (latest?.kind === "device" && latest.identity_url && latest.refresh_token) {
|
|
4011
4691
|
try {
|
|
4012
4692
|
const f = ctx.fetch ?? fetch;
|
|
4013
|
-
await f(`${
|
|
4693
|
+
await f(`${latest.identity_url}/oauth/revoke`, {
|
|
4014
4694
|
method: "POST",
|
|
4015
4695
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
4016
|
-
body: new URLSearchParams({ token:
|
|
4696
|
+
body: new URLSearchParams({ token: latest.refresh_token, token_type_hint: "refresh_token", client_id: latest.client_id ?? DEFAULT_CLIENT_ID }),
|
|
4017
4697
|
signal: AbortSignal.timeout(1e4)
|
|
4018
4698
|
});
|
|
4019
4699
|
} catch (e) {
|
|
4020
4700
|
ctx.err(`could not revoke the sign-in at the identity service (${errMessage(e)}); it was removed from this machine`);
|
|
4021
4701
|
}
|
|
4022
4702
|
}
|
|
4023
|
-
|
|
4024
|
-
|
|
4703
|
+
fs16.rmSync(file, { force: true });
|
|
4704
|
+
if (rowGone) fs16.rmSync(identityFile(ctx.dirs), { force: true });
|
|
4705
|
+
else forgetRevokedIdentity(ctx);
|
|
4706
|
+
if (row && notRevoked !== null) {
|
|
4707
|
+
ctx.err(`signed out on this machine, but connector ${row.connector_id} was NOT revoked at Claw (${notRevoked}).`);
|
|
4708
|
+
ctx.err("It is still your connector there, and instructions sent to it wait for the next sign-in on this machine. Revoke it with:");
|
|
4709
|
+
ctx.err(` ${revokeByHand(row.server, row.connector_id)}`);
|
|
4710
|
+
ctx.err("or sign in again and run logout once Claw is reachable.");
|
|
4711
|
+
return EXIT.failure;
|
|
4712
|
+
}
|
|
4025
4713
|
ctx.out("signed out");
|
|
4026
4714
|
return EXIT.ok;
|
|
4027
4715
|
}
|
|
4716
|
+
var serviceHelpers = {
|
|
4717
|
+
runningPid,
|
|
4718
|
+
stop: (ctx) => cmdStop(ctx, true),
|
|
4719
|
+
preflight: (ctx) => {
|
|
4720
|
+
try {
|
|
4721
|
+
const cfg = loadCfg(ctx);
|
|
4722
|
+
if (cfg.agents.length === 0) return "agents.toml defines no agents; add an [[agents]] table";
|
|
4723
|
+
if (!loadCredentials(credentialsFile(ctx.dirs))) return "not signed in: run claw-connector login";
|
|
4724
|
+
} catch (e) {
|
|
4725
|
+
return errMessage(e);
|
|
4726
|
+
}
|
|
4727
|
+
const nl = needsLogin(ctx.dirs);
|
|
4728
|
+
return nl ? `${nl.reason}: run claw-connector login first` : null;
|
|
4729
|
+
}
|
|
4730
|
+
};
|
|
4028
4731
|
|
|
4029
4732
|
// src/cli.ts
|
|
4030
4733
|
var connector = null;
|