@primusclaw/connector 0.1.5 → 0.1.6
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 +106 -25
- package/dist/cli.js +1684 -356
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
5
|
+
|
|
3
6
|
// src/commands.ts
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
+
import fs19 from "node:fs";
|
|
8
|
+
import os9 from "node:os";
|
|
9
|
+
import path21 from "node:path";
|
|
7
10
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
8
11
|
import { parseArgs as parseArgs2 } from "node:util";
|
|
9
12
|
|
|
@@ -563,11 +566,11 @@ var RevokedError = class extends Error {
|
|
|
563
566
|
}
|
|
564
567
|
};
|
|
565
568
|
var HttpError = class extends Error {
|
|
566
|
-
constructor(status2, code,
|
|
567
|
-
super(`${
|
|
569
|
+
constructor(status2, code, path22) {
|
|
570
|
+
super(`${path22} answered ${status2} ${code}`);
|
|
568
571
|
this.status = status2;
|
|
569
572
|
this.code = code;
|
|
570
|
-
this.path =
|
|
573
|
+
this.path = path22;
|
|
571
574
|
}
|
|
572
575
|
status;
|
|
573
576
|
code;
|
|
@@ -591,8 +594,8 @@ var ClawClient = class {
|
|
|
591
594
|
...extra
|
|
592
595
|
};
|
|
593
596
|
}
|
|
594
|
-
async send(method,
|
|
595
|
-
const url = this.o.server +
|
|
597
|
+
async send(method, path22, body, signal) {
|
|
598
|
+
const url = this.o.server + path22;
|
|
596
599
|
for (let attempt = 0; ; attempt++) {
|
|
597
600
|
const headers = await this.headers(body === void 0 ? {} : { "content-type": "application/json" });
|
|
598
601
|
let res;
|
|
@@ -604,7 +607,7 @@ var ClawClient = class {
|
|
|
604
607
|
signal: signal ?? AbortSignal.timeout(this.o.requestTimeoutMs ?? 2e4)
|
|
605
608
|
});
|
|
606
609
|
} catch (e) {
|
|
607
|
-
throw new TransportError(`${method} ${
|
|
610
|
+
throw new TransportError(`${method} ${path22}: ${errMessage(e)}`);
|
|
608
611
|
}
|
|
609
612
|
if (res.status !== 401) return res;
|
|
610
613
|
const err = await errorCode(res);
|
|
@@ -613,12 +616,12 @@ var ClawClient = class {
|
|
|
613
616
|
this.o.tokens.invalidate();
|
|
614
617
|
continue;
|
|
615
618
|
}
|
|
616
|
-
throw new HttpError(401, err,
|
|
619
|
+
throw new HttpError(401, err, path22);
|
|
617
620
|
}
|
|
618
621
|
}
|
|
619
|
-
async json(method,
|
|
620
|
-
const res = await this.send(method,
|
|
621
|
-
if (!res.ok) throw new HttpError(res.status, await errorCode(res),
|
|
622
|
+
async json(method, path22, body) {
|
|
623
|
+
const res = await this.send(method, path22, body);
|
|
624
|
+
if (!res.ok) throw new HttpError(res.status, await errorCode(res), path22);
|
|
622
625
|
const parsed = await res.json().catch(() => null);
|
|
623
626
|
return isObject(parsed) && isObject(parsed.data) ? parsed.data : {};
|
|
624
627
|
}
|
|
@@ -667,21 +670,21 @@ var ClawClient = class {
|
|
|
667
670
|
};
|
|
668
671
|
try {
|
|
669
672
|
arm();
|
|
670
|
-
const
|
|
673
|
+
const path22 = `/v1/remote-agents/connectors/${cid}/commands`;
|
|
671
674
|
const headers = await this.headers({ accept: "text/event-stream", ...opts.lastEventId ? { "last-event-id": String(opts.lastEventId) } : {} });
|
|
672
675
|
let res;
|
|
673
676
|
try {
|
|
674
|
-
res = await this.f(this.o.server +
|
|
677
|
+
res = await this.f(this.o.server + path22, { headers, signal: stop.signal });
|
|
675
678
|
} catch (e) {
|
|
676
|
-
throw new TransportError(timedOut ? `no response for ${opts.watchdogMs} ms` : `GET ${
|
|
679
|
+
throw new TransportError(timedOut ? `no response for ${opts.watchdogMs} ms` : `GET ${path22}: ${errMessage(e)}`);
|
|
677
680
|
}
|
|
678
681
|
if (res.status === 401) {
|
|
679
682
|
const err = await errorCode(res);
|
|
680
683
|
if (err === "revoked") throw new RevokedError();
|
|
681
684
|
this.o.tokens.invalidate();
|
|
682
|
-
throw new HttpError(401, err,
|
|
685
|
+
throw new HttpError(401, err, path22);
|
|
683
686
|
}
|
|
684
|
-
if (!res.ok || !res.body) throw new HttpError(res.status, await errorCode(res),
|
|
687
|
+
if (!res.ok || !res.body) throw new HttpError(res.status, await errorCode(res), path22);
|
|
685
688
|
try {
|
|
686
689
|
for await (const frame of readSse(res.body, arm)) yield frame;
|
|
687
690
|
} catch (e) {
|
|
@@ -701,59 +704,10 @@ async function errorCode(res) {
|
|
|
701
704
|
}
|
|
702
705
|
|
|
703
706
|
// src/config.ts
|
|
704
|
-
import fs5 from "node:fs";
|
|
705
|
-
import os from "node:os";
|
|
706
|
-
import path5 from "node:path";
|
|
707
|
-
import { parse as parseToml } from "smol-toml";
|
|
708
|
-
|
|
709
|
-
// src/roots.ts
|
|
710
707
|
import fs4 from "node:fs";
|
|
708
|
+
import os from "node:os";
|
|
711
709
|
import path4 from "node:path";
|
|
712
|
-
|
|
713
|
-
try {
|
|
714
|
-
return fs4.realpathSync.native(root);
|
|
715
|
-
} catch {
|
|
716
|
-
return null;
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
var inside = (child, root) => child === root || child.startsWith(root.endsWith(path4.sep) ? root : root + path4.sep);
|
|
720
|
-
function checkCwd(cwd, roots) {
|
|
721
|
-
if (typeof cwd !== "string" || cwd === "" || cwd.includes("\0")) return { ok: false, reason: "invalid_cwd" };
|
|
722
|
-
if (!path4.isAbsolute(cwd)) return { ok: false, reason: "cwd_not_absolute" };
|
|
723
|
-
let real;
|
|
724
|
-
try {
|
|
725
|
-
real = fs4.realpathSync.native(cwd);
|
|
726
|
-
} catch {
|
|
727
|
-
return { ok: false, reason: "cwd_not_found" };
|
|
728
|
-
}
|
|
729
|
-
try {
|
|
730
|
-
if (!fs4.statSync(real).isDirectory()) return { ok: false, reason: "cwd_not_directory" };
|
|
731
|
-
} catch {
|
|
732
|
-
return { ok: false, reason: "cwd_not_found" };
|
|
733
|
-
}
|
|
734
|
-
for (const root of roots) {
|
|
735
|
-
const r = realRoot(root);
|
|
736
|
-
if (r && inside(real, r)) return { ok: true, real };
|
|
737
|
-
}
|
|
738
|
-
return { ok: false, reason: "cwd_outside_roots" };
|
|
739
|
-
}
|
|
740
|
-
function relativeInside(p, cwd) {
|
|
741
|
-
const abs = path4.resolve(cwd, p);
|
|
742
|
-
let real;
|
|
743
|
-
try {
|
|
744
|
-
real = fs4.realpathSync.native(abs);
|
|
745
|
-
} catch {
|
|
746
|
-
try {
|
|
747
|
-
real = path4.join(fs4.realpathSync.native(path4.dirname(abs)), path4.basename(abs));
|
|
748
|
-
} catch {
|
|
749
|
-
return null;
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
if (!inside(real, cwd)) return null;
|
|
753
|
-
return path4.relative(cwd, real).split(path4.sep).join("/");
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
// src/config.ts
|
|
710
|
+
import { parse as parseToml } from "smol-toml";
|
|
757
711
|
var ConfigError = class extends Error {
|
|
758
712
|
constructor(problems) {
|
|
759
713
|
super(`agents.toml: ${problems.join("; ")}`);
|
|
@@ -769,7 +723,8 @@ var PRESETS = {
|
|
|
769
723
|
args: ["-y", "@agentclientprotocol/claude-agent-acp@0.99.0"],
|
|
770
724
|
askMode: "default",
|
|
771
725
|
safeModes: ["default", "plan"],
|
|
772
|
-
unattended: { askMode: "bypassPermissions" }
|
|
726
|
+
unattended: { askMode: "bypassPermissions" },
|
|
727
|
+
readOnly: ["plan"]
|
|
773
728
|
},
|
|
774
729
|
// Existing Claude Code sessions, taken over from the phone. The kind is the opaque label a chat client picks an icon
|
|
775
730
|
// from; it says what the entry is for, not which product serves it. Same adapter and same ask mode as `claude`.
|
|
@@ -781,7 +736,8 @@ var PRESETS = {
|
|
|
781
736
|
askMode: "default",
|
|
782
737
|
safeModes: ["default", "plan"],
|
|
783
738
|
attach: true,
|
|
784
|
-
unattended: { askMode: "bypassPermissions" }
|
|
739
|
+
unattended: { askMode: "bypassPermissions" },
|
|
740
|
+
readOnly: ["plan"]
|
|
785
741
|
},
|
|
786
742
|
codex: {
|
|
787
743
|
kind: "codex",
|
|
@@ -799,7 +755,8 @@ var PRESETS = {
|
|
|
799
755
|
args: ["acp"],
|
|
800
756
|
askMode: "ask",
|
|
801
757
|
safeModes: ["ask", "plan"],
|
|
802
|
-
unattended: { askMode: "agent", args: ["--force", "acp"] }
|
|
758
|
+
unattended: { askMode: "agent", args: ["--force", "acp"] },
|
|
759
|
+
readOnly: ["ask", "plan"]
|
|
803
760
|
}
|
|
804
761
|
};
|
|
805
762
|
var ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
@@ -813,7 +770,7 @@ var strList = (v, what, problems) => {
|
|
|
813
770
|
}
|
|
814
771
|
return v;
|
|
815
772
|
};
|
|
816
|
-
var expandHome = (p) => p === "~" ? os.homedir() : p.startsWith("~/") ?
|
|
773
|
+
var expandHome = (p) => p === "~" ? os.homedir() : p.startsWith("~/") ? path4.join(os.homedir(), p.slice(2)) : p;
|
|
817
774
|
function parseConfig(text, defaults = {}) {
|
|
818
775
|
const problems = [];
|
|
819
776
|
let doc;
|
|
@@ -880,11 +837,10 @@ function parseConfig(text, defaults = {}) {
|
|
|
880
837
|
} else asking.add(askMode);
|
|
881
838
|
}
|
|
882
839
|
if (asking.has(askMode) && !safeModes.includes(askMode)) safeModes.push(askMode);
|
|
883
|
-
const roots = strList(raw.roots, `${label}.roots`, problems).map((r) =>
|
|
840
|
+
const roots = strList(raw.roots, `${label}.roots`, problems).map((r) => path4.resolve(expandHome(r)));
|
|
884
841
|
if (roots.length === 0) problems.push(`${label}: roots is required (an agent with no allowed directory is never offered)`);
|
|
885
842
|
for (const r of roots) {
|
|
886
|
-
if (r ===
|
|
887
|
-
else if (!realRoot(r)) problems.push(`${label}: root ${r} does not exist`);
|
|
843
|
+
if (r === path4.parse(r).root) problems.push(`${label}: a root may not be the filesystem root`);
|
|
888
844
|
}
|
|
889
845
|
const env = {};
|
|
890
846
|
if (raw.env !== void 0) {
|
|
@@ -920,6 +876,8 @@ function parseConfig(text, defaults = {}) {
|
|
|
920
876
|
askingModes: [...asking],
|
|
921
877
|
allowUnsafeModes: allowUnsafe,
|
|
922
878
|
unattended: !asking.has(askMode),
|
|
879
|
+
// What the preset measured; a custom agent declares nothing here.
|
|
880
|
+
readOnlyModes: [...preset?.readOnly ?? []],
|
|
923
881
|
autoAllow: [...globalAllow, ...own],
|
|
924
882
|
attach
|
|
925
883
|
});
|
|
@@ -931,7 +889,7 @@ function parseConfig(text, defaults = {}) {
|
|
|
931
889
|
var EXISTING_SESSIONS_SUFFIX = "-sessions";
|
|
932
890
|
function existingSessionAgents(rawAgents, agents, problems) {
|
|
933
891
|
const out = [];
|
|
934
|
-
const configDir = (a) =>
|
|
892
|
+
const configDir = (a) => path4.resolve(expandHome(a.env.CLAUDE_CONFIG_DIR ?? process.env.CLAUDE_CONFIG_DIR ?? path4.join(os.homedir(), ".claude")));
|
|
935
893
|
const ids = new Set(agents.map((a) => a.id));
|
|
936
894
|
for (const raw of rawAgents) {
|
|
937
895
|
if (!isObject(raw) || typeof raw.id !== "string") continue;
|
|
@@ -979,7 +937,7 @@ function parseAutoAllow(raw, at, problems) {
|
|
|
979
937
|
if (!isObject(r) || typeof r.kind !== "string" || typeof r.path_glob !== "string" || !r.kind || !r.path_glob) {
|
|
980
938
|
return void problems.push(`${at}[${i}] needs a kind and a path_glob`);
|
|
981
939
|
}
|
|
982
|
-
if (
|
|
940
|
+
if (path4.isAbsolute(r.path_glob) || r.path_glob.split("/").includes("..")) {
|
|
983
941
|
return void problems.push(`${at}[${i}].path_glob must be relative to the working directory and may not contain ..`);
|
|
984
942
|
}
|
|
985
943
|
rules.push({ kind: r.kind, pathGlob: r.path_glob });
|
|
@@ -989,7 +947,7 @@ function parseAutoAllow(raw, at, problems) {
|
|
|
989
947
|
function loadConfig(file, defaults = {}) {
|
|
990
948
|
let text;
|
|
991
949
|
try {
|
|
992
|
-
text =
|
|
950
|
+
text = fs4.readFileSync(file, "utf8");
|
|
993
951
|
} catch (e) {
|
|
994
952
|
if (e.code === "ENOENT") throw new ConfigError([`${file} does not exist; run "claw-connector init" to create one`]);
|
|
995
953
|
throw e;
|
|
@@ -1003,7 +961,9 @@ function sampleApprovals(approval) {
|
|
|
1003
961
|
return { unattended: never.filter((id) => SAMPLE_ACTIVE.includes(id)), parked: never.filter((id) => !SAMPLE_ACTIVE.includes(id)) };
|
|
1004
962
|
}
|
|
1005
963
|
var SAMPLE_NOTES = {
|
|
1006
|
-
"claude-attach": "# Take over a Claude Code conversation that already exists on this machine (exit it in the terminal first)."
|
|
964
|
+
"claude-attach": "# Take over a Claude Code conversation that already exists on this machine (exit it in the terminal first).",
|
|
965
|
+
cursor: `# Cursor asks before it runs a command but cannot ask before it edits a file, so with approval "ask" its sessions are
|
|
966
|
+
# read-only (ask and plan modes): they answer questions and change nothing. To let it write code, set approval = "never".`
|
|
1007
967
|
};
|
|
1008
968
|
var NEVER_NOTE = `# No per-step approval: this agent runs like it does in your terminal. A phone session writes files and runs
|
|
1009
969
|
# commands without asking, and the phone shows it as unattended. To go back to asking, set it to "ask".`;
|
|
@@ -1036,8 +996,8 @@ ${SAMPLE_AGENTS.map(agent).join("\n\n")}
|
|
|
1036
996
|
var SAMPLE_CONFIG = sampleConfig();
|
|
1037
997
|
|
|
1038
998
|
// src/credentials.ts
|
|
1039
|
-
import
|
|
1040
|
-
import
|
|
999
|
+
import fs5 from "node:fs";
|
|
1000
|
+
import path5 from "node:path";
|
|
1041
1001
|
var DEFAULT_CLIENT_ID = "claw-connector";
|
|
1042
1002
|
var CONNECTOR_SCOPE = "claw.connector";
|
|
1043
1003
|
var CredentialsError = class extends Error {
|
|
@@ -1052,20 +1012,20 @@ var TransientAuthError = class extends Error {
|
|
|
1052
1012
|
function loadCredentials(file) {
|
|
1053
1013
|
let fd;
|
|
1054
1014
|
try {
|
|
1055
|
-
fd =
|
|
1015
|
+
fd = fs5.openSync(file, "r");
|
|
1056
1016
|
} catch (e) {
|
|
1057
1017
|
if (e.code === "ENOENT") return null;
|
|
1058
1018
|
throw e;
|
|
1059
1019
|
}
|
|
1060
1020
|
let text;
|
|
1061
1021
|
try {
|
|
1062
|
-
const st =
|
|
1022
|
+
const st = fs5.fstatSync(fd);
|
|
1063
1023
|
if (process.platform !== "win32" && (st.mode & 63) !== 0) {
|
|
1064
1024
|
throw new CredentialsError(`${file} is readable by other users (mode ${(st.mode & 511).toString(8)}); run: chmod 600 ${file}`);
|
|
1065
1025
|
}
|
|
1066
|
-
text =
|
|
1026
|
+
text = fs5.readFileSync(fd, "utf8");
|
|
1067
1027
|
} finally {
|
|
1068
|
-
|
|
1028
|
+
fs5.closeSync(fd);
|
|
1069
1029
|
}
|
|
1070
1030
|
let raw;
|
|
1071
1031
|
try {
|
|
@@ -1079,11 +1039,11 @@ function loadCredentials(file) {
|
|
|
1079
1039
|
return raw;
|
|
1080
1040
|
}
|
|
1081
1041
|
function saveCredentials(file, creds) {
|
|
1082
|
-
|
|
1042
|
+
fs5.mkdirSync(path5.dirname(file), { recursive: true, mode: 448 });
|
|
1083
1043
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1044
|
+
fs5.writeFileSync(tmp, JSON.stringify(creds, null, 2) + "\n", { mode: 384 });
|
|
1045
|
+
fs5.chmodSync(tmp, 384);
|
|
1046
|
+
fs5.renameSync(tmp, file);
|
|
1087
1047
|
}
|
|
1088
1048
|
var KeyTokenProvider = class {
|
|
1089
1049
|
constructor(key) {
|
|
@@ -1273,9 +1233,9 @@ async function deviceLogin(o) {
|
|
|
1273
1233
|
}
|
|
1274
1234
|
|
|
1275
1235
|
// src/claude-mode.ts
|
|
1276
|
-
import
|
|
1236
|
+
import fs6 from "node:fs";
|
|
1277
1237
|
import os2 from "node:os";
|
|
1278
|
-
import
|
|
1238
|
+
import path6 from "node:path";
|
|
1279
1239
|
function managedSettingsFile(platform = process.platform) {
|
|
1280
1240
|
if (platform === "darwin") return "/Library/Application Support/ClaudeCode/managed-settings.json";
|
|
1281
1241
|
if (platform === "win32") return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
|
|
@@ -1284,7 +1244,7 @@ function managedSettingsFile(platform = process.platform) {
|
|
|
1284
1244
|
function readSettings(file, unreadable) {
|
|
1285
1245
|
let text;
|
|
1286
1246
|
try {
|
|
1287
|
-
text =
|
|
1247
|
+
text = fs6.readFileSync(file, "utf8");
|
|
1288
1248
|
} catch (e) {
|
|
1289
1249
|
if (e.code !== "ENOENT") unreadable.push(file);
|
|
1290
1250
|
return null;
|
|
@@ -1320,9 +1280,9 @@ function bypassUnavailableWarnings(agents, env, opts = {}) {
|
|
|
1320
1280
|
continue;
|
|
1321
1281
|
}
|
|
1322
1282
|
const home = merged.HOME?.trim() || os2.homedir();
|
|
1323
|
-
const configDir = merged.CLAUDE_CONFIG_DIR?.trim() ||
|
|
1283
|
+
const configDir = merged.CLAUDE_CONFIG_DIR?.trim() || path6.join(home, ".claude");
|
|
1324
1284
|
const unreadable = [];
|
|
1325
|
-
for (const file of [
|
|
1285
|
+
for (const file of [path6.join(configDir, "settings.json"), opts.managedFile ?? managedSettingsFile()]) {
|
|
1326
1286
|
const s = readSettings(file, unreadable);
|
|
1327
1287
|
const perms = s && isObject(s.permissions) ? s.permissions : null;
|
|
1328
1288
|
if (perms?.disableBypassPermissionsMode === "disable") {
|
|
@@ -1339,21 +1299,21 @@ function claudeSessionMeta(a) {
|
|
|
1339
1299
|
}
|
|
1340
1300
|
|
|
1341
1301
|
// src/hooks.ts
|
|
1342
|
-
import fs9 from "node:fs";
|
|
1343
|
-
import path9 from "node:path";
|
|
1344
|
-
|
|
1345
|
-
// src/terminals.ts
|
|
1346
1302
|
import fs8 from "node:fs";
|
|
1347
1303
|
import path8 from "node:path";
|
|
1304
|
+
|
|
1305
|
+
// src/terminals.ts
|
|
1306
|
+
import fs7 from "node:fs";
|
|
1307
|
+
import path7 from "node:path";
|
|
1348
1308
|
var SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1349
1309
|
var TERMINALS_DIR = "claw-connector-terminals";
|
|
1350
|
-
var terminalsDir = (configDir) =>
|
|
1310
|
+
var terminalsDir = (configDir) => path7.join(configDir, TERMINALS_DIR);
|
|
1351
1311
|
var UNREADABLE_GRACE_MS = 6e4;
|
|
1352
1312
|
var MAX_STDIN = 1 << 20;
|
|
1353
1313
|
function localPidDomain() {
|
|
1354
1314
|
if (process.platform !== "linux") return null;
|
|
1355
1315
|
try {
|
|
1356
|
-
return `linux:${
|
|
1316
|
+
return `linux:${fs7.readFileSync("/etc/machine-id", "utf8").trim()}:${fs7.readlinkSync("/proc/self/ns/pid")}`;
|
|
1357
1317
|
} catch {
|
|
1358
1318
|
return null;
|
|
1359
1319
|
}
|
|
@@ -1384,7 +1344,7 @@ function readTerminals(configDir, localDomain, now = Date.now()) {
|
|
|
1384
1344
|
const dir = terminalsDir(configDir);
|
|
1385
1345
|
let names;
|
|
1386
1346
|
try {
|
|
1387
|
-
names =
|
|
1347
|
+
names = fs7.readdirSync(dir);
|
|
1388
1348
|
} catch (e) {
|
|
1389
1349
|
if (e.code === "ENOENT") return [];
|
|
1390
1350
|
throw e;
|
|
@@ -1392,15 +1352,15 @@ function readTerminals(configDir, localDomain, now = Date.now()) {
|
|
|
1392
1352
|
const out = [];
|
|
1393
1353
|
for (const name of names) {
|
|
1394
1354
|
if (!name.endsWith(".json")) continue;
|
|
1395
|
-
const file =
|
|
1355
|
+
const file = path7.join(dir, name);
|
|
1396
1356
|
let doc;
|
|
1397
1357
|
try {
|
|
1398
|
-
doc = JSON.parse(
|
|
1358
|
+
doc = JSON.parse(fs7.readFileSync(file, "utf8"));
|
|
1399
1359
|
} catch (e) {
|
|
1400
1360
|
if (e.code === "ENOENT") continue;
|
|
1401
1361
|
let age = Infinity;
|
|
1402
1362
|
try {
|
|
1403
|
-
age = now -
|
|
1363
|
+
age = now - fs7.statSync(file).mtimeMs;
|
|
1404
1364
|
} catch {
|
|
1405
1365
|
}
|
|
1406
1366
|
if (age < UNREADABLE_GRACE_MS) throw new Error(`${file} is unreadable (${e.message.split("\n")[0]})`);
|
|
@@ -1427,19 +1387,19 @@ var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
|
1427
1387
|
function hookCommand(node, script, arg, configDir) {
|
|
1428
1388
|
return `${shellQuote(node)} ${shellQuote(script)} ${arg} --dir ${shellQuote(configDir)} ${MANAGED_MARK}`;
|
|
1429
1389
|
}
|
|
1430
|
-
var settingsFile = (configDir) =>
|
|
1390
|
+
var settingsFile = (configDir) => path8.join(configDir, "settings.json");
|
|
1431
1391
|
var isObject3 = (x) => typeof x === "object" && x !== null && !Array.isArray(x);
|
|
1432
1392
|
var isOurs = (h) => isObject3(h) && h.type === "command" && typeof h.command === "string" && h.command.endsWith(` ${MANAGED_MARK}`) && OURS_RE.test(h.command);
|
|
1433
1393
|
function load(configDir) {
|
|
1434
1394
|
const given = settingsFile(configDir);
|
|
1435
1395
|
let file = given;
|
|
1436
1396
|
try {
|
|
1437
|
-
file =
|
|
1397
|
+
file = fs8.realpathSync(given);
|
|
1438
1398
|
} catch {
|
|
1439
1399
|
}
|
|
1440
1400
|
let text;
|
|
1441
1401
|
try {
|
|
1442
|
-
text =
|
|
1402
|
+
text = fs8.readFileSync(file, "utf8");
|
|
1443
1403
|
} catch (e) {
|
|
1444
1404
|
if (e.code === "ENOENT") return { doc: {}, text: null, file };
|
|
1445
1405
|
throw new HooksError(`cannot read ${given}: ${e.message}`);
|
|
@@ -1457,18 +1417,18 @@ function load(configDir) {
|
|
|
1457
1417
|
function save(l, doc) {
|
|
1458
1418
|
const indent = l.text?.match(/^([ \t]+)"/m)?.[1] ?? " ";
|
|
1459
1419
|
const body = JSON.stringify(doc, null, indent) + "\n";
|
|
1460
|
-
|
|
1420
|
+
fs8.mkdirSync(path8.dirname(l.file), { recursive: true, mode: 448 });
|
|
1461
1421
|
let mode = 384;
|
|
1462
1422
|
try {
|
|
1463
|
-
mode =
|
|
1423
|
+
mode = fs8.statSync(l.file).mode & 511;
|
|
1464
1424
|
} catch {
|
|
1465
1425
|
}
|
|
1466
1426
|
const tmp = `${l.file}.claw-connector.${process.pid}.tmp`;
|
|
1467
|
-
|
|
1427
|
+
fs8.writeFileSync(tmp, body, { mode });
|
|
1468
1428
|
try {
|
|
1469
|
-
|
|
1429
|
+
fs8.renameSync(tmp, l.file);
|
|
1470
1430
|
} catch (e) {
|
|
1471
|
-
|
|
1431
|
+
fs8.rmSync(tmp, { force: true });
|
|
1472
1432
|
throw e;
|
|
1473
1433
|
}
|
|
1474
1434
|
}
|
|
@@ -1548,7 +1508,7 @@ function uninstallHooks(configDir) {
|
|
|
1548
1508
|
if (touchedAny && Object.keys(hooks).length === 0) delete doc.hooks;
|
|
1549
1509
|
}
|
|
1550
1510
|
if (touchedAny) save(l, doc);
|
|
1551
|
-
|
|
1511
|
+
fs8.rmSync(terminalsDir(configDir), { recursive: true, force: true });
|
|
1552
1512
|
return { changed: touchedAny, file: l.file };
|
|
1553
1513
|
}
|
|
1554
1514
|
function hooksStatus(configDir) {
|
|
@@ -1571,7 +1531,7 @@ function hooksStatus(configDir) {
|
|
|
1571
1531
|
const m = /^'((?:[^']|'\\'')*)' '((?:[^']|'\\'')*)' /.exec(String(x.command));
|
|
1572
1532
|
if (!m) continue;
|
|
1573
1533
|
for (const f of [m[1], m[2]].map((s) => s.replace(/'\\''/g, "'"))) {
|
|
1574
|
-
if (!
|
|
1534
|
+
if (!fs8.existsSync(f)) {
|
|
1575
1535
|
healthy = false;
|
|
1576
1536
|
problem ??= `${f} (named by the ${h.event} hook) does not exist; run: claw-connector hooks install`;
|
|
1577
1537
|
}
|
|
@@ -1582,7 +1542,7 @@ function hooksStatus(configDir) {
|
|
|
1582
1542
|
}
|
|
1583
1543
|
|
|
1584
1544
|
// src/connector.ts
|
|
1585
|
-
import
|
|
1545
|
+
import fs16 from "node:fs";
|
|
1586
1546
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1587
1547
|
|
|
1588
1548
|
// src/attach.ts
|
|
@@ -1594,10 +1554,11 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
1594
1554
|
|
|
1595
1555
|
// src/adapter.ts
|
|
1596
1556
|
import { spawn as spawn2 } from "node:child_process";
|
|
1597
|
-
import
|
|
1598
|
-
import
|
|
1557
|
+
import fs9 from "node:fs";
|
|
1558
|
+
import path9 from "node:path";
|
|
1599
1559
|
import { Readable, Writable } from "node:stream";
|
|
1600
1560
|
import * as acp from "@agentclientprotocol/sdk";
|
|
1561
|
+
var AUTH_STATUS_METHOD = "_auth/status_update";
|
|
1601
1562
|
var AdapterProcess = class _AdapterProcess {
|
|
1602
1563
|
constructor(child, pid, registry) {
|
|
1603
1564
|
this.child = child;
|
|
@@ -1608,13 +1569,18 @@ var AdapterProcess = class _AdapterProcess {
|
|
|
1608
1569
|
pid;
|
|
1609
1570
|
registry;
|
|
1610
1571
|
caps = {};
|
|
1572
|
+
/** The ways to sign in the adapter offered at `initialize` (`authMethods`), for `authenticate`. */
|
|
1573
|
+
authMethods = [];
|
|
1574
|
+
/** The last sign-in it reported, if it reports one; see {@link waitAuthStatus}. */
|
|
1575
|
+
authStatus = null;
|
|
1611
1576
|
exited = false;
|
|
1612
1577
|
conn;
|
|
1578
|
+
authWaiters = [];
|
|
1613
1579
|
static async start(o) {
|
|
1614
|
-
|
|
1580
|
+
fs9.mkdirSync(path9.dirname(o.logFile), { recursive: true, mode: 448 });
|
|
1615
1581
|
const env = { ...process.env, ...o.cfg.env };
|
|
1616
1582
|
const run2 = await adapterStore()?.launch(o.cfg, o.cwd, env) ?? { command: o.cfg.command, args: o.cfg.args, cwd: o.cwd };
|
|
1617
|
-
const log =
|
|
1583
|
+
const log = fs9.openSync(o.logFile, "a", 384);
|
|
1618
1584
|
const child = spawn2(run2.command, run2.args, {
|
|
1619
1585
|
cwd: run2.cwd,
|
|
1620
1586
|
env,
|
|
@@ -1626,7 +1592,7 @@ var AdapterProcess = class _AdapterProcess {
|
|
|
1626
1592
|
child.once("spawn", () => resolve());
|
|
1627
1593
|
child.once("error", (e) => reject(new Error(`could not start ${run2.command}: ${errMessage(e)}`)));
|
|
1628
1594
|
}).catch((e) => {
|
|
1629
|
-
|
|
1595
|
+
fs9.closeSync(log);
|
|
1630
1596
|
throw e;
|
|
1631
1597
|
});
|
|
1632
1598
|
const pid = child.pid;
|
|
@@ -1636,7 +1602,7 @@ var AdapterProcess = class _AdapterProcess {
|
|
|
1636
1602
|
const flushLine = (line) => {
|
|
1637
1603
|
const text = redactText(line).text;
|
|
1638
1604
|
try {
|
|
1639
|
-
|
|
1605
|
+
fs9.writeSync(log, text + "\n");
|
|
1640
1606
|
} catch {
|
|
1641
1607
|
}
|
|
1642
1608
|
try {
|
|
@@ -1662,7 +1628,7 @@ var AdapterProcess = class _AdapterProcess {
|
|
|
1662
1628
|
tail = "";
|
|
1663
1629
|
}
|
|
1664
1630
|
try {
|
|
1665
|
-
|
|
1631
|
+
fs9.closeSync(log);
|
|
1666
1632
|
} catch {
|
|
1667
1633
|
}
|
|
1668
1634
|
});
|
|
@@ -1680,7 +1646,12 @@ var AdapterProcess = class _AdapterProcess {
|
|
|
1680
1646
|
if (ctx.method.startsWith("cursor/")) return await o.hooks.onExtension(ctx.method, ctx.params ?? {});
|
|
1681
1647
|
throw acp.RequestError.methodNotFound(ctx.method);
|
|
1682
1648
|
}).onNotification("*", async (ctx) => {
|
|
1683
|
-
|
|
1649
|
+
const params = ctx.params ?? {};
|
|
1650
|
+
if (ctx.method === AUTH_STATUS_METHOD && params.authStatus && typeof params.authStatus.kind === "string") {
|
|
1651
|
+
a.authStatus = { kind: params.authStatus.kind, ...typeof params.authStatus.label === "string" ? { label: params.authStatus.label } : {} };
|
|
1652
|
+
for (const w of a.authWaiters.splice(0)) w();
|
|
1653
|
+
}
|
|
1654
|
+
o.hooks.onNotification(ctx.method, params);
|
|
1684
1655
|
});
|
|
1685
1656
|
a.conn = app.connect(acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout)));
|
|
1686
1657
|
try {
|
|
@@ -1690,12 +1661,46 @@ var AdapterProcess = class _AdapterProcess {
|
|
|
1690
1661
|
"initialize"
|
|
1691
1662
|
);
|
|
1692
1663
|
a.caps = init.agentCapabilities ?? {};
|
|
1664
|
+
const methods2 = init.authMethods;
|
|
1665
|
+
a.authMethods = Array.isArray(methods2) ? methods2.filter((m) => typeof m === "object" && m !== null) : [];
|
|
1693
1666
|
} catch (e) {
|
|
1694
1667
|
await a.kill();
|
|
1695
1668
|
throw e;
|
|
1696
1669
|
}
|
|
1697
1670
|
return a;
|
|
1698
1671
|
}
|
|
1672
|
+
/** Whether the adapter says it reports its sign-in (`agentCapabilities._meta.authStatus`). */
|
|
1673
|
+
get reportsAuth() {
|
|
1674
|
+
const meta = this.caps._meta;
|
|
1675
|
+
return !!meta && typeof meta === "object" && meta.authStatus !== void 0;
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* The sign-in the adapter reports, waiting up to `ms` for its first report (claude-agent-acp sends it shortly after
|
|
1679
|
+
* `initialize`, and again for each new session). Null when it reports nothing in that time: not known, not "none".
|
|
1680
|
+
*/
|
|
1681
|
+
async waitAuthStatus(ms) {
|
|
1682
|
+
if (this.authStatus || !this.reportsAuth || ms <= 0) return this.authStatus;
|
|
1683
|
+
await new Promise((resolve) => {
|
|
1684
|
+
const done = () => {
|
|
1685
|
+
clearTimeout(t);
|
|
1686
|
+
const i = this.authWaiters.indexOf(done);
|
|
1687
|
+
if (i >= 0) this.authWaiters.splice(i, 1);
|
|
1688
|
+
resolve();
|
|
1689
|
+
};
|
|
1690
|
+
const t = setTimeout(done, ms);
|
|
1691
|
+
t.unref?.();
|
|
1692
|
+
this.authWaiters.push(done);
|
|
1693
|
+
});
|
|
1694
|
+
return this.authStatus;
|
|
1695
|
+
}
|
|
1696
|
+
/** The id of an `authenticate` method that signs in with an API key the adapter reads from its own environment. */
|
|
1697
|
+
apiKeyMethod() {
|
|
1698
|
+
for (const m of this.authMethods) {
|
|
1699
|
+
const id = typeof m.id === "string" ? m.id : typeof m.methodId === "string" ? m.methodId : null;
|
|
1700
|
+
if (id === "api-key") return id;
|
|
1701
|
+
}
|
|
1702
|
+
return null;
|
|
1703
|
+
}
|
|
1699
1704
|
request(method, params, timeoutMs = 6e4) {
|
|
1700
1705
|
return withTimeout(this.conn.agent.request(method, params), timeoutMs, method);
|
|
1701
1706
|
}
|
|
@@ -1771,6 +1776,155 @@ function isRemoteCommandType(v) {
|
|
|
1771
1776
|
return typeof v === "string" && REMOTE_COMMAND_TYPES.includes(v);
|
|
1772
1777
|
}
|
|
1773
1778
|
|
|
1779
|
+
// src/roots.ts
|
|
1780
|
+
import fs10 from "node:fs";
|
|
1781
|
+
import path10 from "node:path";
|
|
1782
|
+
function realRoot(root) {
|
|
1783
|
+
try {
|
|
1784
|
+
return fs10.realpathSync.native(root);
|
|
1785
|
+
} catch {
|
|
1786
|
+
return null;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
var inside = (child, root) => child === root || child.startsWith(root.endsWith(path10.sep) ? root : root + path10.sep);
|
|
1790
|
+
function checkCwd(cwd, roots) {
|
|
1791
|
+
if (typeof cwd !== "string" || cwd === "" || cwd.includes("\0")) return { ok: false, reason: "invalid_cwd" };
|
|
1792
|
+
if (!path10.isAbsolute(cwd)) return { ok: false, reason: "cwd_not_absolute" };
|
|
1793
|
+
let real;
|
|
1794
|
+
try {
|
|
1795
|
+
real = fs10.realpathSync.native(cwd);
|
|
1796
|
+
} catch {
|
|
1797
|
+
return { ok: false, reason: "cwd_not_found" };
|
|
1798
|
+
}
|
|
1799
|
+
try {
|
|
1800
|
+
if (!fs10.statSync(real).isDirectory()) return { ok: false, reason: "cwd_not_directory" };
|
|
1801
|
+
} catch {
|
|
1802
|
+
return { ok: false, reason: "cwd_not_found" };
|
|
1803
|
+
}
|
|
1804
|
+
for (const root of roots) {
|
|
1805
|
+
const r = realRoot(root);
|
|
1806
|
+
if (r && inside(real, r)) return { ok: true, real };
|
|
1807
|
+
}
|
|
1808
|
+
return { ok: false, reason: "cwd_outside_roots" };
|
|
1809
|
+
}
|
|
1810
|
+
var stalled = /* @__PURE__ */ new Set();
|
|
1811
|
+
var Stalled = class extends Error {
|
|
1812
|
+
};
|
|
1813
|
+
async function bounded(what, f, ms) {
|
|
1814
|
+
if (stalled.has(what)) throw new Stalled(what);
|
|
1815
|
+
let timer;
|
|
1816
|
+
const call = f();
|
|
1817
|
+
const late = new Promise((_, reject) => {
|
|
1818
|
+
timer = setTimeout(() => reject(new Stalled(what)), ms);
|
|
1819
|
+
timer.unref?.();
|
|
1820
|
+
});
|
|
1821
|
+
try {
|
|
1822
|
+
return await Promise.race([call, late]);
|
|
1823
|
+
} catch (e) {
|
|
1824
|
+
if (e instanceof Stalled) {
|
|
1825
|
+
stalled.add(what);
|
|
1826
|
+
void call.then(() => stalled.delete(what), () => stalled.delete(what));
|
|
1827
|
+
}
|
|
1828
|
+
throw e;
|
|
1829
|
+
} finally {
|
|
1830
|
+
clearTimeout(timer);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
var realpathOf = (p, ms, f = fs10.promises) => bounded(p, () => f.realpath(p), ms);
|
|
1834
|
+
var isDir = async (p, ms, f = fs10.promises) => {
|
|
1835
|
+
try {
|
|
1836
|
+
return (await bounded(`stat:${p}`, () => f.stat(p), ms)).isDirectory();
|
|
1837
|
+
} catch (e) {
|
|
1838
|
+
if (e instanceof Stalled) throw e;
|
|
1839
|
+
return false;
|
|
1840
|
+
}
|
|
1841
|
+
};
|
|
1842
|
+
async function resolveCwd(cwd, roots, o) {
|
|
1843
|
+
const ms = o.timeoutMs ?? 1e4;
|
|
1844
|
+
if (typeof cwd !== "string" || cwd.trim() === "" || cwd.includes("\0")) return { ok: false, reason: "invalid_cwd" };
|
|
1845
|
+
const typed = cwd.trim();
|
|
1846
|
+
let candidates;
|
|
1847
|
+
if (typed === "~" || typed.startsWith("~/")) candidates = [path10.join(o.home, typed.slice(2))];
|
|
1848
|
+
else if (path10.isAbsolute(typed)) candidates = [typed];
|
|
1849
|
+
else if (typed.startsWith("~")) return { ok: false, reason: "cwd_not_absolute" };
|
|
1850
|
+
else {
|
|
1851
|
+
const clean = path10.normalize(typed).replace(/\/+$/, "");
|
|
1852
|
+
if (clean === ".." || clean.startsWith("../")) return { ok: false, reason: "cwd_outside_roots" };
|
|
1853
|
+
candidates = [.../* @__PURE__ */ new Set([
|
|
1854
|
+
...roots.map((r) => path10.join(r, clean)),
|
|
1855
|
+
...roots.filter((r) => path10.basename(r) === clean)
|
|
1856
|
+
])];
|
|
1857
|
+
}
|
|
1858
|
+
const realRoots = [];
|
|
1859
|
+
const down = [];
|
|
1860
|
+
await Promise.all(roots.map(async (r) => {
|
|
1861
|
+
try {
|
|
1862
|
+
realRoots.push(await realpathOf(r, ms, o.fs));
|
|
1863
|
+
} catch {
|
|
1864
|
+
down.push(path10.resolve(r));
|
|
1865
|
+
}
|
|
1866
|
+
}));
|
|
1867
|
+
const underDown = (p) => down.some((r) => inside(path10.resolve(p), r));
|
|
1868
|
+
const hits = /* @__PURE__ */ new Map();
|
|
1869
|
+
let unavailable = false;
|
|
1870
|
+
for (const c of candidates) {
|
|
1871
|
+
if (underDown(c)) {
|
|
1872
|
+
unavailable = true;
|
|
1873
|
+
continue;
|
|
1874
|
+
}
|
|
1875
|
+
let real;
|
|
1876
|
+
try {
|
|
1877
|
+
real = await realpathOf(c, ms, o.fs);
|
|
1878
|
+
} catch (e) {
|
|
1879
|
+
if (e instanceof Stalled) unavailable = true;
|
|
1880
|
+
continue;
|
|
1881
|
+
}
|
|
1882
|
+
let dir;
|
|
1883
|
+
try {
|
|
1884
|
+
dir = await isDir(real, ms, o.fs);
|
|
1885
|
+
} catch {
|
|
1886
|
+
unavailable = true;
|
|
1887
|
+
continue;
|
|
1888
|
+
}
|
|
1889
|
+
if (!dir) {
|
|
1890
|
+
if (candidates.length === 1) return { ok: false, reason: "cwd_not_directory" };
|
|
1891
|
+
continue;
|
|
1892
|
+
}
|
|
1893
|
+
if (realRoots.some((r) => inside(real, r))) hits.set(real, true);
|
|
1894
|
+
else if (candidates.length === 1) return { ok: false, reason: "cwd_outside_roots" };
|
|
1895
|
+
}
|
|
1896
|
+
if (hits.size > 1) return { ok: false, reason: "cwd_ambiguous" };
|
|
1897
|
+
if (hits.size === 1) return { ok: true, real: [...hits.keys()][0] };
|
|
1898
|
+
return { ok: false, reason: unavailable ? "root_unavailable" : "cwd_not_found" };
|
|
1899
|
+
}
|
|
1900
|
+
async function rootProblems(agents, timeoutMs = 1e4) {
|
|
1901
|
+
const out = [];
|
|
1902
|
+
await Promise.all(agents.flatMap((a) => a.roots.map(async (r) => {
|
|
1903
|
+
try {
|
|
1904
|
+
const real = await realpathOf(r, timeoutMs);
|
|
1905
|
+
if (!await isDir(real, timeoutMs)) out.push(`${a.id}: root ${r} is not a directory; sessions there are refused`);
|
|
1906
|
+
} catch (e) {
|
|
1907
|
+
out.push(e instanceof Stalled ? `${a.id}: root ${r} does not answer (a stalled network mount?); its sessions are refused (root_unavailable) until it does` : `${a.id}: root ${r} does not exist (yet); its sessions are refused until it does (create it, or fix roots in agents.toml)`);
|
|
1908
|
+
}
|
|
1909
|
+
})));
|
|
1910
|
+
return out.sort();
|
|
1911
|
+
}
|
|
1912
|
+
function relativeInside(p, cwd) {
|
|
1913
|
+
const abs = path10.resolve(cwd, p);
|
|
1914
|
+
let real;
|
|
1915
|
+
try {
|
|
1916
|
+
real = fs10.realpathSync.native(abs);
|
|
1917
|
+
} catch {
|
|
1918
|
+
try {
|
|
1919
|
+
real = path10.join(fs10.realpathSync.native(path10.dirname(abs)), path10.basename(abs));
|
|
1920
|
+
} catch {
|
|
1921
|
+
return null;
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
if (!inside(real, cwd)) return null;
|
|
1925
|
+
return path10.relative(cwd, real).split(path10.sep).join("/");
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1774
1928
|
// src/attach.ts
|
|
1775
1929
|
var TwinCheckError = class extends Error {
|
|
1776
1930
|
};
|
|
@@ -1850,6 +2004,14 @@ var AttachSupport = class {
|
|
|
1850
2004
|
});
|
|
1851
2005
|
return this.refreshing;
|
|
1852
2006
|
}
|
|
2007
|
+
/** Where the listing adapter starts: the first root that is there now (a root may be missing), else the home directory. */
|
|
2008
|
+
async listingDir() {
|
|
2009
|
+
for (const r of this.agent.roots) {
|
|
2010
|
+
const v = await resolveCwd(r, [r], { home: os3.homedir(), timeoutMs: 5e3 });
|
|
2011
|
+
if (v.ok) return v.real;
|
|
2012
|
+
}
|
|
2013
|
+
return os3.homedir();
|
|
2014
|
+
}
|
|
1853
2015
|
async doRefresh() {
|
|
1854
2016
|
let adapter = null;
|
|
1855
2017
|
this.noteHooks();
|
|
@@ -1858,7 +2020,7 @@ var AttachSupport = class {
|
|
|
1858
2020
|
cfg: this.agent,
|
|
1859
2021
|
registry: this.d.registry,
|
|
1860
2022
|
sessionId: `list-${this.agent.id}`,
|
|
1861
|
-
cwd: this.
|
|
2023
|
+
cwd: await this.listingDir(),
|
|
1862
2024
|
hooks: NO_HOOKS,
|
|
1863
2025
|
logFile: path11.join(this.d.logDir, `adapter-${this.agent.id}.log`),
|
|
1864
2026
|
version: this.d.version,
|
|
@@ -1872,7 +2034,8 @@ var AttachSupport = class {
|
|
|
1872
2034
|
const res = await adapter.request("session/list", cursor ? { cursor } : {}, 3e4);
|
|
1873
2035
|
for (const s of Array.isArray(res.sessions) ? res.sessions : []) {
|
|
1874
2036
|
if (!isObject(s) || typeof s.sessionId !== "string" || !SESSION_REF_RE.test(s.sessionId) || typeof s.cwd !== "string") continue;
|
|
1875
|
-
|
|
2037
|
+
if (!path11.isAbsolute(s.cwd)) continue;
|
|
2038
|
+
const where = await resolveCwd(s.cwd, this.agent.roots, { home: os3.homedir(), timeoutMs: 5e3 });
|
|
1876
2039
|
if (!where.ok) continue;
|
|
1877
2040
|
const at = typeof s.updatedAt === "string" ? Date.parse(s.updatedAt) : NaN;
|
|
1878
2041
|
found.set(s.sessionId.toLowerCase(), {
|
|
@@ -2432,13 +2595,15 @@ import os4 from "node:os";
|
|
|
2432
2595
|
import path12 from "node:path";
|
|
2433
2596
|
function resolveDirs(env = process.env, home = os4.homedir()) {
|
|
2434
2597
|
const one = env.CLAW_CONNECTOR_HOME?.trim();
|
|
2435
|
-
|
|
2598
|
+
const own = env.CLAW_CONNECTOR_CONFIG_DIR?.trim();
|
|
2599
|
+
if (one) return { config: own ? path12.resolve(own) : path12.join(one, "config"), state: path12.join(one, "state") };
|
|
2436
2600
|
const cfg = env.XDG_CONFIG_HOME?.trim() || path12.join(home, ".config");
|
|
2437
2601
|
const st = env.XDG_STATE_HOME?.trim() || path12.join(home, ".local", "state");
|
|
2438
|
-
return { config: path12.join(cfg, "claw-connector"), state: path12.join(st, "claw-connector") };
|
|
2602
|
+
return { config: own ? path12.resolve(own) : path12.join(cfg, "claw-connector"), state: path12.join(st, "claw-connector") };
|
|
2439
2603
|
}
|
|
2440
2604
|
var configFile = (d) => path12.join(d.config, "agents.toml");
|
|
2441
2605
|
var credentialsFile = (d) => path12.join(d.config, "credentials.json");
|
|
2606
|
+
var serviceEnvFile = (d) => path12.join(d.config, "service-env.json");
|
|
2442
2607
|
var identityFile = (d) => path12.join(d.state, "connector.json");
|
|
2443
2608
|
var sessionsFile = (d) => path12.join(d.state, "sessions.json");
|
|
2444
2609
|
var childrenFile = (d) => path12.join(d.state, "children.json");
|
|
@@ -2454,6 +2619,8 @@ var supervisorPidFile = (d) => path12.join(d.state, "supervisor.pid");
|
|
|
2454
2619
|
var runLogFile = (d) => path12.join(d.state, "run.log");
|
|
2455
2620
|
var keepaliveFile = (d) => path12.join(d.state, "keepalive.sh");
|
|
2456
2621
|
var serviceFile = (d) => path12.join(d.state, "service.json");
|
|
2622
|
+
var doctorFile = (d) => path12.join(d.state, "doctor.json");
|
|
2623
|
+
var keepaliveStampFile = (d) => path12.join(d.state, "keepalive-last");
|
|
2457
2624
|
|
|
2458
2625
|
// src/health.ts
|
|
2459
2626
|
var MAX_BYTES = 128 * 1024;
|
|
@@ -2523,7 +2690,70 @@ function clearNeedsLogin(dirs) {
|
|
|
2523
2690
|
}
|
|
2524
2691
|
|
|
2525
2692
|
// src/session.ts
|
|
2693
|
+
import path15 from "node:path";
|
|
2694
|
+
|
|
2695
|
+
// src/hints.ts
|
|
2526
2696
|
import path14 from "node:path";
|
|
2697
|
+
function agentFamily(a) {
|
|
2698
|
+
const words = [a.command, ...a.args].map((s) => path14.basename(s));
|
|
2699
|
+
if (words.some((s) => s.includes("claude-agent-acp") || s.includes("claude-code-acp"))) return "claude";
|
|
2700
|
+
if (words.some((s) => s.includes("codex-acp"))) return "codex";
|
|
2701
|
+
const cmd = path14.basename(a.command);
|
|
2702
|
+
if (cmd === "agent" || cmd === "cursor-agent") return "cursor";
|
|
2703
|
+
if (a.kind === "claude" || a.kind === "attach") return "claude";
|
|
2704
|
+
if (a.kind === "codex" || a.kind === "cursor") return a.kind;
|
|
2705
|
+
return "other";
|
|
2706
|
+
}
|
|
2707
|
+
var SIGN_IN = {
|
|
2708
|
+
claude: `Claude Code is not signed in on that machine: run "claude" there and sign in with /login, or set ANTHROPIC_API_KEY (or your gateway's ANTHROPIC_AUTH_TOKEN) for this agent in agents.toml under [agents.env]; a service installed from a shell that has the variable keeps it too`,
|
|
2709
|
+
codex: `Codex is not signed in on that machine: run "codex login" there (or "codex login --with-api-key"), or set OPENAI_API_KEY for this agent in agents.toml under [agents.env]`,
|
|
2710
|
+
cursor: `Cursor is not signed in on that machine: run "agent login" there, or set CURSOR_API_KEY for this agent in agents.toml under [agents.env]`,
|
|
2711
|
+
other: `the agent is not signed in on that machine: sign it in there, then try again`
|
|
2712
|
+
};
|
|
2713
|
+
var DOCTOR = `run "claw-connector doctor" on that machine; it names the fix`;
|
|
2714
|
+
var HINTS = {
|
|
2715
|
+
invalid_cwd: "give the folder to work in: a full path, ~/path, or the name of a folder inside one of this agent's roots",
|
|
2716
|
+
cwd_not_absolute: "give the folder as a full path, as ~/path, or as the name of a folder inside one of this agent's roots",
|
|
2717
|
+
cwd_not_found: "that folder does not exist on that machine: pick an existing folder inside one of this agent's roots, or create it there first",
|
|
2718
|
+
cwd_not_directory: "that is a file, not a folder: pick a folder inside one of this agent's roots",
|
|
2719
|
+
cwd_ambiguous: "more than one of this agent's roots has a folder by that name: give its full path",
|
|
2720
|
+
cwd_outside_roots: "this agent may only work inside its roots (agents.toml on that machine): pick a folder inside one of them, or add the folder to roots there and restart the connector",
|
|
2721
|
+
cwd_mismatch: "that conversation belongs to another folder: take it over without naming a folder, or name the one it was started in",
|
|
2722
|
+
root_unavailable: "the folder's disk is not answering on that machine (a network mount that is down or not mounted yet): try again in a minute, or check the mount there",
|
|
2723
|
+
unknown_agent: "that machine no longer offers this agent: check agents.toml there, then restart the connector",
|
|
2724
|
+
too_many_sessions: "that machine already runs the most sessions it allows at once (10): close one, then try again",
|
|
2725
|
+
mode_not_allowed: `that mode does not ask before each step, and this agent may use such a mode only with approval = "never" in agents.toml on that machine`,
|
|
2726
|
+
mode_unavailable: `the agent does not offer the mode this machine requires for it: ${DOCTOR}`,
|
|
2727
|
+
mode_not_enforced: `the agent would not run in a mode that asks before each step (often because Claude's own settings default to bypassPermissions): ${DOCTOR}, or set approval = "never" for this agent there`,
|
|
2728
|
+
mode_not_confirmed: `the agent did not confirm the mode this machine requires: ${DOCTOR}`,
|
|
2729
|
+
mode_mismatch: `the agent switched to a mode other than the one this machine requires: ${DOCTOR}`,
|
|
2730
|
+
adapter_start_failed: `the agent's program could not start on that machine (not installed, not on the service's PATH, or the download failed): ${DOCTOR}`,
|
|
2731
|
+
adapter_error: `the agent failed while opening the session: ${DOCTOR}; the adapter's log is in the connector's logs directory there`,
|
|
2732
|
+
adapter_protocol: `the agent answered in a way the connector does not understand: ${DOCTOR}, and update the agent's adapter`,
|
|
2733
|
+
adapter_cannot_resume: "this agent cannot reopen an earlier session: start a new one",
|
|
2734
|
+
adapter_gone: "the agent stopped while the session was opening: try again",
|
|
2735
|
+
resume_not_supported: "this agent starts new conversations only: start a new session instead of continuing one",
|
|
2736
|
+
resume_ref_required: "this agent only takes over conversations that already exist on that machine: pick one from its list, or use the agent that starts new ones",
|
|
2737
|
+
invalid_resume_ref: "that is not a conversation this machine lists: pick one from its list",
|
|
2738
|
+
attach_unavailable: "taking over conversations is not available for this agent on that machine: start a new session instead",
|
|
2739
|
+
already_attached: "that conversation is already open from the chat: use that session",
|
|
2740
|
+
unknown_session: "that machine no longer has this conversation (or it is outside the agent's roots): pick another one from its list",
|
|
2741
|
+
catalog_unavailable: `that machine could not list its conversations just now: try again; if it keeps failing, ${DOCTOR}`,
|
|
2742
|
+
session_live: "that conversation is open in a terminal on that machine: exit it there (/exit), then try again",
|
|
2743
|
+
recently_written: "that conversation was written to a moment ago: wait a few seconds, then try again",
|
|
2744
|
+
twin_check_failed: "the connector could not check whether a terminal holds that conversation open: try again in a moment",
|
|
2745
|
+
too_many_queued_prompts: "five messages are already waiting for this session: wait for the agent to finish one",
|
|
2746
|
+
text_required: "the message was empty",
|
|
2747
|
+
session_closed: "the session has already ended: start a new one"
|
|
2748
|
+
};
|
|
2749
|
+
function hintFor(reason, agent) {
|
|
2750
|
+
if (reason === "agent_auth_required") return SIGN_IN[agent ? agentFamily(agent) : "other"];
|
|
2751
|
+
return HINTS[reason] ?? null;
|
|
2752
|
+
}
|
|
2753
|
+
function withHint(data, agent) {
|
|
2754
|
+
const hint = hintFor(data.reason, agent);
|
|
2755
|
+
return hint ? { ...data, hint } : data;
|
|
2756
|
+
}
|
|
2527
2757
|
|
|
2528
2758
|
// src/permissions.ts
|
|
2529
2759
|
import { randomUUID } from "node:crypto";
|
|
@@ -2657,7 +2887,9 @@ var DEFAULT_SESSION_TIMING = {
|
|
|
2657
2887
|
modeConfirmMs: 3e3,
|
|
2658
2888
|
twinWatchMs: 1e3,
|
|
2659
2889
|
spectateMs: 1e3,
|
|
2660
|
-
spectateCheckMs: 5e3
|
|
2890
|
+
spectateCheckMs: 5e3,
|
|
2891
|
+
rootCheckMs: 1e4,
|
|
2892
|
+
authWaitMs: 3e3
|
|
2661
2893
|
};
|
|
2662
2894
|
var READ_ONLY_REASON = "read_only_live_session";
|
|
2663
2895
|
var SessionRefusal = class extends Error {
|
|
@@ -2722,6 +2954,13 @@ var Session = class {
|
|
|
2722
2954
|
*/
|
|
2723
2955
|
twinSeen = false;
|
|
2724
2956
|
turnRunning = false;
|
|
2957
|
+
/**
|
|
2958
|
+
* The connector is going away (stopped, or restarted by its supervisor) and will come back. What this process cannot
|
|
2959
|
+
* finish is left recorded for the next run instead of being ended here: the running turn (no `turn_end`), its waiting
|
|
2960
|
+
* permission requests (still pending), and the prompts queued behind it (not counted as handled, so Claw delivers them
|
|
2961
|
+
* again). See {@link SessionManager.recover}.
|
|
2962
|
+
*/
|
|
2963
|
+
stopping = false;
|
|
2725
2964
|
queue = [];
|
|
2726
2965
|
launching = null;
|
|
2727
2966
|
idle;
|
|
@@ -2763,7 +3002,7 @@ var Session = class {
|
|
|
2763
3002
|
return this.noAskWarning || this.currentMode !== null && !this.agent.askingModes.includes(this.currentMode);
|
|
2764
3003
|
}
|
|
2765
3004
|
shownModes() {
|
|
2766
|
-
return this.modes.filter((m) => this.modeAllowed(m.id)).map((m) => ({ ...m, unattended: !this.agent.askingModes.includes(m.id) }));
|
|
3005
|
+
return this.modes.filter((m) => this.modeAllowed(m.id)).map((m) => ({ ...m, unattended: !this.agent.askingModes.includes(m.id), ...this.agent.readOnlyModes.includes(m.id) ? { read_only: true } : {} }));
|
|
2767
3006
|
}
|
|
2768
3007
|
// --- starting ----------------------------------------------------------------
|
|
2769
3008
|
/** `session.new`: start the adapter, create the ACP session, lock the mode. A refusal closes the session with a reason. */
|
|
@@ -2971,7 +3210,7 @@ var Session = class {
|
|
|
2971
3210
|
sessionId: this.id,
|
|
2972
3211
|
cwd: this.cwd,
|
|
2973
3212
|
hooks: this.hooks(),
|
|
2974
|
-
logFile:
|
|
3213
|
+
logFile: path15.join(this.env.logDir, `adapter-${this.agent.id}.log`),
|
|
2975
3214
|
version: this.env.version
|
|
2976
3215
|
}).catch((e) => {
|
|
2977
3216
|
throw new SessionRefusal("adapter_start_failed", errMessage(e));
|
|
@@ -2984,19 +3223,20 @@ var Session = class {
|
|
|
2984
3223
|
this.modeSeen = [];
|
|
2985
3224
|
try {
|
|
2986
3225
|
if (how === "new") {
|
|
2987
|
-
const res = await adapter.request("session/new", { cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) });
|
|
3226
|
+
const res = await this.signedIn(adapter, () => adapter.request("session/new", { cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) }));
|
|
2988
3227
|
if (typeof res.sessionId !== "string") throw new SessionRefusal("adapter_protocol", "session/new returned no session id");
|
|
2989
3228
|
this.acpId = res.sessionId;
|
|
2990
3229
|
this.absorb(res);
|
|
3230
|
+
await this.refuseSignedOut(adapter);
|
|
2991
3231
|
} else {
|
|
2992
3232
|
if (!this.acpId) throw new SessionRefusal("adapter_cannot_resume", "no adapter session to resume");
|
|
2993
3233
|
const caps = adapter.caps.sessionCapabilities;
|
|
2994
3234
|
if (caps?.resume) {
|
|
2995
|
-
this.absorb(await adapter.request("session/resume", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) }));
|
|
3235
|
+
this.absorb(await this.signedIn(adapter, () => adapter.request("session/resume", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) })));
|
|
2996
3236
|
} else if (adapter.caps.loadSession) {
|
|
2997
3237
|
this.replaying = true;
|
|
2998
3238
|
try {
|
|
2999
|
-
this.absorb(await adapter.requestUnbounded("session/load", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) }));
|
|
3239
|
+
this.absorb(await this.signedIn(adapter, () => adapter.requestUnbounded("session/load", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) })));
|
|
3000
3240
|
} finally {
|
|
3001
3241
|
this.replaying = false;
|
|
3002
3242
|
}
|
|
@@ -3011,6 +3251,43 @@ var Session = class {
|
|
|
3011
3251
|
throw e instanceof SessionRefusal ? e : mapAdapterError(e);
|
|
3012
3252
|
}
|
|
3013
3253
|
}
|
|
3254
|
+
/**
|
|
3255
|
+
* `open` (a `session/new`), signing the adapter in first when it needs that and can do it by itself: an adapter that
|
|
3256
|
+
* answers "Authentication required" and offers an `api-key` method, while its environment holds a key it reads
|
|
3257
|
+
* (codex-acp: OPENAI_API_KEY or CODEX_API_KEY), is told to `authenticate` with it and asked once more. Without this a
|
|
3258
|
+
* Codex agent set up with only an API key never opened a session. The key itself is never sent: the adapter reads
|
|
3259
|
+
* its own environment. (codex-acp then keeps the key in its auth.json, as `codex login --with-api-key` does.)
|
|
3260
|
+
*/
|
|
3261
|
+
async signedIn(adapter, open) {
|
|
3262
|
+
try {
|
|
3263
|
+
return await open();
|
|
3264
|
+
} catch (e) {
|
|
3265
|
+
const method = adapter.apiKeyMethod();
|
|
3266
|
+
if (mapAdapterError(e).reason !== "agent_auth_required" || !method || !this.hasApiKey()) throw e;
|
|
3267
|
+
this.audit("agent_authenticate", { method });
|
|
3268
|
+
try {
|
|
3269
|
+
await adapter.request("authenticate", { methodId: method });
|
|
3270
|
+
} catch (ae) {
|
|
3271
|
+
throw new SessionRefusal("agent_auth_required", `authenticate ${method} failed: ${errMessage(ae)}`);
|
|
3272
|
+
}
|
|
3273
|
+
return await open();
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
/** Whether the adapter's environment (the connector's, with the agent's `[agents.env]` over it) holds an OpenAI/Codex key. */
|
|
3277
|
+
hasApiKey() {
|
|
3278
|
+
const env = { ...process.env, ...this.agent.env };
|
|
3279
|
+
return ["CODEX_API_KEY", "OPENAI_API_KEY"].some((k) => typeof env[k] === "string" && env[k].trim() !== "");
|
|
3280
|
+
}
|
|
3281
|
+
/**
|
|
3282
|
+
* An adapter that reports its sign-in (claude-agent-acp) and says there is none: the session would open, say it is
|
|
3283
|
+
* ready and fail at its first message with "Authentication required". A new session is refused now instead, with the
|
|
3284
|
+
* fix. A session that already exists (resumed after an idle recycle or a restart) is not: its prompts say why and it
|
|
3285
|
+
* stays, so it carries on once someone signs in on the machine.
|
|
3286
|
+
*/
|
|
3287
|
+
async refuseSignedOut(adapter) {
|
|
3288
|
+
const st = await adapter.waitAuthStatus(this.env.timing.authWaitMs);
|
|
3289
|
+
if (st?.kind === "none") throw new SessionRefusal("agent_auth_required", `the agent reports that it is not signed in${st.label ? ` (${st.label})` : ""}`);
|
|
3290
|
+
}
|
|
3014
3291
|
/** Take the modes and the mode config option out of a `session/new|resume|load` answer. */
|
|
3015
3292
|
absorb(res) {
|
|
3016
3293
|
const modes = res.modes;
|
|
@@ -3146,7 +3423,7 @@ var Session = class {
|
|
|
3146
3423
|
this.adapter = null;
|
|
3147
3424
|
const hadTurn = this.turnRunning;
|
|
3148
3425
|
const cancelled = this.book.cancelAll();
|
|
3149
|
-
for (const actionId of cancelled) this.emit("permission_resolved", { actionId, optionId: null, outcome: "cancelled", by: "adapter_exit" });
|
|
3426
|
+
if (!this.stopping) for (const actionId of cancelled) this.emit("permission_resolved", { actionId, optionId: null, outcome: "cancelled", by: "adapter_exit" });
|
|
3150
3427
|
if (!this.closed) this.audit("adapter_exited", { code: info.code, signal: info.signal, during_turn: hadTurn });
|
|
3151
3428
|
}
|
|
3152
3429
|
onUpdate(update, params) {
|
|
@@ -3195,16 +3472,20 @@ var Session = class {
|
|
|
3195
3472
|
kind: str(toolCall.kind, 64),
|
|
3196
3473
|
input: boundedInput(toolCall.rawInput),
|
|
3197
3474
|
toolCallId: str(toolCall.toolCallId, 128)
|
|
3198
|
-
}, (optionId) => optionId === null ? { outcome: { outcome: "cancelled" } } : { outcome: { outcome: "selected", optionId } });
|
|
3475
|
+
}, (optionId) => optionId === null ? { outcome: { outcome: "cancelled" } } : { outcome: { outcome: "selected", optionId } }, true);
|
|
3199
3476
|
}
|
|
3200
3477
|
/** Put a question on the phone and wait for the answer, a cancel or the timeout. `shape` turns the answer into what the adapter expects. */
|
|
3201
|
-
askPerson(options, info, shape) {
|
|
3478
|
+
askPerson(options, info, shape, permission = false) {
|
|
3202
3479
|
return new Promise((resolve) => {
|
|
3203
3480
|
const p = this.book.open(options, (actionId) => redactDeep({ actionId, ...info, options, timeout_ms: this.book.ttl }), (optionId) => {
|
|
3204
|
-
|
|
3481
|
+
if (!this.stopping) {
|
|
3482
|
+
this.env.store.removePending(this.id, p.actionId);
|
|
3483
|
+
if (this.book.size === 0) this.env.store.markWaiting(this.id, false);
|
|
3484
|
+
}
|
|
3205
3485
|
resolve(shape(optionId));
|
|
3206
3486
|
});
|
|
3207
3487
|
this.env.store.addPending(this.id, p.actionId);
|
|
3488
|
+
this.env.store.markWaiting(this.id, true, permission);
|
|
3208
3489
|
this.audit("permission_requested", { action_id: p.actionId, tool: info.tool, tool_kind: info.kind, options: options.map((o) => o.optionId) });
|
|
3209
3490
|
this.emit("permissionRequest", p.payload);
|
|
3210
3491
|
});
|
|
@@ -3286,11 +3567,16 @@ var Session = class {
|
|
|
3286
3567
|
this.queue.push({ text, cmdId, cmdSeq, started });
|
|
3287
3568
|
if (!this.turnRunning) void this.pump();
|
|
3288
3569
|
}
|
|
3289
|
-
/** Prompts that will now never run (the
|
|
3290
|
-
dropQueued() {
|
|
3570
|
+
/** Prompts that will now never run here: they count as handled, unless (`keep`) the connector is only restarting and the next run should get them again. */
|
|
3571
|
+
dropQueued(keep = false) {
|
|
3291
3572
|
const q = this.queue;
|
|
3292
3573
|
this.queue = [];
|
|
3293
|
-
for (const item of q) item.started();
|
|
3574
|
+
if (!keep) for (const item of q) item.started();
|
|
3575
|
+
}
|
|
3576
|
+
/** The connector is about to stop its adapters and will come back: see {@link stopping}. */
|
|
3577
|
+
shutdown() {
|
|
3578
|
+
this.stopping = true;
|
|
3579
|
+
this.dropQueued(true);
|
|
3294
3580
|
}
|
|
3295
3581
|
async pump() {
|
|
3296
3582
|
if (this.turnRunning) return;
|
|
@@ -3314,6 +3600,7 @@ var Session = class {
|
|
|
3314
3600
|
this.audit("prompt_refused", { reason: refused.reason, detail: refused.detail.slice(0, 300) });
|
|
3315
3601
|
this.rejectCommand("prompt", cmdId, refused.reason, cmdSeq);
|
|
3316
3602
|
this.emit("turn_end", { stopReason: "error", error: refused.reason, cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
3603
|
+
this.env.store.endTurn(this.id);
|
|
3317
3604
|
return;
|
|
3318
3605
|
}
|
|
3319
3606
|
}
|
|
@@ -3321,7 +3608,11 @@ var Session = class {
|
|
|
3321
3608
|
await this.ensureAdapter();
|
|
3322
3609
|
} catch (e) {
|
|
3323
3610
|
started();
|
|
3324
|
-
|
|
3611
|
+
const reason = e instanceof SessionRefusal ? e.reason : "adapter_unavailable";
|
|
3612
|
+
const hint = hintFor(reason, this.agent);
|
|
3613
|
+
this.emit("turn_end", { stopReason: "error", error: reason, ...hint ? { hint } : {}, cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
3614
|
+
this.env.store.endTurn(this.id);
|
|
3615
|
+
if (reason === "agent_auth_required") return;
|
|
3325
3616
|
return this.refuse(e);
|
|
3326
3617
|
}
|
|
3327
3618
|
const a = this.adapter;
|
|
@@ -3330,13 +3621,22 @@ var Session = class {
|
|
|
3330
3621
|
const watch = support ? this.watchTwins(support, this.acpId) : null;
|
|
3331
3622
|
try {
|
|
3332
3623
|
const reply = a.requestUnbounded("session/prompt", { sessionId: this.acpId, prompt: [{ type: "text", text }] });
|
|
3624
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3625
|
+
this.env.store.beginTurn({ session_id: this.id, cmd_id: cmdId, cmd_seq: cmdSeq, started_at: now });
|
|
3626
|
+
this.env.store.patch(this.id, { used_at: now });
|
|
3333
3627
|
started();
|
|
3334
3628
|
const res = await reply;
|
|
3335
3629
|
const usage = isObject(res.usage) ? res.usage : void 0;
|
|
3336
|
-
this.
|
|
3630
|
+
this.endTurn({ stopReason: typeof res.stopReason === "string" ? res.stopReason : "end_turn", ...usage ? { usage } : {}, cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
3337
3631
|
} catch (e) {
|
|
3338
|
-
this.audit("turn_failed", { error: errMessage(e) });
|
|
3339
|
-
|
|
3632
|
+
if (!this.stopping) this.audit("turn_failed", { error: errMessage(e) });
|
|
3633
|
+
const r = mapAdapterError(e);
|
|
3634
|
+
if (r.reason === "agent_auth_required") {
|
|
3635
|
+
this.endTurn({ stopReason: "error", error: r.reason, message: errMessage(e).slice(0, 300), hint: hintFor(r.reason, this.agent), cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
3636
|
+
if (this.adapter === a) await this.dropAdapter();
|
|
3637
|
+
} else {
|
|
3638
|
+
this.endTurn({ stopReason: "error", error: errMessage(e).slice(0, 300), cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
3639
|
+
}
|
|
3340
3640
|
} finally {
|
|
3341
3641
|
this.markTranscript();
|
|
3342
3642
|
if (watch) {
|
|
@@ -3381,6 +3681,12 @@ var Session = class {
|
|
|
3381
3681
|
}
|
|
3382
3682
|
return null;
|
|
3383
3683
|
}
|
|
3684
|
+
/** The turn is over: say so and forget it. Not while the connector is going away: the next run ends or continues it. */
|
|
3685
|
+
endTurn(data) {
|
|
3686
|
+
if (this.stopping) return;
|
|
3687
|
+
this.emit("turn_end", data);
|
|
3688
|
+
this.env.store.endTurn(this.id);
|
|
3689
|
+
}
|
|
3384
3690
|
/** What size the transcript is as this connector leaves it: after it starts the session and after every turn. */
|
|
3385
3691
|
markTranscript() {
|
|
3386
3692
|
if (this.agent.attach && this.acpId) this.transcriptMark = this.env.attach.get(this.agent.id)?.transcriptSize(this.acpId) ?? null;
|
|
@@ -3403,6 +3709,7 @@ var Session = class {
|
|
|
3403
3709
|
if (this.adapter && !this.adapter.exited) await this.enforceMode(this.targetMode);
|
|
3404
3710
|
else await this.launch("resume");
|
|
3405
3711
|
} catch (e) {
|
|
3712
|
+
if (e instanceof SessionRefusal && e.reason === "agent_auth_required") return this.rejectCommand("resume", cmdId, e.reason);
|
|
3406
3713
|
return this.refuse(e);
|
|
3407
3714
|
}
|
|
3408
3715
|
this.audit("session_resumed", { mode: this.currentMode, unattended: this.unattended });
|
|
@@ -3449,7 +3756,7 @@ var Session = class {
|
|
|
3449
3756
|
await this.dropAdapter();
|
|
3450
3757
|
this.env.store.patch(this.id, { status: "closed" });
|
|
3451
3758
|
this.audit("session_closed", { reason });
|
|
3452
|
-
this.emit("session_closed", { reason });
|
|
3759
|
+
this.emit("session_closed", withHint({ reason }, this.agent));
|
|
3453
3760
|
}
|
|
3454
3761
|
/** A session that cannot be opened (or kept safe) is closed with the reason, never left half-open. */
|
|
3455
3762
|
async refuse(e) {
|
|
@@ -3461,7 +3768,7 @@ var Session = class {
|
|
|
3461
3768
|
/** A command refused; a prompt's refusal names it by `cmd_seq` too, as its turn_end would. */
|
|
3462
3769
|
rejectCommand(type, cmdId, reason, cmdSeq = null) {
|
|
3463
3770
|
this.audit("command_rejected", { type, cmd_id: cmdId, reason });
|
|
3464
|
-
this.emit("command_rejected", { type, cmd_id: cmdId, reason, ...cmdSeq !== null ? { cmd_seq: cmdSeq } : {} });
|
|
3771
|
+
this.emit("command_rejected", withHint({ type, cmd_id: cmdId, reason, ...cmdSeq !== null ? { cmd_seq: cmdSeq } : {} }, this.agent));
|
|
3465
3772
|
}
|
|
3466
3773
|
};
|
|
3467
3774
|
function mapAdapterError(e) {
|
|
@@ -3486,7 +3793,10 @@ function limitStrings(value, max) {
|
|
|
3486
3793
|
}
|
|
3487
3794
|
|
|
3488
3795
|
// src/manager.ts
|
|
3796
|
+
import os5 from "node:os";
|
|
3489
3797
|
var MAX_ACTIVE_SESSIONS = 10;
|
|
3798
|
+
var RECOVER_NOTICE_MS = 4 * 36e5;
|
|
3799
|
+
var CONTINUE_PROMPT = "[claw-connector] The connector on this machine restarted while you were waiting for my permission, so that request was cancelled. Continue the task from where you left off, and ask again for anything that still needs my permission.";
|
|
3490
3800
|
var cmdSeqOf = (cmd) => Number.isSafeInteger(cmd.cmd_seq) ? cmd.cmd_seq : null;
|
|
3491
3801
|
var SessionManager = class {
|
|
3492
3802
|
constructor(env) {
|
|
@@ -3500,6 +3810,15 @@ var SessionManager = class {
|
|
|
3500
3810
|
agent(id) {
|
|
3501
3811
|
return typeof id === "string" ? this.env.config.agents.find((a) => a.id === id) : void 0;
|
|
3502
3812
|
}
|
|
3813
|
+
/** The folder a phone asked for, as this agent would see it: `~` is the home its adapter runs with. */
|
|
3814
|
+
where(cwd, agent) {
|
|
3815
|
+
return resolveCwd(typeof cwd === "string" ? cwd : "", agent.roots, { home: agent.env.HOME?.trim() || os5.homedir(), timeoutMs: this.env.timing.rootCheckMs });
|
|
3816
|
+
}
|
|
3817
|
+
/** A session refused before it had a process: audited, and closed with its reason and what to do about it. */
|
|
3818
|
+
refuseNew(sid, reason, agent, extra = {}) {
|
|
3819
|
+
this.env.audit.record("session_refused", { session_id: sid, ...agent ? { agent_id: agent.id } : {}, reason, ...extra });
|
|
3820
|
+
this.env.emit(sid, "session_closed", withHint({ reason }, agent));
|
|
3821
|
+
}
|
|
3503
3822
|
/**
|
|
3504
3823
|
* Commands of one session run one after another; sessions run side by side.
|
|
3505
3824
|
* `handled` is called exactly once, when the command counts as carried out: for most that is when the
|
|
@@ -3549,8 +3868,20 @@ var SessionManager = class {
|
|
|
3549
3868
|
return false;
|
|
3550
3869
|
}
|
|
3551
3870
|
const s = await this.lookup(sid);
|
|
3552
|
-
if (
|
|
3553
|
-
this.env.emit(sid, "session_closed", { reason: "unknown_session" });
|
|
3871
|
+
if (s === null) {
|
|
3872
|
+
this.env.emit(sid, "session_closed", { reason: "unknown_session", hint: "the session has already ended on that machine: start a new one" });
|
|
3873
|
+
return false;
|
|
3874
|
+
}
|
|
3875
|
+
if (!(s instanceof Session)) {
|
|
3876
|
+
if (type === "close") {
|
|
3877
|
+
this.env.store.patch(sid, { status: "closed" });
|
|
3878
|
+
this.env.audit.record("session_closed", { session_id: sid, reason: "closed" });
|
|
3879
|
+
this.env.emit(sid, "session_closed", { reason: "closed" });
|
|
3880
|
+
return false;
|
|
3881
|
+
}
|
|
3882
|
+
const seq = cmdSeqOf(cmd);
|
|
3883
|
+
this.env.audit.record("command_rejected", { type, cmd_id: cmdId, reason: s.unavailable });
|
|
3884
|
+
this.env.emit(sid, "command_rejected", withHint({ type, cmd_id: cmdId, reason: s.unavailable, ...seq !== null ? { cmd_seq: seq } : {} }, s.agent));
|
|
3554
3885
|
return false;
|
|
3555
3886
|
}
|
|
3556
3887
|
switch (type) {
|
|
@@ -3589,30 +3920,15 @@ var SessionManager = class {
|
|
|
3589
3920
|
return;
|
|
3590
3921
|
}
|
|
3591
3922
|
const agent = this.agent(cmd.agent_id);
|
|
3592
|
-
if (!agent) {
|
|
3593
|
-
this.env.audit.record("session_refused", { session_id: sid, reason: "unknown_agent", agent_id: String(cmd.agent_id).slice(0, 64) });
|
|
3594
|
-
this.env.emit(sid, "session_closed", { reason: "unknown_agent" });
|
|
3595
|
-
return;
|
|
3596
|
-
}
|
|
3923
|
+
if (!agent) return this.refuseNew(sid, "unknown_agent", null, { agent_id: String(cmd.agent_id).slice(0, 64) });
|
|
3597
3924
|
const ref = cmd.resume_ref;
|
|
3598
3925
|
if (agent.attach) return this.attachNew(cmd, agent);
|
|
3599
|
-
if (ref !== void 0 && ref !== null)
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
}
|
|
3604
|
-
const verdict = checkCwd(typeof cmd.cwd === "string" ? cmd.cwd : "", agent.roots);
|
|
3605
|
-
if (!verdict.ok) {
|
|
3606
|
-
this.env.audit.record("session_refused", { session_id: sid, agent_id: agent.id, reason: verdict.reason, cwd: String(cmd.cwd).slice(0, 300) });
|
|
3607
|
-
this.env.emit(sid, "session_closed", { reason: verdict.reason });
|
|
3608
|
-
return;
|
|
3609
|
-
}
|
|
3926
|
+
if (ref !== void 0 && ref !== null) return this.refuseNew(sid, "resume_not_supported", agent);
|
|
3927
|
+
const verdict = await this.where(cmd.cwd, agent);
|
|
3928
|
+
if (!verdict.ok) return this.refuseNew(sid, verdict.reason, agent, { cwd: String(cmd.cwd).slice(0, 300) });
|
|
3929
|
+
if (this.sessions.has(sid)) return;
|
|
3610
3930
|
const active = [...this.sessions.values()].filter((s2) => !s2.closed).length;
|
|
3611
|
-
if (active >= MAX_ACTIVE_SESSIONS)
|
|
3612
|
-
this.env.audit.record("session_refused", { session_id: sid, agent_id: agent.id, reason: "too_many_sessions" });
|
|
3613
|
-
this.env.emit(sid, "session_closed", { reason: "too_many_sessions" });
|
|
3614
|
-
return;
|
|
3615
|
-
}
|
|
3931
|
+
if (active >= MAX_ACTIVE_SESSIONS) return this.refuseNew(sid, "too_many_sessions", agent);
|
|
3616
3932
|
const s = new Session(sid, agent, verdict.real, this.env);
|
|
3617
3933
|
this.sessions.set(sid, s);
|
|
3618
3934
|
await s.create(typeof cmd.mode === "string" ? cmd.mode : null);
|
|
@@ -3624,10 +3940,7 @@ var SessionManager = class {
|
|
|
3624
3940
|
*/
|
|
3625
3941
|
async attachNew(cmd, agent) {
|
|
3626
3942
|
const sid = cmd.session_id;
|
|
3627
|
-
const refuse = (reason, extra = {}) =>
|
|
3628
|
-
this.env.audit.record("session_refused", { session_id: sid, agent_id: agent.id, reason, ...extra });
|
|
3629
|
-
this.env.emit(sid, "session_closed", { reason });
|
|
3630
|
-
};
|
|
3943
|
+
const refuse = (reason, extra = {}) => this.refuseNew(sid, reason, agent, extra);
|
|
3631
3944
|
const ref = typeof cmd.resume_ref === "string" ? cmd.resume_ref : "";
|
|
3632
3945
|
if (!ref) return refuse("resume_ref_required");
|
|
3633
3946
|
if (!SESSION_REF_RE.test(ref)) return refuse("invalid_resume_ref");
|
|
@@ -3641,7 +3954,7 @@ var SessionManager = class {
|
|
|
3641
3954
|
try {
|
|
3642
3955
|
const found = await support.resolve(want);
|
|
3643
3956
|
if (!found.ok) return refuse(found.reason);
|
|
3644
|
-
const asked =
|
|
3957
|
+
const asked = await this.where(cmd.cwd, agent);
|
|
3645
3958
|
if (!asked.ok) return refuse(asked.reason, { cwd: String(cmd.cwd).slice(0, 300) });
|
|
3646
3959
|
if (asked.real !== found.item.cwd) return refuse("cwd_mismatch", { cwd: String(cmd.cwd).slice(0, 300) });
|
|
3647
3960
|
const s = new Session(sid, agent, found.item.cwd, this.env, found.item.ref);
|
|
@@ -3658,20 +3971,25 @@ var SessionManager = class {
|
|
|
3658
3971
|
for (const s of this.sessions.values()) if (!s.closed && !s.readOnly && s.agent.id === agentId && s.acpId) out.add(s.acpId.toLowerCase());
|
|
3659
3972
|
return out;
|
|
3660
3973
|
}
|
|
3661
|
-
/**
|
|
3974
|
+
/**
|
|
3975
|
+
* A session this process knows, or one it made earlier (before a restart) and can bring back; `unavailable` when its
|
|
3976
|
+
* folder's disk does not answer now (the session is kept for later).
|
|
3977
|
+
*/
|
|
3662
3978
|
async lookup(sid) {
|
|
3663
3979
|
const have = this.sessions.get(sid);
|
|
3664
3980
|
if (have) return have;
|
|
3665
3981
|
const rec = this.env.store.get(sid);
|
|
3666
3982
|
if (!rec || rec.status !== "active") return null;
|
|
3667
3983
|
const agent = this.agent(rec.agent_id);
|
|
3668
|
-
const verdict = agent ?
|
|
3984
|
+
const verdict = agent ? await this.where(rec.cwd, agent) : null;
|
|
3669
3985
|
if (!agent || !verdict?.ok) {
|
|
3986
|
+
if (agent && verdict && !verdict.ok && verdict.reason === "root_unavailable") return { unavailable: verdict.reason, agent };
|
|
3670
3987
|
this.env.store.patch(sid, { status: "closed" });
|
|
3671
|
-
this.
|
|
3672
|
-
this.env.emit(sid, "session_closed", { reason: agent ? "cwd_outside_roots" : "unknown_agent" });
|
|
3988
|
+
this.refuseNew(sid, agent ? verdict && !verdict.ok ? verdict.reason : "cwd_outside_roots" : "unknown_agent", agent ?? null);
|
|
3673
3989
|
return null;
|
|
3674
3990
|
}
|
|
3991
|
+
const raced = this.sessions.get(sid);
|
|
3992
|
+
if (raced) return raced;
|
|
3675
3993
|
const s = new Session(sid, agent, verdict.real, this.env, rec.acp_session_id, rec.mode);
|
|
3676
3994
|
this.sessions.set(sid, s);
|
|
3677
3995
|
if (rec.read_only) s.restoreSpectate();
|
|
@@ -3691,11 +4009,61 @@ var SessionManager = class {
|
|
|
3691
4009
|
get pendingPermissions() {
|
|
3692
4010
|
return [...this.sessions.values()].reduce((n, s) => n + s.pendingCount, 0);
|
|
3693
4011
|
}
|
|
3694
|
-
/**
|
|
3695
|
-
|
|
4012
|
+
/**
|
|
4013
|
+
* After a restart (a crash, a kill, a supervisor's restart, an upgrade): every turn the previous run left running is
|
|
4014
|
+
* ended or carried on, and every session used lately is told it is back (`session_recovered`), once.
|
|
4015
|
+
*
|
|
4016
|
+
* A turn whose agent was waiting for a person's permission for a tool call, cut off no longer ago than a card lives, in a
|
|
4017
|
+
* session of an agent that always asks, goes on with {@link CONTINUE_PROMPT} under the same prompt (`cmd_id`,
|
|
4018
|
+
* `cmd_seq`): the agent asks again, so the card comes back, and the prompt's own `turn_end` still ends it. Not while
|
|
4019
|
+
* the kill switch is on (`mayContinue`). Any other turn ends with `turn_end` `error: "connector_restarted"`, naming its
|
|
4020
|
+
* prompt, so the phone stops waiting for it; whether the agent finished what it was doing is in the conversation, which
|
|
4021
|
+
* the next message resumes.
|
|
4022
|
+
*/
|
|
4023
|
+
recover(o) {
|
|
4024
|
+
const now = o.now ?? Date.now();
|
|
4025
|
+
const told = /* @__PURE__ */ new Set();
|
|
4026
|
+
for (const t of this.env.store.listTurns()) {
|
|
4027
|
+
const sid = t.session_id;
|
|
4028
|
+
const rec = this.env.store.get(sid);
|
|
4029
|
+
const active = rec?.status === "active";
|
|
4030
|
+
const agent = rec ? this.agent(rec.agent_id) : void 0;
|
|
4031
|
+
const asks = agent !== void 0 && !agent.allowUnsafeModes && rec?.mode !== null && rec?.mode !== void 0 && agent.askingModes.includes(rec.mode);
|
|
4032
|
+
const waiting = Boolean(t.waiting_permission);
|
|
4033
|
+
const recent = o.downForMs !== null && o.downForMs <= this.env.timing.permissionTtlMs;
|
|
4034
|
+
if (o.mayContinue && active && asks && waiting && recent) {
|
|
4035
|
+
this.env.audit.record("turn_continued", { session_id: sid, cmd_id: t.cmd_id, cmd_seq: t.cmd_seq, down_ms: o.downForMs });
|
|
4036
|
+
this.env.emit(sid, "session_recovered", { reason: "connector_restart", interrupted: true, continuing: true, cmd_id: t.cmd_id, cmd_seq: t.cmd_seq });
|
|
4037
|
+
void this.handle({ cmd_id: t.cmd_id ?? `continue-${t.cmd_seq ?? sid}`, type: "prompt", session_id: sid, text: CONTINUE_PROMPT, ...t.cmd_seq !== null ? { cmd_seq: t.cmd_seq } : {} }, () => {
|
|
4038
|
+
if (this.env.store.turn(sid)?.started_at === t.started_at) this.env.store.endTurn(sid);
|
|
4039
|
+
});
|
|
4040
|
+
} else {
|
|
4041
|
+
this.env.audit.record("turn_interrupted", { session_id: sid, cmd_id: t.cmd_id, cmd_seq: t.cmd_seq, waiting, down_ms: o.downForMs });
|
|
4042
|
+
if (active) this.env.emit(sid, "session_recovered", { reason: "connector_restart", interrupted: true, continuing: false, cmd_id: t.cmd_id, cmd_seq: t.cmd_seq });
|
|
4043
|
+
this.env.emit(sid, "turn_end", { stopReason: "error", error: "connector_restarted", cmd_id: t.cmd_id, cmd_seq: t.cmd_seq });
|
|
4044
|
+
this.env.store.endTurn(sid);
|
|
4045
|
+
}
|
|
4046
|
+
told.add(sid);
|
|
4047
|
+
}
|
|
4048
|
+
for (const sid of this.env.store.ids()) {
|
|
4049
|
+
const rec = this.env.store.get(sid);
|
|
4050
|
+
if (told.has(sid) || rec?.status !== "active" || !rec.used_at) continue;
|
|
4051
|
+
const used = Date.parse(rec.used_at);
|
|
4052
|
+
if (!Number.isFinite(used) || now - used > RECOVER_NOTICE_MS) continue;
|
|
4053
|
+
this.env.emit(sid, "session_recovered", { reason: "connector_restart", interrupted: false, continuing: false });
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
/**
|
|
4057
|
+
* Every adapter process, now. Sessions stay recorded and can be resumed. `restarting`: the connector is only going away
|
|
4058
|
+
* for a while (stopped, or restarted by its supervisor); what is in flight is left for the next run ({@link recover})
|
|
4059
|
+
* instead of being ended, and queued prompts are delivered again. Otherwise (the kill switch: revoked, paused) every
|
|
4060
|
+
* turn ends here and queued prompts count as handled.
|
|
4061
|
+
*/
|
|
4062
|
+
async killAll(reason, restarting = false) {
|
|
3696
4063
|
for (const s of this.sessions.values()) {
|
|
3697
4064
|
this.env.audit.record("session_killed", { session_id: s.id, reason });
|
|
3698
|
-
s.
|
|
4065
|
+
if (restarting) s.shutdown();
|
|
4066
|
+
else s.dropQueued();
|
|
3699
4067
|
s.stopTail();
|
|
3700
4068
|
}
|
|
3701
4069
|
await this.env.registry.killAll();
|
|
@@ -3705,7 +4073,7 @@ var SessionManager = class {
|
|
|
3705
4073
|
|
|
3706
4074
|
// src/outbox.ts
|
|
3707
4075
|
import fs13 from "node:fs";
|
|
3708
|
-
import
|
|
4076
|
+
import path16 from "node:path";
|
|
3709
4077
|
var DROPPABLE_TYPES = /* @__PURE__ */ new Set([
|
|
3710
4078
|
"agent_message_chunk",
|
|
3711
4079
|
"agent_thought_chunk",
|
|
@@ -3735,8 +4103,8 @@ var Outbox = class {
|
|
|
3735
4103
|
fs13.mkdirSync(this.dir, { recursive: true, mode: 448 });
|
|
3736
4104
|
this.load();
|
|
3737
4105
|
}
|
|
3738
|
-
eventsFile = (sid) =>
|
|
3739
|
-
metaFile = (sid) =>
|
|
4106
|
+
eventsFile = (sid) => path16.join(this.dir, `${safeName(sid)}.jsonl`);
|
|
4107
|
+
metaFile = (sid) => path16.join(this.dir, `${safeName(sid)}.meta.json`);
|
|
3740
4108
|
writeMeta(sid) {
|
|
3741
4109
|
const tmp = this.metaFile(sid) + ".tmp";
|
|
3742
4110
|
fs13.writeFileSync(tmp, JSON.stringify(this.meta.get(sid)), { mode: 384 });
|
|
@@ -3753,11 +4121,11 @@ var Outbox = class {
|
|
|
3753
4121
|
for (const base of bases) {
|
|
3754
4122
|
let meta = { lastSeq: 0, acked: 0 };
|
|
3755
4123
|
try {
|
|
3756
|
-
meta = JSON.parse(fs13.readFileSync(
|
|
4124
|
+
meta = JSON.parse(fs13.readFileSync(path16.join(this.dir, `${base}.meta.json`), "utf8"));
|
|
3757
4125
|
} catch {
|
|
3758
4126
|
}
|
|
3759
4127
|
let sid = null;
|
|
3760
|
-
const file =
|
|
4128
|
+
const file = path16.join(this.dir, `${base}.jsonl`);
|
|
3761
4129
|
let text = "";
|
|
3762
4130
|
try {
|
|
3763
4131
|
text = fs13.readFileSync(file, "utf8");
|
|
@@ -3778,8 +4146,8 @@ var Outbox = class {
|
|
|
3778
4146
|
if (sid === null) {
|
|
3779
4147
|
sid = base;
|
|
3780
4148
|
try {
|
|
3781
|
-
if (this.now() - fs13.statSync(
|
|
3782
|
-
for (const f of [`${base}.meta.json`, `${base}.jsonl`]) fs13.rmSync(
|
|
4149
|
+
if (this.now() - fs13.statSync(path16.join(this.dir, `${base}.meta.json`)).mtimeMs > TOMBSTONE_MS) {
|
|
4150
|
+
for (const f of [`${base}.meta.json`, `${base}.jsonl`]) fs13.rmSync(path16.join(this.dir, f), { force: true });
|
|
3783
4151
|
continue;
|
|
3784
4152
|
}
|
|
3785
4153
|
} catch {
|
|
@@ -3897,11 +4265,19 @@ var Outbox = class {
|
|
|
3897
4265
|
|
|
3898
4266
|
// src/session-store.ts
|
|
3899
4267
|
import fs14 from "node:fs";
|
|
3900
|
-
import
|
|
4268
|
+
import path17 from "node:path";
|
|
3901
4269
|
var SessionStore = class {
|
|
3902
4270
|
constructor(file) {
|
|
3903
4271
|
this.file = file;
|
|
3904
4272
|
this.pendingFile = `${file}.pending`;
|
|
4273
|
+
this.turnsFile = `${file}.turns`;
|
|
4274
|
+
try {
|
|
4275
|
+
const raw = JSON.parse(fs14.readFileSync(this.turnsFile, "utf8"));
|
|
4276
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
4277
|
+
for (const [k, t] of Object.entries(raw)) if (t && typeof t.session_id === "string") this.turns[k] = t;
|
|
4278
|
+
}
|
|
4279
|
+
} catch {
|
|
4280
|
+
}
|
|
3905
4281
|
try {
|
|
3906
4282
|
const raw = JSON.parse(fs14.readFileSync(this.pendingFile, "utf8"));
|
|
3907
4283
|
if (Array.isArray(raw)) this.pending = raw.filter((p) => p && typeof p.session_id === "string" && typeof p.action_id === "string");
|
|
@@ -3917,6 +4293,8 @@ var SessionStore = class {
|
|
|
3917
4293
|
recs = {};
|
|
3918
4294
|
pending = [];
|
|
3919
4295
|
pendingFile;
|
|
4296
|
+
turns = {};
|
|
4297
|
+
turnsFile;
|
|
3920
4298
|
get(id) {
|
|
3921
4299
|
return this.recs[id];
|
|
3922
4300
|
}
|
|
@@ -3951,50 +4329,527 @@ var SessionStore = class {
|
|
|
3951
4329
|
return out;
|
|
3952
4330
|
}
|
|
3953
4331
|
savePending() {
|
|
3954
|
-
fs14.mkdirSync(
|
|
4332
|
+
fs14.mkdirSync(path17.dirname(this.pendingFile), { recursive: true, mode: 448 });
|
|
3955
4333
|
const tmp = `${this.pendingFile}.${process.pid}.tmp`;
|
|
3956
4334
|
fs14.writeFileSync(tmp, JSON.stringify(this.pending), { mode: 384 });
|
|
3957
4335
|
fs14.renameSync(tmp, this.pendingFile);
|
|
3958
4336
|
}
|
|
4337
|
+
/**
|
|
4338
|
+
* The turn a session's agent is running. It lives in the adapter's memory, so a connector that dies (or is stopped)
|
|
4339
|
+
* mid-turn would leave the prompt open for ever: the next run reads these and ends or continues each one.
|
|
4340
|
+
*/
|
|
4341
|
+
beginTurn(t) {
|
|
4342
|
+
this.turns[t.session_id] = t;
|
|
4343
|
+
this.saveTurns();
|
|
4344
|
+
}
|
|
4345
|
+
/** The turn's agent is waiting for a person (`true`; `permission`: a tool call's permission request), or no longer (`false`). */
|
|
4346
|
+
markWaiting(sessionId, waiting, permission = false) {
|
|
4347
|
+
const t = this.turns[sessionId];
|
|
4348
|
+
if (!t) return;
|
|
4349
|
+
const next = { ...t, waiting, waiting_permission: waiting && (permission || Boolean(t.waiting_permission)) };
|
|
4350
|
+
if (Boolean(t.waiting) === next.waiting && Boolean(t.waiting_permission) === next.waiting_permission) return;
|
|
4351
|
+
this.turns[sessionId] = next;
|
|
4352
|
+
this.saveTurns();
|
|
4353
|
+
}
|
|
4354
|
+
turn(sessionId) {
|
|
4355
|
+
return this.turns[sessionId];
|
|
4356
|
+
}
|
|
4357
|
+
endTurn(sessionId) {
|
|
4358
|
+
if (!(sessionId in this.turns)) return;
|
|
4359
|
+
delete this.turns[sessionId];
|
|
4360
|
+
this.saveTurns();
|
|
4361
|
+
}
|
|
4362
|
+
/** The turns recorded as running. Each stays until it is ended ({@link endTurn}) or a new turn of its session replaces it. */
|
|
4363
|
+
listTurns() {
|
|
4364
|
+
return Object.values(this.turns).map((t) => ({ ...t }));
|
|
4365
|
+
}
|
|
4366
|
+
saveTurns() {
|
|
4367
|
+
fs14.mkdirSync(path17.dirname(this.turnsFile), { recursive: true, mode: 448 });
|
|
4368
|
+
const tmp = `${this.turnsFile}.${process.pid}.tmp`;
|
|
4369
|
+
fs14.writeFileSync(tmp, JSON.stringify(this.turns), { mode: 384 });
|
|
4370
|
+
fs14.renameSync(tmp, this.turnsFile);
|
|
4371
|
+
}
|
|
3959
4372
|
ids() {
|
|
3960
4373
|
return Object.keys(this.recs);
|
|
3961
4374
|
}
|
|
3962
4375
|
save() {
|
|
3963
|
-
fs14.mkdirSync(
|
|
4376
|
+
fs14.mkdirSync(path17.dirname(this.file), { recursive: true, mode: 448 });
|
|
3964
4377
|
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
3965
4378
|
fs14.writeFileSync(tmp, JSON.stringify(this.recs), { mode: 384 });
|
|
3966
4379
|
fs14.renameSync(tmp, this.file);
|
|
3967
4380
|
}
|
|
3968
4381
|
};
|
|
3969
4382
|
|
|
3970
|
-
// src/
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
}
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
4383
|
+
// src/doctor.ts
|
|
4384
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
4385
|
+
import fs15 from "node:fs";
|
|
4386
|
+
import fsp from "node:fs/promises";
|
|
4387
|
+
import os6 from "node:os";
|
|
4388
|
+
import path18 from "node:path";
|
|
4389
|
+
import { parse as parseToml2 } from "smol-toml";
|
|
4390
|
+
var FS_TIMEOUT_MS = 5e3;
|
|
4391
|
+
async function within2(p, ms = FS_TIMEOUT_MS) {
|
|
4392
|
+
let t;
|
|
4393
|
+
const late = new Promise((resolve) => {
|
|
4394
|
+
t = setTimeout(() => resolve(null), ms);
|
|
4395
|
+
t.unref?.();
|
|
4396
|
+
});
|
|
4397
|
+
p.catch(() => void 0);
|
|
4398
|
+
try {
|
|
4399
|
+
return await Promise.race([p, late]);
|
|
4400
|
+
} finally {
|
|
4401
|
+
clearTimeout(t);
|
|
4402
|
+
}
|
|
4403
|
+
}
|
|
4404
|
+
var errnoOf = async (p) => within2(p.then(() => "", (e) => e.code ?? "EIO"));
|
|
4405
|
+
var fileExists = async (f) => await errnoOf(fsp.access(f)) === "";
|
|
4406
|
+
async function readText(f) {
|
|
4407
|
+
return within2(fsp.readFile(f, "utf8").catch(() => null));
|
|
4408
|
+
}
|
|
4409
|
+
async function readJson(file) {
|
|
4410
|
+
const t = await readText(file);
|
|
4411
|
+
if (t === null) return null;
|
|
4412
|
+
try {
|
|
4413
|
+
const v = JSON.parse(t);
|
|
4414
|
+
return isObject(v) ? v : null;
|
|
4415
|
+
} catch {
|
|
4416
|
+
return null;
|
|
4417
|
+
}
|
|
4418
|
+
}
|
|
4419
|
+
var NODE_MIN_MAJOR = 22;
|
|
4420
|
+
var RECHECK_MS = 15 * 6e4;
|
|
4421
|
+
var KEEPALIVE_STALE_MS = 15 * 6e4;
|
|
4422
|
+
var PROBE_TIMEOUT_MS = 15e3;
|
|
4423
|
+
var PIPE_GRACE_MS = 500;
|
|
4424
|
+
var MAX_REPORTED2 = 6;
|
|
4425
|
+
function runProbe(cmd, args, env, signal, timeoutMs = PROBE_TIMEOUT_MS, input, ownGroup = true) {
|
|
4426
|
+
return new Promise((resolve) => {
|
|
4427
|
+
if (signal?.aborted) return resolve({ code: null, out: "stopped" });
|
|
4428
|
+
let out = "";
|
|
4429
|
+
let child;
|
|
4430
|
+
try {
|
|
4431
|
+
child = spawn3(cmd, args, { env, cwd: os6.homedir(), detached: ownGroup, stdio: [input === void 0 ? "ignore" : "pipe", "pipe", "pipe"], windowsHide: true });
|
|
4432
|
+
} catch (e) {
|
|
4433
|
+
return resolve({ code: null, out: errMessage(e) });
|
|
4434
|
+
}
|
|
4435
|
+
if (input !== void 0) {
|
|
4436
|
+
child.stdin?.on("error", () => void 0);
|
|
4437
|
+
child.stdin?.end(input);
|
|
4438
|
+
}
|
|
4439
|
+
const take = (b) => {
|
|
4440
|
+
if (out.length < 256 * 1024) out += b.toString("utf8");
|
|
4441
|
+
};
|
|
4442
|
+
child.stdout?.on("data", take);
|
|
4443
|
+
child.stderr?.on("data", take);
|
|
4444
|
+
const killGroup = () => {
|
|
4445
|
+
try {
|
|
4446
|
+
if (child.pid) process.kill(ownGroup ? -child.pid : child.pid, "SIGKILL");
|
|
4447
|
+
} catch {
|
|
4448
|
+
}
|
|
4449
|
+
};
|
|
4450
|
+
let settled = false;
|
|
4451
|
+
const finish = (code) => {
|
|
4452
|
+
if (settled) return;
|
|
4453
|
+
settled = true;
|
|
4454
|
+
clearTimeout(timer);
|
|
4455
|
+
signal?.removeEventListener("abort", onAbort);
|
|
4456
|
+
killGroup();
|
|
4457
|
+
child.stdout?.destroy();
|
|
4458
|
+
child.stderr?.destroy();
|
|
4459
|
+
resolve({ code, out: out.trim() });
|
|
4460
|
+
};
|
|
4461
|
+
const onAbort = () => finish(null);
|
|
4462
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
4463
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
4464
|
+
child.once("error", (e) => {
|
|
4465
|
+
out = errMessage(e);
|
|
4466
|
+
finish(null);
|
|
4467
|
+
});
|
|
4468
|
+
const ended = (st) => new Promise((res) => {
|
|
4469
|
+
if (!st || st.readableEnded) res();
|
|
4470
|
+
else {
|
|
4471
|
+
st.once("end", () => res());
|
|
4472
|
+
st.once("close", () => res());
|
|
4473
|
+
}
|
|
4474
|
+
});
|
|
4475
|
+
const drained = Promise.all([ended(child.stdout), ended(child.stderr)]);
|
|
4476
|
+
child.once("exit", (code) => {
|
|
4477
|
+
const grace = setTimeout(() => finish(code), PIPE_GRACE_MS);
|
|
4478
|
+
void drained.then(() => {
|
|
4479
|
+
clearTimeout(grace);
|
|
4480
|
+
finish(code);
|
|
4481
|
+
});
|
|
4482
|
+
});
|
|
4483
|
+
});
|
|
4484
|
+
}
|
|
4485
|
+
function defaultDeps(env, over = {}) {
|
|
4486
|
+
const signal = over.signal;
|
|
4487
|
+
const ownGroup = over.ownGroup ?? true;
|
|
4488
|
+
const run2 = (cmd, args, e) => runProbe(cmd, args, e, signal, PROBE_TIMEOUT_MS, void 0, ownGroup);
|
|
4489
|
+
return {
|
|
4490
|
+
env,
|
|
4491
|
+
platform: process.platform,
|
|
4492
|
+
home: os6.homedir(),
|
|
4493
|
+
user: env.USER || (() => {
|
|
4494
|
+
try {
|
|
4495
|
+
return os6.userInfo().username;
|
|
4496
|
+
} catch {
|
|
4497
|
+
return "";
|
|
4498
|
+
}
|
|
4499
|
+
})(),
|
|
4500
|
+
nodeVersion: process.versions.node,
|
|
4501
|
+
uid: process.getuid?.() ?? null,
|
|
4502
|
+
now: () => Date.now(),
|
|
4503
|
+
probe: run2,
|
|
4504
|
+
crontab: async () => {
|
|
4505
|
+
const r = await run2("crontab", ["-l"], env);
|
|
4506
|
+
return r.code === 0 ? r.out : null;
|
|
4507
|
+
},
|
|
4508
|
+
exec: (cmd, args) => run2(cmd, args, env),
|
|
4509
|
+
writable: async (dir) => {
|
|
4510
|
+
const code = await errnoOf(fsp.access(dir, fs15.constants.W_OK));
|
|
4511
|
+
return code === "" ? null : code ?? "ETIMEDOUT";
|
|
4512
|
+
},
|
|
4513
|
+
get: async (url) => {
|
|
4514
|
+
try {
|
|
4515
|
+
const r = await fetch(url, { signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(8e3)]) : AbortSignal.timeout(8e3), redirect: "manual" });
|
|
4516
|
+
await r.body?.cancel().catch(() => void 0);
|
|
4517
|
+
return { status: r.status };
|
|
4518
|
+
} catch (e) {
|
|
4519
|
+
const cause = e.cause;
|
|
4520
|
+
return { error: cause instanceof Error ? `${errMessage(e)} (${cause.message})` : errMessage(e) };
|
|
4521
|
+
}
|
|
4522
|
+
},
|
|
4523
|
+
...over
|
|
4524
|
+
};
|
|
4525
|
+
}
|
|
4526
|
+
async function which(cmd, pathVar) {
|
|
4527
|
+
const executable = async (p) => await within2(fsp.stat(p).then(async (st) => st.isFile() && await fsp.access(p, fs15.constants.X_OK).then(() => true, () => false), () => false)) === true;
|
|
4528
|
+
if (cmd.includes("/")) return await executable(cmd) ? cmd : null;
|
|
4529
|
+
for (const dir of (pathVar ?? "").split(path18.delimiter)) {
|
|
4530
|
+
if (!dir) continue;
|
|
4531
|
+
const p = path18.join(dir, cmd);
|
|
4532
|
+
if (await executable(p)) return p;
|
|
4533
|
+
}
|
|
4534
|
+
return null;
|
|
4535
|
+
}
|
|
4536
|
+
var agentEnv = (env, a) => ({ ...env, ...a.env });
|
|
4537
|
+
function familyOf(a) {
|
|
4538
|
+
const words = [a.command, ...a.args].map((s) => path18.basename(s));
|
|
4539
|
+
if (words.some((s) => s.includes("claude-agent-acp") || s.includes("claude-code-acp"))) return "claude";
|
|
4540
|
+
if (words.some((s) => s.includes("codex-acp"))) return "codex";
|
|
4541
|
+
if (path18.basename(a.command) === "agent" || path18.basename(a.command) === "cursor-agent") return "cursor";
|
|
4542
|
+
return "other";
|
|
4543
|
+
}
|
|
4544
|
+
var LOGIN_FIX = {
|
|
4545
|
+
claude: "claude auth login",
|
|
4546
|
+
codex: "codex login",
|
|
4547
|
+
cursor: "agent login"
|
|
4548
|
+
};
|
|
4549
|
+
function installFix(cmd, family) {
|
|
4550
|
+
const base = path18.basename(cmd);
|
|
4551
|
+
if (base === "npx" || base === "node") return "install Node.js 22 or newer (it brings npx): https://nodejs.org";
|
|
4552
|
+
if (family === "cursor") return "install the Cursor CLI: curl https://cursor.com/install -fsS | bash";
|
|
4553
|
+
return `install ${base}, or set this agent's command in agents.toml to its full path`;
|
|
4554
|
+
}
|
|
4555
|
+
var set = (env, name) => typeof env[name] === "string" && env[name] !== "";
|
|
4556
|
+
async function claudeSignedIn(env, d) {
|
|
4557
|
+
const keys = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"];
|
|
4558
|
+
if (keys.some((k) => set(env, k))) return true;
|
|
4559
|
+
const dir = env.CLAUDE_CONFIG_DIR?.trim() || path18.join(d.home, ".claude");
|
|
4560
|
+
const settings = await readJson(path18.join(dir, "settings.json"));
|
|
4561
|
+
if (settings && (typeof settings.apiKeyHelper === "string" || isObject(settings.env) && keys.some((k) => typeof settings.env[k] === "string"))) return true;
|
|
4562
|
+
const claude = await which("claude", env.PATH);
|
|
4563
|
+
if (claude) {
|
|
4564
|
+
const r = await d.probe(claude, ["auth", "status", "--json"], env);
|
|
4565
|
+
try {
|
|
4566
|
+
const v = JSON.parse(r.out.slice(r.out.indexOf("{")));
|
|
4567
|
+
if (isObject(v) && typeof v.loggedIn === "boolean") return v.loggedIn;
|
|
4568
|
+
} catch {
|
|
4569
|
+
}
|
|
4570
|
+
}
|
|
4571
|
+
if (await fileExists(path18.join(dir, ".credentials.json"))) return true;
|
|
4572
|
+
if (d.platform === "darwin") return null;
|
|
4573
|
+
return false;
|
|
4574
|
+
}
|
|
4575
|
+
async function codexAuth(env, d) {
|
|
4576
|
+
const home = env.CODEX_HOME?.trim() || path18.join(d.home, ".codex");
|
|
4577
|
+
let cfg = {};
|
|
4578
|
+
try {
|
|
4579
|
+
const t = await readText(path18.join(home, "config.toml"));
|
|
4580
|
+
if (t !== null) cfg = parseToml2(t);
|
|
4581
|
+
} catch {
|
|
4582
|
+
}
|
|
4583
|
+
const name = typeof cfg.model_provider === "string" ? cfg.model_provider : "openai";
|
|
4584
|
+
const providers = isObject(cfg.model_providers) ? cfg.model_providers : {};
|
|
4585
|
+
const provider = isObject(providers[name]) ? providers[name] : null;
|
|
4586
|
+
if (name !== "openai" && provider && provider.requires_openai_auth !== true) {
|
|
4587
|
+
const key = typeof provider.env_key === "string" ? provider.env_key : null;
|
|
4588
|
+
if (!key || set(env, key)) return { ok: true };
|
|
4589
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(key)) return { ok: false, detail: `Codex's model provider "${name.slice(0, 64)}" has an env_key that is not a variable name`, fix: `set env_key in ${path18.join(home, "config.toml")} to the NAME of the variable that holds the key` };
|
|
4590
|
+
return { ok: false, detail: `Codex uses the model provider "${name}", which reads ${key}, and ${key} is not set where the connector runs`, fix: `set ${key} for this agent: add it to the agent's env in agents.toml, or export it before starting the connector` };
|
|
4591
|
+
}
|
|
4592
|
+
if (set(env, "OPENAI_API_KEY")) return { ok: true };
|
|
4593
|
+
const codex = await which("codex", env.PATH);
|
|
4594
|
+
if (codex) {
|
|
4595
|
+
const r = await d.probe(codex, ["login", "status"], env);
|
|
4596
|
+
if (r.code === 0 && !/not logged in/i.test(r.out)) return { ok: true };
|
|
4597
|
+
if (/not logged in/i.test(r.out)) return { ok: false, detail: "Codex is not signed in on this machine", fix: LOGIN_FIX.codex };
|
|
4598
|
+
}
|
|
4599
|
+
if (await fileExists(path18.join(home, "auth.json"))) return { ok: true };
|
|
4600
|
+
if (codex || typeof cfg.cli_auth_credentials_store === "string" && cfg.cli_auth_credentials_store !== "file") return null;
|
|
4601
|
+
return { ok: false, detail: "Codex is not signed in on this machine", fix: "npm install -g @openai/codex, then: codex login" };
|
|
4602
|
+
}
|
|
4603
|
+
async function checkAll(input, d) {
|
|
4604
|
+
const ok = [];
|
|
4605
|
+
const problems = [...input.before ?? []];
|
|
4606
|
+
const add = (p) => problems.push(p);
|
|
4607
|
+
const major = Number(d.nodeVersion.split(".")[0]);
|
|
4608
|
+
if (!Number.isFinite(major) || major < NODE_MIN_MAJOR) {
|
|
4609
|
+
add({ code: "node_too_old", severity: "fail", detail: `Node.js ${d.nodeVersion} is older than ${NODE_MIN_MAJOR}`, fix: `install Node.js ${NODE_MIN_MAJOR} or newer (https://nodejs.org), then: npm install -g @primusclaw/connector` });
|
|
4610
|
+
} else ok.push(`Node.js ${d.nodeVersion}`);
|
|
4611
|
+
const servicePath = input.by === "doctor" ? await installedServicePath(input.dirs) : null;
|
|
4612
|
+
const same = (x, y) => x.length === y.length && x.every((v, i) => v === y[i]);
|
|
4613
|
+
const checked = input.agents.filter((a) => !(a.attach && input.agents.some((b) => !b.attach && b.command === a.command && same(b.args, a.args) && same(b.roots, a.roots))));
|
|
4614
|
+
const perAgent = await Promise.all(checked.map(async (a) => {
|
|
4615
|
+
const env = agentEnv(d.env, a);
|
|
4616
|
+
const family = familyOf(a);
|
|
4617
|
+
const out = { ok: [], problems: [] };
|
|
4618
|
+
const found = await which(a.command, env.PATH);
|
|
4619
|
+
if (!found) {
|
|
4620
|
+
out.problems.push({ code: "agent_cli_missing", severity: "fail", agent: a.id, detail: `${a.command} (the program agent ${a.id} runs) is not on PATH`, fix: installFix(a.command, family) });
|
|
4621
|
+
} else {
|
|
4622
|
+
out.ok.push(`${a.id}: ${a.command} is ${found}`);
|
|
4623
|
+
if (servicePath !== null && a.env.PATH === void 0 && !await which(a.command, servicePath)) {
|
|
4624
|
+
out.problems.push({ code: "service_path_missing", severity: "fail", agent: a.id, detail: `the installed service's PATH does not find ${a.command}, so the service cannot start agent ${a.id}`, fix: "claw-connector service install (run it again from this shell: it carries this shell's PATH into the service)" });
|
|
4625
|
+
}
|
|
4626
|
+
}
|
|
4627
|
+
if (family === "claude") {
|
|
4628
|
+
const signed = await claudeSignedIn(env, d);
|
|
4629
|
+
if (signed === false) out.problems.push({ code: "agent_not_signed_in", severity: "fail", agent: a.id, detail: "Claude Code is not signed in on this machine", fix: LOGIN_FIX.claude });
|
|
4630
|
+
else if (signed) out.ok.push(`${a.id}: Claude Code is signed in`);
|
|
4631
|
+
} else if (family === "codex") {
|
|
4632
|
+
const auth = await codexAuth(env, d);
|
|
4633
|
+
if (auth && !auth.ok) out.problems.push({ code: "agent_not_signed_in", severity: "fail", agent: a.id, detail: auth.detail, fix: auth.fix });
|
|
4634
|
+
else if (auth?.ok) out.ok.push(`${a.id}: Codex can sign in`);
|
|
4635
|
+
}
|
|
4636
|
+
for (const root of a.roots) {
|
|
4637
|
+
const why = await within2(Promise.resolve(d.writable(root)), d.fsTimeoutMs) ?? "ETIMEDOUT";
|
|
4638
|
+
if (why === "ETIMEDOUT") continue;
|
|
4639
|
+
if (why === null) out.ok.push(`${a.id}: ${root} is writable`);
|
|
4640
|
+
else {
|
|
4641
|
+
out.problems.push(why === "ENOENT" ? { code: "root_missing", severity: "fail", agent: a.id, detail: `${root} (a root of agent ${a.id}) does not exist`, fix: `mkdir -p ${shq(root)} (or change roots in agents.toml)` } : { code: "root_not_writable", severity: "fail", agent: a.id, detail: `${root} (a root of agent ${a.id}) is not writable by ${d.user || "this user"}${why === "EROFS" ? " (a read-only file system)" : ""}`, fix: await rootFix(root, why, d) });
|
|
4642
|
+
}
|
|
4643
|
+
}
|
|
4644
|
+
return out;
|
|
4645
|
+
}));
|
|
4646
|
+
for (const r of perAgent) {
|
|
4647
|
+
ok.push(...r.ok);
|
|
4648
|
+
problems.push(...r.problems);
|
|
4649
|
+
}
|
|
4650
|
+
for (const [upper, lower] of [["HTTPS_PROXY", "https_proxy"], ["HTTP_PROXY", "http_proxy"], ["NO_PROXY", "no_proxy"]]) {
|
|
4651
|
+
const u = d.env[upper];
|
|
4652
|
+
const l = d.env[lower];
|
|
4653
|
+
if (u !== void 0 && l !== void 0 && u !== l) {
|
|
4654
|
+
add({ code: "proxy_mismatch", severity: "warn", detail: `${upper} and ${lower} are set to different values${l === "" || u === "" ? " (one of them is empty)" : ""}; agents that read ${lower} (git, curl) take ${l === "" ? "no proxy" : "the other one"}`, fix: `export ${lower}="$${upper}" (or unset the one you do not mean)` });
|
|
4655
|
+
}
|
|
4656
|
+
}
|
|
4657
|
+
for (const name of ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]) {
|
|
4658
|
+
const v = d.env[name];
|
|
4659
|
+
if (!v) continue;
|
|
4660
|
+
try {
|
|
4661
|
+
new URL(v);
|
|
4662
|
+
} catch {
|
|
4663
|
+
add({ code: "proxy_invalid", severity: "warn", detail: `${name} is not a URL`, fix: `export ${name}=http://HOST:PORT (or unset it)` });
|
|
4664
|
+
}
|
|
4665
|
+
}
|
|
4666
|
+
await checkSupervisor(input, d, ok, add);
|
|
4667
|
+
if (input.by === "doctor" && input.server) {
|
|
4668
|
+
const r = await d.get(`${input.server.replace(/\/+$/, "")}/health`);
|
|
4669
|
+
const shown = withoutUserinfo(input.server);
|
|
4670
|
+
if ("status" in r && r.status < 500) ok.push(`Claw answers at ${shown}`);
|
|
4671
|
+
else {
|
|
4672
|
+
const why = "status" in r ? `HTTP ${r.status}` : r.error;
|
|
4673
|
+
const proxied = ["HTTPS_PROXY", "https_proxy"].some((k) => set(d.env, k));
|
|
4674
|
+
const untrusted = /certificate|self[- ]signed|UNABLE_TO_GET_ISSUER|CERT_/i.test(why);
|
|
4675
|
+
if (untrusted) {
|
|
4676
|
+
add({
|
|
4677
|
+
code: "claw_tls_untrusted",
|
|
4678
|
+
severity: "fail",
|
|
4679
|
+
detail: `Node.js does not trust the certificate of ${shown} (${why.split(input.server).join(shown)})`,
|
|
4680
|
+
fix: "export NODE_USE_SYSTEM_CA=1 (Node.js then trusts the CAs this machine trusts; or NODE_EXTRA_CA_CERTS=/path/to/ca.pem), then run the connector again; an installed service: claw-connector service install again so it carries it"
|
|
4681
|
+
});
|
|
4682
|
+
} else add({
|
|
4683
|
+
code: "claw_unreachable",
|
|
4684
|
+
severity: "fail",
|
|
4685
|
+
detail: `${shown} does not answer (${why.split(input.server).join(shown)})`,
|
|
4686
|
+
fix: proxied && !set(d.env, "NODE_USE_ENV_PROXY") ? "export NODE_USE_ENV_PROXY=1 (Node.js only uses HTTPS_PROXY when it is set; needs Node 22.21+ or 24+), then run the connector again" : `check that this machine reaches it: curl -sS ${shown.replace(/\/+$/, "")}/health`
|
|
4687
|
+
});
|
|
4688
|
+
}
|
|
4689
|
+
}
|
|
4690
|
+
return { checked_at: new Date(d.now()).toISOString(), by: input.by, ok, problems: dedupe(problems) };
|
|
4691
|
+
}
|
|
4692
|
+
function withoutUserinfo(v) {
|
|
4693
|
+
try {
|
|
4694
|
+
const u = new URL(v);
|
|
4695
|
+
return `${u.protocol}//${u.host}${u.pathname === "/" && !v.endsWith("/") ? "" : u.pathname}`;
|
|
4696
|
+
} catch {
|
|
4697
|
+
return v.replace(/\/\/[^/@]*@/, "//").replace(/[?#].*$/, "");
|
|
4698
|
+
}
|
|
4699
|
+
}
|
|
4700
|
+
function dedupe(ps) {
|
|
4701
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4702
|
+
return ps.filter((p) => {
|
|
4703
|
+
const k = `${p.code}\0${p.detail}`;
|
|
4704
|
+
if (seen.has(k)) return false;
|
|
4705
|
+
seen.add(k);
|
|
4706
|
+
return true;
|
|
4707
|
+
});
|
|
4708
|
+
}
|
|
4709
|
+
async function rootFix(root, why, d) {
|
|
4710
|
+
if (why === "EROFS") return "pick a root on a writable disk in agents.toml";
|
|
4711
|
+
const owner = await within2(fsp.stat(root).then((st) => st.uid, () => null));
|
|
4712
|
+
if (owner !== null && d.uid !== null && owner !== d.uid) return `pick a root you own in agents.toml (${root} belongs to uid ${owner})`;
|
|
4713
|
+
return `chmod u+w ${shq(root)} (or pick a root you own in agents.toml)`;
|
|
4714
|
+
}
|
|
4715
|
+
var shq = (s) => /^[A-Za-z0-9_./~:@%+-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
4716
|
+
async function installedSupervisorOf(dirs) {
|
|
4717
|
+
const v = await readJson(serviceFile(dirs));
|
|
4718
|
+
return v && typeof v.supervisor === "string" ? v.supervisor : null;
|
|
4719
|
+
}
|
|
4720
|
+
async function checkSupervisor(input, d, ok, add) {
|
|
4721
|
+
const sup = await installedSupervisorOf(input.dirs);
|
|
4722
|
+
if (sup === null) {
|
|
4723
|
+
add({ code: "no_service", severity: "warn", detail: "the connector is not installed as a service: nothing starts it again after it dies or the machine restarts", fix: "claw-connector service install" });
|
|
4724
|
+
return;
|
|
4725
|
+
}
|
|
4726
|
+
if (sup === "systemd") {
|
|
4727
|
+
if (d.platform !== "linux") return;
|
|
4728
|
+
const linger = await d.exec("loginctl", ["show-user", d.user, "-p", "Linger", "--value"]);
|
|
4729
|
+
if (linger.code === 0 && linger.out.trim() !== "yes") {
|
|
4730
|
+
add({ code: "linger_off", severity: "warn", detail: "the systemd user service stops when you log out and does not start at boot", fix: `loginctl enable-linger ${d.user}` });
|
|
4731
|
+
} else ok.push("service: systemd --user");
|
|
4732
|
+
return;
|
|
4733
|
+
}
|
|
4734
|
+
if (sup === "script") {
|
|
4735
|
+
const last = await within2(fsp.stat(keepaliveStampFile(input.dirs)).then((st) => st.mtimeMs, () => 0)) ?? 0;
|
|
4736
|
+
if (d.now() - last <= KEEPALIVE_STALE_MS) {
|
|
4737
|
+
ok.push("service: keep-alive script, called recently");
|
|
4738
|
+
return;
|
|
4739
|
+
}
|
|
4740
|
+
const script = keepaliveFile(input.dirs);
|
|
4741
|
+
const tab = await d.crontab();
|
|
4742
|
+
if (tab !== null && tab.split("\n").some((l) => !l.trim().startsWith("#") && l.includes(script))) {
|
|
4743
|
+
ok.push("service: keep-alive script in crontab");
|
|
4744
|
+
return;
|
|
4745
|
+
}
|
|
4746
|
+
add({
|
|
4747
|
+
code: "keepalive_not_scheduled",
|
|
4748
|
+
severity: "warn",
|
|
4749
|
+
detail: `nothing has run ${script} in the last ${KEEPALIVE_STALE_MS / 6e4} minutes: after a restart the connector stays offline`,
|
|
4750
|
+
fix: `add to crontab -e (or the container's startup hook): */5 * * * * sh ${script}`
|
|
4751
|
+
});
|
|
4752
|
+
return;
|
|
4753
|
+
}
|
|
4754
|
+
ok.push(`service: ${sup}`);
|
|
4755
|
+
}
|
|
4756
|
+
async function installedServicePath(dirs, home = os6.homedir(), xdgConfig) {
|
|
4757
|
+
const v = await readJson(serviceFile(dirs));
|
|
4758
|
+
if (!v) return null;
|
|
4759
|
+
if (typeof v.path === "string") return v.path;
|
|
4760
|
+
if (v.supervisor === "script") {
|
|
4761
|
+
const m = (await readText(keepaliveFile(dirs)))?.match(/^export PATH='((?:[^']|'\\'')*)'$/m);
|
|
4762
|
+
return m ? m[1].replace(/'\\''/g, "'") : null;
|
|
4763
|
+
}
|
|
4764
|
+
if (v.supervisor === "systemd") {
|
|
4765
|
+
const unit = await readText(path18.join(xdgConfig?.trim() || path18.join(home, ".config"), "systemd", "user", "claw-connector.service"));
|
|
4766
|
+
const m = unit?.match(/^Environment="PATH=((?:[^"\\]|\\.)*)"$/m);
|
|
4767
|
+
return m ? m[1].replace(/\\(.)/g, "$1").replace(/%%/g, "%") : null;
|
|
4768
|
+
}
|
|
4769
|
+
if (v.supervisor === "launchd") {
|
|
4770
|
+
const plist = await readText(path18.join(home, "Library", "LaunchAgents", "com.primusclaw.connector.plist"));
|
|
4771
|
+
const m = plist?.match(/<key>PATH<\/key><string>([^<]*)<\/string>/);
|
|
4772
|
+
return m ? m[1].replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/&/g, "&") : null;
|
|
4773
|
+
}
|
|
4774
|
+
return null;
|
|
4775
|
+
}
|
|
4776
|
+
function agentHealth(report, agentId) {
|
|
4777
|
+
const cut = (s, n) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
|
|
4778
|
+
const mine = report.problems.filter((p) => p.agent === void 0 || p.agent === agentId);
|
|
4779
|
+
mine.sort((a, b) => a.severity === b.severity ? 0 : a.severity === "fail" ? -1 : 1);
|
|
4780
|
+
return {
|
|
4781
|
+
checked_at: report.checked_at,
|
|
4782
|
+
problems: mine.slice(0, MAX_REPORTED2).map((p) => ({ code: p.code, severity: p.severity, scope: p.agent === void 0 ? "machine" : "agent", detail: cut(p.detail, 200), fix: cut(p.fix, 200) }))
|
|
4783
|
+
};
|
|
4784
|
+
}
|
|
4785
|
+
function writeReport(dirs, report) {
|
|
4786
|
+
try {
|
|
4787
|
+
fs15.mkdirSync(dirs.state, { recursive: true, mode: 448 });
|
|
4788
|
+
const file = doctorFile(dirs);
|
|
4789
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
4790
|
+
fs15.writeFileSync(tmp, JSON.stringify(report, null, 2) + "\n", { mode: 384 });
|
|
4791
|
+
fs15.renameSync(tmp, file);
|
|
4792
|
+
} catch {
|
|
4793
|
+
}
|
|
4794
|
+
}
|
|
4795
|
+
function formatReport(r) {
|
|
4796
|
+
const lines = r.ok.map((l) => `ok ${l}`);
|
|
4797
|
+
for (const p of r.problems) {
|
|
4798
|
+
lines.push(`${p.severity === "fail" ? "FAIL" : "warn"} ${p.agent ? `${p.agent}: ` : ""}${p.detail}`);
|
|
4799
|
+
lines.push(` fix: ${p.fix}`);
|
|
4800
|
+
}
|
|
4801
|
+
const fails = r.problems.filter((p) => p.severity === "fail").length;
|
|
4802
|
+
const warns = r.problems.length - fails;
|
|
4803
|
+
lines.push("");
|
|
4804
|
+
lines.push(r.problems.length === 0 ? "No problems found." : `${fails} problem${fails === 1 ? " that stops" : "s that stop"} sessions, ${warns} warning${warns === 1 ? "" : "s"}.`);
|
|
4805
|
+
return lines;
|
|
4806
|
+
}
|
|
4807
|
+
|
|
4808
|
+
// src/connector.ts
|
|
4809
|
+
function withHealth(base, health, limit = REMOTE_AGENT_LIMITS.maxCapabilitiesBytes / 2) {
|
|
4810
|
+
if (!health) return base;
|
|
4811
|
+
const size = (c) => new TextEncoder().encode(JSON.stringify(c)).length;
|
|
4812
|
+
const problems = [...health.problems];
|
|
4813
|
+
for (; ; ) {
|
|
4814
|
+
const caps = { ...base, health: { ...health, problems } };
|
|
4815
|
+
if (size(caps) <= limit) return caps;
|
|
4816
|
+
if (problems.length === 0) return base;
|
|
4817
|
+
problems.pop();
|
|
4818
|
+
}
|
|
4819
|
+
}
|
|
4820
|
+
var RESTART_RECONNECT_GAP_MS = 5e3;
|
|
4821
|
+
var READ_ONLY_HINT = `sessions of this agent start read-only: it answers and plans but changes no file. To let phone sessions write code, set approval = "never" for this agent in agents.toml on that machine and restart the connector`;
|
|
4822
|
+
var DEFAULT_TIMING = {
|
|
4823
|
+
...DEFAULT_SESSION_TIMING,
|
|
4824
|
+
heartbeatMs: REMOTE_AGENT_TIMING.heartbeatMs,
|
|
4825
|
+
watchdogMs: REMOTE_AGENT_TIMING.watchdogMs,
|
|
4826
|
+
batchMs: 150,
|
|
4827
|
+
batchMax: 20,
|
|
4828
|
+
backoffMinMs: 1e3,
|
|
4829
|
+
backoffMaxMs: 6e4,
|
|
4830
|
+
pausePollMs: 1e3,
|
|
4831
|
+
catalogMs: 12e4,
|
|
4832
|
+
sessionsWatchMs: 1e4,
|
|
4833
|
+
attachCliMs: 8e3,
|
|
4834
|
+
recentWriteMs: 1e4,
|
|
4835
|
+
authFailLimit: 4,
|
|
4836
|
+
authFailWindowMs: 12e4,
|
|
4837
|
+
healthMs: RECHECK_MS,
|
|
4838
|
+
healthMinGapMs: 5e3,
|
|
4839
|
+
limitRetryMs: 10 * 6e4
|
|
4840
|
+
};
|
|
4841
|
+
var CONNECTOR_LIMIT_HINT = "Claw already holds the most connectors one person may have, so it will not register this machine: remove machines you no longer use (in the chat: /agents, or claw-connector logout on that machine). This connector tries again every 10 minutes by itself.";
|
|
4842
|
+
var isConnectorLimit = (e) => e instanceof HttpError && e.status === 409 && e.code === "connector_limit_reached";
|
|
4843
|
+
var CommandLedger = class {
|
|
4844
|
+
inflight = /* @__PURE__ */ new Set();
|
|
4845
|
+
inflightIds = /* @__PURE__ */ new Set();
|
|
4846
|
+
ids;
|
|
4847
|
+
highest = 0;
|
|
4848
|
+
floor;
|
|
4849
|
+
constructor(cursor = 0, handled = []) {
|
|
4850
|
+
this.floor = cursor;
|
|
4851
|
+
this.highest = cursor;
|
|
4852
|
+
this.ids = [...handled];
|
|
3998
4853
|
}
|
|
3999
4854
|
/** Whether this command was already carried out (or is being carried out now). */
|
|
4000
4855
|
seen(seq, cmdId) {
|
|
@@ -4085,6 +4940,11 @@ var Connector = class {
|
|
|
4085
4940
|
lastFrame = "";
|
|
4086
4941
|
wakeSender = null;
|
|
4087
4942
|
wakeHeartbeat = null;
|
|
4943
|
+
/** The last health report (null until the first check finishes, or when none is configured). */
|
|
4944
|
+
healthReport = null;
|
|
4945
|
+
wakeHealth = null;
|
|
4946
|
+
healthAsked = false;
|
|
4947
|
+
lastHealthAsk = 0;
|
|
4088
4948
|
streamCtl = null;
|
|
4089
4949
|
ctl = new AbortController();
|
|
4090
4950
|
/** Every event leaves through here: scrubbed of credential shapes, numbered, written to disk, then posted. */
|
|
@@ -4106,16 +4966,19 @@ var Connector = class {
|
|
|
4106
4966
|
}
|
|
4107
4967
|
async descriptors() {
|
|
4108
4968
|
return Promise.all(this.d.config.agents.map(async (a) => {
|
|
4969
|
+
const readOnly = a.readOnlyModes.includes(a.askMode);
|
|
4109
4970
|
const support = this.attach.get(a.id);
|
|
4110
|
-
const base = {
|
|
4971
|
+
const base = withHealth({
|
|
4111
4972
|
ask_mode: a.askMode,
|
|
4112
4973
|
modes: a.safeModes,
|
|
4113
4974
|
roots: a.roots.slice(0, 8),
|
|
4114
4975
|
protocol: 1,
|
|
4115
4976
|
unattended: a.unattended,
|
|
4116
4977
|
unattended_allowed: a.allowUnsafeModes,
|
|
4117
|
-
...support ? { watch_live: true } : {}
|
|
4118
|
-
|
|
4978
|
+
...support ? { watch_live: true } : {},
|
|
4979
|
+
...a.readOnlyModes.length ? { read_only_modes: a.readOnlyModes } : {},
|
|
4980
|
+
...readOnly ? { edits: false, edits_hint: READ_ONLY_HINT } : {}
|
|
4981
|
+
}, this.healthReport ? agentHealth(this.healthReport, a.id) : null);
|
|
4119
4982
|
const capabilities = support ? fitResumable(base, await support.resumable()) : base;
|
|
4120
4983
|
return { agent_id: a.id, kind: a.kind, name: a.name, capabilities };
|
|
4121
4984
|
}));
|
|
@@ -4123,7 +4986,7 @@ var Connector = class {
|
|
|
4123
4986
|
// --- identity -------------------------------------------------------------------
|
|
4124
4987
|
readIdentity() {
|
|
4125
4988
|
try {
|
|
4126
|
-
const raw = JSON.parse(
|
|
4989
|
+
const raw = JSON.parse(fs16.readFileSync(identityFile(this.d.dirs), "utf8"));
|
|
4127
4990
|
if (isObject(raw) && raw.server === this.d.server && typeof raw.connector_id === "string") {
|
|
4128
4991
|
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") : [] };
|
|
4129
4992
|
}
|
|
@@ -4134,9 +4997,9 @@ var Connector = class {
|
|
|
4134
4997
|
writeIdentity() {
|
|
4135
4998
|
const st = { server: this.d.server, connector_id: this.cid, cursor: this.ledger.cursor, handled: this.ledger.recentIds, ...this.rowRevoked ? { revoked: true } : {} };
|
|
4136
4999
|
const file = identityFile(this.d.dirs);
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
5000
|
+
fs16.mkdirSync(this.d.dirs.state, { recursive: true, mode: 448 });
|
|
5001
|
+
fs16.writeFileSync(`${file}.tmp`, JSON.stringify(st), { mode: 384 });
|
|
5002
|
+
fs16.renameSync(`${file}.tmp`, file);
|
|
4140
5003
|
}
|
|
4141
5004
|
/** Use the connector id this machine already has, or register a new one. */
|
|
4142
5005
|
async identify(forceNew = false) {
|
|
@@ -4178,6 +5041,7 @@ var Connector = class {
|
|
|
4178
5041
|
* fix and ends the run with its message.
|
|
4179
5042
|
*/
|
|
4180
5043
|
async startup() {
|
|
5044
|
+
let atLimit = false;
|
|
4181
5045
|
for (let attempt = 0; !this.ctl.signal.aborted; attempt++) {
|
|
4182
5046
|
try {
|
|
4183
5047
|
await this.identify();
|
|
@@ -4188,6 +5052,17 @@ var Connector = class {
|
|
|
4188
5052
|
await this.onRevoked(e);
|
|
4189
5053
|
return "revoked";
|
|
4190
5054
|
}
|
|
5055
|
+
if (isConnectorLimit(e)) {
|
|
5056
|
+
if (!atLimit) {
|
|
5057
|
+
atLimit = true;
|
|
5058
|
+
this.d.log.error(CONNECTOR_LIMIT_HINT, { error: errMessage(e) });
|
|
5059
|
+
this.d.audit.record("connector_limit_reached", {});
|
|
5060
|
+
recordDisconnect(this.d.dirs, { reason: "connector_limit_reached", detail: CONNECTOR_LIMIT_HINT });
|
|
5061
|
+
}
|
|
5062
|
+
await sleep(this.timing.limitRetryMs, this.ctl.signal);
|
|
5063
|
+
while (!this.ctl.signal.aborted && fs16.existsSync(pauseFile(this.d.dirs))) await sleep(this.timing.pausePollMs, this.ctl.signal);
|
|
5064
|
+
continue;
|
|
5065
|
+
}
|
|
4191
5066
|
if (!isTransient(e)) throw e;
|
|
4192
5067
|
this.d.log.warn("cannot reach Claw yet; will retry", { error: errMessage(e), attempt });
|
|
4193
5068
|
await sleep(backoffMs(attempt, this.timing.backoffMinMs, this.timing.backoffMaxMs), this.ctl.signal);
|
|
@@ -4243,7 +5118,7 @@ var Connector = class {
|
|
|
4243
5118
|
}
|
|
4244
5119
|
async pauseWatcher() {
|
|
4245
5120
|
while (!this.ctl.signal.aborted) {
|
|
4246
|
-
const want =
|
|
5121
|
+
const want = fs16.existsSync(pauseFile(this.d.dirs));
|
|
4247
5122
|
if (want && !this.paused) {
|
|
4248
5123
|
this.paused = true;
|
|
4249
5124
|
this.d.audit.record("paused");
|
|
@@ -4258,6 +5133,63 @@ var Connector = class {
|
|
|
4258
5133
|
await sleep(this.timing.pausePollMs, this.ctl.signal);
|
|
4259
5134
|
}
|
|
4260
5135
|
}
|
|
5136
|
+
/**
|
|
5137
|
+
* Check the machine at start, every `healthMs`, and when asked (`recheckHealth`: `claw-connector doctor` signals a running
|
|
5138
|
+
* connector after it ran). A changed result is sent with an early heartbeat. A check that throws keeps the last report, and
|
|
5139
|
+
* a stop never waits for one: it must never take the connection down. Nothing here touches the file system itself (the
|
|
5140
|
+
* checks run in a child process: see commands.ts `runHealth`), so a stalled disk cannot reach this process.
|
|
5141
|
+
*/
|
|
5142
|
+
async healthWatcher() {
|
|
5143
|
+
const check = this.d.health;
|
|
5144
|
+
if (!check) return;
|
|
5145
|
+
while (!this.ctl.signal.aborted) {
|
|
5146
|
+
this.healthAsked = false;
|
|
5147
|
+
try {
|
|
5148
|
+
const before = this.healthReport ? JSON.stringify(this.healthReport.problems) : null;
|
|
5149
|
+
let onAbort = () => {
|
|
5150
|
+
};
|
|
5151
|
+
const stopped = new Promise((resolve) => {
|
|
5152
|
+
onAbort = () => resolve(null);
|
|
5153
|
+
if (this.ctl.signal.aborted) resolve(null);
|
|
5154
|
+
else this.ctl.signal.addEventListener("abort", onAbort, { once: true });
|
|
5155
|
+
});
|
|
5156
|
+
let report;
|
|
5157
|
+
try {
|
|
5158
|
+
report = await Promise.race([check(this.ctl.signal), stopped]);
|
|
5159
|
+
} finally {
|
|
5160
|
+
this.ctl.signal.removeEventListener("abort", onAbort);
|
|
5161
|
+
}
|
|
5162
|
+
if (report === null) break;
|
|
5163
|
+
this.healthReport = report;
|
|
5164
|
+
if (JSON.stringify(this.healthReport.problems) !== before) {
|
|
5165
|
+
this.d.log.info("health", { problems: this.healthReport.problems.map((p) => `${p.agent ?? "machine"}:${p.code}`) });
|
|
5166
|
+
this.wakeHeartbeat?.();
|
|
5167
|
+
}
|
|
5168
|
+
} catch (e) {
|
|
5169
|
+
this.d.log.warn("health check failed", { error: errMessage(e) });
|
|
5170
|
+
}
|
|
5171
|
+
if (this.healthAsked) continue;
|
|
5172
|
+
await new Promise((resolve) => {
|
|
5173
|
+
const done = () => {
|
|
5174
|
+
clearTimeout(t);
|
|
5175
|
+
this.wakeHealth = null;
|
|
5176
|
+
this.ctl.signal.removeEventListener("abort", done);
|
|
5177
|
+
resolve();
|
|
5178
|
+
};
|
|
5179
|
+
const t = setTimeout(done, this.timing.healthMs);
|
|
5180
|
+
this.wakeHealth = done;
|
|
5181
|
+
this.ctl.signal.addEventListener("abort", done, { once: true });
|
|
5182
|
+
});
|
|
5183
|
+
}
|
|
5184
|
+
}
|
|
5185
|
+
/** Check the machine again now (a `doctor` run asks; at most once per `healthMinGapMs`, so a burst of asks is one check). */
|
|
5186
|
+
recheckHealth() {
|
|
5187
|
+
const now = Date.now();
|
|
5188
|
+
if (now - this.lastHealthAsk < this.timing.healthMinGapMs) return;
|
|
5189
|
+
this.lastHealthAsk = now;
|
|
5190
|
+
this.healthAsked = true;
|
|
5191
|
+
this.wakeHealth?.();
|
|
5192
|
+
}
|
|
4261
5193
|
// --- the loops -----------------------------------------------------------------------
|
|
4262
5194
|
/** Post events in batches, one request at a time and in order, until everything is acknowledged. */
|
|
4263
5195
|
async sender() {
|
|
@@ -4438,51 +5370,57 @@ var Connector = class {
|
|
|
4438
5370
|
}
|
|
4439
5371
|
/** Run until stopped or revoked. Always leaves no adapter process behind. */
|
|
4440
5372
|
async run() {
|
|
4441
|
-
|
|
5373
|
+
fs16.mkdirSync(this.d.dirs.state, { recursive: true, mode: 448 });
|
|
4442
5374
|
await this.registry.killAll();
|
|
4443
5375
|
for (const p of this.store.takePending()) {
|
|
4444
5376
|
this.d.audit.record("permission_orphaned", { session_id: p.session_id, action_id: p.action_id });
|
|
4445
5377
|
this.emit(p.session_id, "permission_resolved", { actionId: p.action_id, optionId: null, outcome: "cancelled", by: "connector_restart" });
|
|
4446
5378
|
}
|
|
5379
|
+
let downForMs = null;
|
|
5380
|
+
try {
|
|
5381
|
+
downForMs = Math.max(0, Date.now() - fs16.statSync(identityFile(this.d.dirs)).mtimeMs);
|
|
5382
|
+
} catch {
|
|
5383
|
+
}
|
|
4447
5384
|
const pidf = pidFile(this.d.dirs);
|
|
4448
5385
|
let leftover = false;
|
|
4449
5386
|
try {
|
|
4450
|
-
|
|
5387
|
+
fs16.readFileSync(pidf);
|
|
4451
5388
|
leftover = true;
|
|
4452
5389
|
} catch {
|
|
4453
5390
|
}
|
|
4454
5391
|
if (leftover) {
|
|
4455
5392
|
let last;
|
|
4456
5393
|
try {
|
|
4457
|
-
last =
|
|
5394
|
+
last = fs16.statSync(identityFile(this.d.dirs)).mtime.toISOString();
|
|
4458
5395
|
} catch {
|
|
4459
5396
|
}
|
|
4460
5397
|
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 } : {} });
|
|
4461
5398
|
}
|
|
4462
|
-
|
|
4463
|
-
this.paused =
|
|
5399
|
+
fs16.writeFileSync(pidf, JSON.stringify({ pid: process.pid, start: processStart(process.pid) ?? "" }), { mode: 384 });
|
|
5400
|
+
this.paused = fs16.existsSync(pauseFile(this.d.dirs));
|
|
4464
5401
|
let result = "stopped";
|
|
4465
5402
|
let failure = "";
|
|
4466
5403
|
try {
|
|
4467
5404
|
const up = await this.startup();
|
|
4468
5405
|
if (up !== "ok") return result = up;
|
|
4469
5406
|
await this.manager.restoreReadOnly();
|
|
5407
|
+
this.manager.recover({ downForMs, mayContinue: !this.paused && !fs16.existsSync(pauseFile(this.d.dirs)) });
|
|
4470
5408
|
this.d.audit.record("connector_online", {
|
|
4471
5409
|
connector_id: this.cid,
|
|
4472
5410
|
agents: this.d.config.agents.map((a) => a.id),
|
|
4473
5411
|
paused: this.paused,
|
|
4474
5412
|
unattended_agents: this.d.config.agents.filter((a) => a.unattended).map((a) => a.id)
|
|
4475
5413
|
});
|
|
4476
|
-
await Promise.all([this.sender(), this.heartbeater(), this.streamLoop(), this.pauseWatcher(), this.sessionsWatcher()]);
|
|
5414
|
+
await Promise.all([this.sender(), this.heartbeater(), this.streamLoop(), this.pauseWatcher(), this.sessionsWatcher(), this.healthWatcher()]);
|
|
4477
5415
|
result = this.revoked ? "revoked" : this.authFailed ? "auth_failed" : "stopped";
|
|
4478
5416
|
} catch (e) {
|
|
4479
5417
|
failure = errMessage(e);
|
|
4480
5418
|
throw e;
|
|
4481
5419
|
} finally {
|
|
4482
|
-
await this.manager.killAll(this.revoked ? "revoked" : "stopped").catch(() => void 0);
|
|
5420
|
+
await this.manager.killAll(this.revoked ? "revoked" : "stopped", !this.revoked && !this.authFailed).catch(() => void 0);
|
|
4483
5421
|
const reason = this.revoked ? "revoked" : this.authFailed ? "auth_failed" : failure ? "failed" : this.paused ? "stopped_while_paused" : result;
|
|
4484
5422
|
recordDisconnect(this.d.dirs, { reason, detail: this.authWhy || failure || void 0 });
|
|
4485
|
-
|
|
5423
|
+
fs16.rmSync(pidf, { force: true });
|
|
4486
5424
|
}
|
|
4487
5425
|
return result;
|
|
4488
5426
|
}
|
|
@@ -4498,12 +5436,13 @@ var Connector = class {
|
|
|
4498
5436
|
};
|
|
4499
5437
|
|
|
4500
5438
|
// src/service.ts
|
|
4501
|
-
import { spawn as
|
|
4502
|
-
import
|
|
4503
|
-
import
|
|
4504
|
-
import
|
|
5439
|
+
import { spawn as spawn4, spawnSync } from "node:child_process";
|
|
5440
|
+
import fs17 from "node:fs";
|
|
5441
|
+
import os7 from "node:os";
|
|
5442
|
+
import path19 from "node:path";
|
|
4505
5443
|
import { fileURLToPath } from "node:url";
|
|
4506
5444
|
import { parseArgs } from "node:util";
|
|
5445
|
+
import { parse as parseToml3 } from "smol-toml";
|
|
4507
5446
|
var SUPERVISORS = ["systemd", "launchd", "script"];
|
|
4508
5447
|
var UNIT = "claw-connector.service";
|
|
4509
5448
|
var LABEL = "com.primusclaw.connector";
|
|
@@ -4512,11 +5451,14 @@ var NEEDS_PERSON = 78;
|
|
|
4512
5451
|
var CARRIED_ENV = [
|
|
4513
5452
|
"PATH",
|
|
4514
5453
|
"CLAW_CONNECTOR_HOME",
|
|
5454
|
+
"CLAW_CONNECTOR_CONFIG_DIR",
|
|
4515
5455
|
"XDG_CONFIG_HOME",
|
|
4516
5456
|
"XDG_STATE_HOME",
|
|
4517
5457
|
"CLAUDE_CONFIG_DIR",
|
|
4518
5458
|
"CODEX_HOME",
|
|
4519
5459
|
"NODE_EXTRA_CA_CERTS",
|
|
5460
|
+
"NODE_USE_SYSTEM_CA",
|
|
5461
|
+
"NODE_USE_ENV_PROXY",
|
|
4520
5462
|
"SSL_CERT_FILE",
|
|
4521
5463
|
"HTTPS_PROXY",
|
|
4522
5464
|
"https_proxy",
|
|
@@ -4524,27 +5466,97 @@ var CARRIED_ENV = [
|
|
|
4524
5466
|
"http_proxy",
|
|
4525
5467
|
"NO_PROXY",
|
|
4526
5468
|
"no_proxy",
|
|
4527
|
-
"LANG"
|
|
5469
|
+
"LANG",
|
|
5470
|
+
"IS_SANDBOX",
|
|
5471
|
+
"npm_config_registry",
|
|
5472
|
+
"NPM_CONFIG_REGISTRY",
|
|
5473
|
+
"npm_config_proxy",
|
|
5474
|
+
"npm_config_https_proxy",
|
|
5475
|
+
"AWS_REGION",
|
|
5476
|
+
"AWS_PROFILE",
|
|
5477
|
+
"CLOUD_ML_REGION",
|
|
5478
|
+
"GOOGLE_APPLICATION_CREDENTIALS"
|
|
5479
|
+
];
|
|
5480
|
+
var AGENT_SETTING = /^(?:ANTHROPIC|CLAUDE_CODE|OPENAI|CODEX|CURSOR)_/;
|
|
5481
|
+
var TERMINAL_SESSION = /^(?:CLAUDE_CODE_(?:ENTRYPOINT|SSE_PORT|SESSION|CHILD_SESSION|MESSAGING|EXECPATH|IDE|GIT_BASH)|CODEX_(?:SANDBOX|THREAD|SESSION|MANAGED|INTERNAL|CI))/;
|
|
5482
|
+
var SECRET_NAME = /KEY|TOKEN(?!S)|SECRET|PASSWORD|CREDENTIAL/i;
|
|
5483
|
+
var AGENT_SECRETS = [
|
|
5484
|
+
"ANTHROPIC_API_KEY",
|
|
5485
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
5486
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
5487
|
+
"ANTHROPIC_CUSTOM_HEADERS",
|
|
5488
|
+
"OPENAI_API_KEY",
|
|
5489
|
+
"CODEX_API_KEY",
|
|
5490
|
+
"CURSOR_API_KEY",
|
|
5491
|
+
"AWS_ACCESS_KEY_ID",
|
|
5492
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
5493
|
+
"AWS_SESSION_TOKEN"
|
|
4528
5494
|
];
|
|
4529
|
-
var
|
|
5495
|
+
var NEVER_CARRIED = /* @__PURE__ */ new Set(["CLAW_CONNECTOR_KEY"]);
|
|
5496
|
+
function accountName(env, uid, userInfo = os7.userInfo) {
|
|
5497
|
+
if (env.USER) return env.USER;
|
|
5498
|
+
if (env.LOGNAME) return env.LOGNAME;
|
|
5499
|
+
try {
|
|
5500
|
+
return userInfo().username;
|
|
5501
|
+
} catch {
|
|
5502
|
+
return String(uid);
|
|
5503
|
+
}
|
|
5504
|
+
}
|
|
5505
|
+
function cronDaemonRunning(proc = "/proc") {
|
|
5506
|
+
let self;
|
|
5507
|
+
let pids;
|
|
5508
|
+
try {
|
|
5509
|
+
self = fs17.readlinkSync(`${proc}/self/ns/mnt`);
|
|
5510
|
+
pids = fs17.readdirSync(proc).filter((n) => /^\d+$/.test(n));
|
|
5511
|
+
} catch {
|
|
5512
|
+
return false;
|
|
5513
|
+
}
|
|
5514
|
+
for (const pid of pids) {
|
|
5515
|
+
try {
|
|
5516
|
+
if (!/^(?:cron|crond|busybox)$/.test(fs17.readFileSync(`${proc}/${pid}/comm`, "utf8").trim())) continue;
|
|
5517
|
+
if (!/cron/.test(fs17.readFileSync(`${proc}/${pid}/cmdline`, "utf8"))) continue;
|
|
5518
|
+
if (fs17.readlinkSync(`${proc}/${pid}/ns/mnt`) === self) return true;
|
|
5519
|
+
} catch {
|
|
5520
|
+
}
|
|
5521
|
+
}
|
|
5522
|
+
return false;
|
|
5523
|
+
}
|
|
5524
|
+
function systemCrontab() {
|
|
5525
|
+
return {
|
|
5526
|
+
readCrontab: () => {
|
|
5527
|
+
if (!cronDaemonRunning()) return null;
|
|
5528
|
+
const r = spawnSync("crontab", ["-l"], { encoding: "utf8", timeout: 1e4, stdio: ["ignore", "pipe", "pipe"] });
|
|
5529
|
+
if (r.error) return null;
|
|
5530
|
+
return r.status === 0 ? r.stdout : /no crontab/i.test(r.stderr ?? "") ? "" : null;
|
|
5531
|
+
},
|
|
5532
|
+
writeCrontab: (text) => {
|
|
5533
|
+
const r = spawnSync("crontab", ["-"], { input: text, encoding: "utf8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] });
|
|
5534
|
+
return !r.error && r.status === 0;
|
|
5535
|
+
}
|
|
5536
|
+
};
|
|
5537
|
+
}
|
|
4530
5538
|
function deps(ctx) {
|
|
4531
5539
|
const d = ctx.service ?? {};
|
|
5540
|
+
const uid = d.uid ?? (process.getuid?.() ?? 0);
|
|
5541
|
+
const cron = d.readCrontab && d.writeCrontab ? null : systemCrontab();
|
|
4532
5542
|
return {
|
|
4533
5543
|
platform: d.platform ?? process.platform,
|
|
4534
|
-
home: d.home ??
|
|
4535
|
-
uid
|
|
4536
|
-
user: d.user ?? (ctx.env
|
|
5544
|
+
home: d.home ?? os7.homedir(),
|
|
5545
|
+
uid,
|
|
5546
|
+
user: d.user ?? accountName(ctx.env, uid),
|
|
5547
|
+
readCrontab: d.readCrontab ?? cron.readCrontab,
|
|
5548
|
+
writeCrontab: d.writeCrontab ?? cron.writeCrontab,
|
|
4537
5549
|
exec: d.exec ?? ((cmd, args) => {
|
|
4538
5550
|
const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
|
|
4539
5551
|
return { code: r.error ? null : r.status, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
|
|
4540
5552
|
}),
|
|
4541
5553
|
node: d.node ?? ctx.node ?? process.execPath,
|
|
4542
5554
|
cli: d.cli ?? fileURLToPath(import.meta.url),
|
|
4543
|
-
systemdBooted: d.systemdBooted ?? (() =>
|
|
5555
|
+
systemdBooted: d.systemdBooted ?? (() => fs17.existsSync("/run/systemd/system"))
|
|
4544
5556
|
};
|
|
4545
5557
|
}
|
|
4546
|
-
var systemdUnitPath = (ctx, d) =>
|
|
4547
|
-
var launchdPlistPath = (d) =>
|
|
5558
|
+
var systemdUnitPath = (ctx, d) => path19.join(ctx.env.XDG_CONFIG_HOME?.trim() || path19.join(d.home, ".config"), "systemd", "user", UNIT);
|
|
5559
|
+
var launchdPlistPath = (d) => path19.join(d.home, "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
4548
5560
|
function detectSupervisor(d) {
|
|
4549
5561
|
if (d.platform === "darwin") return "launchd";
|
|
4550
5562
|
if (!d.systemdBooted()) return "script";
|
|
@@ -4554,29 +5566,71 @@ function detectSupervisor(d) {
|
|
|
4554
5566
|
var hasUserinfo = (v) => {
|
|
4555
5567
|
try {
|
|
4556
5568
|
const u = new URL(v);
|
|
4557
|
-
|
|
5569
|
+
if (u.username !== "" || u.password !== "") return true;
|
|
4558
5570
|
} catch {
|
|
4559
|
-
return /\/\/[^/]*@/.test(v);
|
|
4560
5571
|
}
|
|
5572
|
+
return /\/\/[^/\s]*@/.test(v);
|
|
4561
5573
|
};
|
|
4562
|
-
function
|
|
5574
|
+
function codexEnvKeys(env, home) {
|
|
5575
|
+
const dir = env.CODEX_HOME?.trim() || path19.join(home, ".codex");
|
|
5576
|
+
try {
|
|
5577
|
+
const doc = parseToml3(fs17.readFileSync(path19.join(dir, "config.toml"), "utf8"));
|
|
5578
|
+
const providers = isObject(doc.model_providers) ? doc.model_providers : {};
|
|
5579
|
+
return Object.values(providers).flatMap((p) => isObject(p) && typeof p.env_key === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(p.env_key) ? [p.env_key] : []);
|
|
5580
|
+
} catch {
|
|
5581
|
+
return [];
|
|
5582
|
+
}
|
|
5583
|
+
}
|
|
5584
|
+
function carriedEnv(env, extra = [], secretNames = AGENT_SECRETS) {
|
|
4563
5585
|
const vars = [];
|
|
5586
|
+
const secrets = [];
|
|
4564
5587
|
const refused = [];
|
|
4565
5588
|
const withheld = [];
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
5589
|
+
const settings = Object.keys(env).filter((k) => AGENT_SETTING.test(k) && !TERMINAL_SESSION.test(k)).sort();
|
|
5590
|
+
for (const name of /* @__PURE__ */ new Set([...CARRIED_ENV, ...settings, ...secretNames, ...extra])) {
|
|
5591
|
+
if (NEVER_CARRIED.has(name)) {
|
|
5592
|
+
if (extra.includes(name)) refused.push(name);
|
|
4569
5593
|
continue;
|
|
4570
5594
|
}
|
|
4571
5595
|
const v = env[name];
|
|
4572
5596
|
if (v === void 0 || v === "") continue;
|
|
4573
|
-
|
|
5597
|
+
const proxy = /proxy$/i.test(name);
|
|
5598
|
+
const secret = !CARRIED_ENV.includes(name) && (SECRET_NAME.test(name) || secretNames.includes(name)) || !proxy && hasUserinfo(v);
|
|
5599
|
+
if (secret) {
|
|
5600
|
+
secrets.push([name, v]);
|
|
5601
|
+
continue;
|
|
5602
|
+
}
|
|
5603
|
+
if (proxy && hasUserinfo(v) && !extra.includes(name)) {
|
|
5604
|
+
withheld.push(name);
|
|
5605
|
+
continue;
|
|
5606
|
+
}
|
|
5607
|
+
if (/[\r\n]/.test(v)) {
|
|
4574
5608
|
withheld.push(name);
|
|
4575
5609
|
continue;
|
|
4576
5610
|
}
|
|
4577
5611
|
vars.push([name, v]);
|
|
4578
5612
|
}
|
|
4579
|
-
return { vars, refused, withheld };
|
|
5613
|
+
return { vars, secrets, refused, withheld };
|
|
5614
|
+
}
|
|
5615
|
+
function applyServiceEnv(agents, kept, env) {
|
|
5616
|
+
const used = /* @__PURE__ */ new Set();
|
|
5617
|
+
for (const a of agents) {
|
|
5618
|
+
for (const [k, v] of Object.entries(kept)) {
|
|
5619
|
+
if (env[k] !== void 0 || Object.hasOwn(a.env, k)) continue;
|
|
5620
|
+
a.env = { ...a.env, [k]: v };
|
|
5621
|
+
used.add(k);
|
|
5622
|
+
}
|
|
5623
|
+
}
|
|
5624
|
+
return [...used].sort();
|
|
5625
|
+
}
|
|
5626
|
+
function loadServiceEnv(file) {
|
|
5627
|
+
try {
|
|
5628
|
+
const raw = JSON.parse(fs17.readFileSync(file, "utf8"));
|
|
5629
|
+
if (!isObject(raw)) return {};
|
|
5630
|
+
return Object.fromEntries(Object.entries(raw).filter((e) => typeof e[1] === "string" && !NEVER_CARRIED.has(e[0])));
|
|
5631
|
+
} catch {
|
|
5632
|
+
return {};
|
|
5633
|
+
}
|
|
4580
5634
|
}
|
|
4581
5635
|
var sdEnv = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
|
|
4582
5636
|
var sdQuote = (s) => sdEnv(s).replace(/\$/g, "$$$$");
|
|
@@ -4645,7 +5699,7 @@ exec ${sh(d.node)} ${sh(d.cli)} service ensure
|
|
|
4645
5699
|
}
|
|
4646
5700
|
function readPidRec(file) {
|
|
4647
5701
|
try {
|
|
4648
|
-
const raw = JSON.parse(
|
|
5702
|
+
const raw = JSON.parse(fs17.readFileSync(file, "utf8"));
|
|
4649
5703
|
if (isObject(raw) && typeof raw.pid === "number" && typeof raw.start === "string" && raw.start !== "") return { pid: raw.pid, start: raw.start };
|
|
4650
5704
|
} catch {
|
|
4651
5705
|
}
|
|
@@ -4665,8 +5719,8 @@ async function supervise(ctx, o = {}) {
|
|
|
4665
5719
|
ctx.err(`a supervisor is already running (pid ${other})`);
|
|
4666
5720
|
return 1;
|
|
4667
5721
|
}
|
|
4668
|
-
|
|
4669
|
-
|
|
5722
|
+
fs17.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
5723
|
+
fs17.writeFileSync(pidf, JSON.stringify({ pid: process.pid, start: processStart(process.pid) ?? "" }), { mode: 384 });
|
|
4670
5724
|
let stopping2 = false;
|
|
4671
5725
|
let child = null;
|
|
4672
5726
|
const wake = new AbortController();
|
|
@@ -4681,7 +5735,7 @@ async function supervise(ctx, o = {}) {
|
|
|
4681
5735
|
o.signal?.addEventListener("abort", () => onSig("SIGTERM"), { once: true });
|
|
4682
5736
|
try {
|
|
4683
5737
|
for (let round = 0; !stopping2; round++) {
|
|
4684
|
-
child =
|
|
5738
|
+
child = spawn4(cmd, args, { stdio: ["ignore", "inherit", "inherit"], env: ctx.env });
|
|
4685
5739
|
const c = child;
|
|
4686
5740
|
const { code, signal } = await new Promise((resolve) => {
|
|
4687
5741
|
c.once("exit", (code2, signal2) => resolve({ code: code2, signal: signal2 }));
|
|
@@ -4710,13 +5764,14 @@ async function supervise(ctx, o = {}) {
|
|
|
4710
5764
|
} finally {
|
|
4711
5765
|
for (const [s, h] of handlers) process.off(s, h);
|
|
4712
5766
|
const rec = readPidRec(pidf);
|
|
4713
|
-
if (rec?.pid === process.pid)
|
|
5767
|
+
if (rec?.pid === process.pid) fs17.rmSync(pidf, { force: true });
|
|
4714
5768
|
}
|
|
4715
5769
|
}
|
|
4716
5770
|
async function cmdSupervise(_args, ctx) {
|
|
4717
5771
|
return supervise(ctx);
|
|
4718
5772
|
}
|
|
4719
|
-
async function ensure(ctx, d, h) {
|
|
5773
|
+
async function ensure(ctx, d, h, o = {}) {
|
|
5774
|
+
if (!o.byInstall) stampKeepalive(ctx);
|
|
4720
5775
|
const sup = supervisorPid(ctx);
|
|
4721
5776
|
if (sup !== null) {
|
|
4722
5777
|
ctx.out(`supervisor running (pid ${sup})`);
|
|
@@ -4732,17 +5787,24 @@ async function ensure(ctx, d, h) {
|
|
|
4732
5787
|
ctx.out(`the connector is running (pid ${run2}) without the supervisor; leaving it`);
|
|
4733
5788
|
return 0;
|
|
4734
5789
|
}
|
|
4735
|
-
|
|
4736
|
-
const log =
|
|
5790
|
+
fs17.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
5791
|
+
const log = fs17.openSync(runLogFile(ctx.dirs), "a", 384);
|
|
4737
5792
|
try {
|
|
4738
|
-
const p =
|
|
5793
|
+
const p = spawn4(d.node, [d.cli, "supervise"], { detached: true, cwd: d.home, stdio: ["ignore", log, log], env: ctx.env });
|
|
4739
5794
|
p.unref();
|
|
4740
5795
|
ctx.out(`started the supervisor (pid ${p.pid}); log: ${runLogFile(ctx.dirs)}`);
|
|
4741
5796
|
} finally {
|
|
4742
|
-
|
|
5797
|
+
fs17.closeSync(log);
|
|
4743
5798
|
}
|
|
4744
5799
|
return 0;
|
|
4745
5800
|
}
|
|
5801
|
+
function stampKeepalive(ctx) {
|
|
5802
|
+
try {
|
|
5803
|
+
fs17.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
5804
|
+
fs17.writeFileSync(keepaliveStampFile(ctx.dirs), (/* @__PURE__ */ new Date()).toISOString() + "\n", { mode: 384 });
|
|
5805
|
+
} catch {
|
|
5806
|
+
}
|
|
5807
|
+
}
|
|
4746
5808
|
async function cmdService(args, ctx, h) {
|
|
4747
5809
|
const [sub, ...rest] = args;
|
|
4748
5810
|
const usage = "usage: claw-connector service install|uninstall|status [--supervisor systemd|launchd|script] [--env NAME ...]";
|
|
@@ -4769,7 +5831,7 @@ async function cmdService(args, ctx, h) {
|
|
|
4769
5831
|
}
|
|
4770
5832
|
function installedSupervisor(ctx) {
|
|
4771
5833
|
try {
|
|
4772
|
-
const raw = JSON.parse(
|
|
5834
|
+
const raw = JSON.parse(fs17.readFileSync(serviceFile(ctx.dirs), "utf8"));
|
|
4773
5835
|
if (isObject(raw) && typeof raw.supervisor === "string" && SUPERVISORS.includes(raw.supervisor)) return raw.supervisor;
|
|
4774
5836
|
} catch {
|
|
4775
5837
|
}
|
|
@@ -4789,11 +5851,67 @@ function startAfterLogin(ctx, h) {
|
|
|
4789
5851
|
}
|
|
4790
5852
|
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`);
|
|
4791
5853
|
}
|
|
5854
|
+
var UnsafeDirError = class extends Error {
|
|
5855
|
+
};
|
|
5856
|
+
function writeSecretFile(file, body) {
|
|
5857
|
+
const dir = path19.dirname(file);
|
|
5858
|
+
fs17.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
5859
|
+
let dfd;
|
|
5860
|
+
try {
|
|
5861
|
+
dfd = fs17.openSync(dir, fs17.constants.O_RDONLY | (fs17.constants.O_DIRECTORY ?? 0) | (fs17.constants.O_NOFOLLOW ?? 0));
|
|
5862
|
+
} catch (e) {
|
|
5863
|
+
throw new UnsafeDirError(`${dir} cannot be opened as a directory of its own (${e.code ?? errMessage(e)}; a link is not followed): the agents' sign-in variables are not written there (set CLAW_CONNECTOR_CONFIG_DIR to a directory of your own)`);
|
|
5864
|
+
}
|
|
5865
|
+
try {
|
|
5866
|
+
const st = fs17.fstatSync(dfd);
|
|
5867
|
+
const uid = process.getuid?.();
|
|
5868
|
+
if (!st.isDirectory() || uid !== void 0 && st.uid !== uid) {
|
|
5869
|
+
throw new UnsafeDirError(`${dir} is ${!st.isDirectory() ? "not a directory" : "owned by another account"}: the agents' sign-in variables are not written there (set CLAW_CONNECTOR_CONFIG_DIR to a directory of your own)`);
|
|
5870
|
+
}
|
|
5871
|
+
if ((st.mode & 63) !== 0) fs17.fchmodSync(dfd, 448);
|
|
5872
|
+
let anchored;
|
|
5873
|
+
if (fs17.existsSync(`/proc/self/fd/${dfd}`)) anchored = `/proc/self/fd/${dfd}`;
|
|
5874
|
+
else {
|
|
5875
|
+
const real = fs17.realpathSync(dir);
|
|
5876
|
+
const rs = fs17.statSync(real);
|
|
5877
|
+
if (rs.dev !== st.dev || rs.ino !== st.ino) throw new UnsafeDirError(`${dir} changed while it was being checked; the agents' sign-in variables are not written there`);
|
|
5878
|
+
const weak = renamableAncestor(real);
|
|
5879
|
+
if (weak) throw new UnsafeDirError(`${weak} can be changed by other accounts, so ${dir} could be swapped while the agents' sign-in variables are written; they are not written there (set CLAW_CONNECTOR_CONFIG_DIR to a directory of your own)`);
|
|
5880
|
+
anchored = real;
|
|
5881
|
+
}
|
|
5882
|
+
const tmp = path19.join(anchored, `${path19.basename(file)}.${process.pid}.${Date.now()}.tmp`);
|
|
5883
|
+
const fd = fs17.openSync(tmp, fs17.constants.O_WRONLY | fs17.constants.O_CREAT | fs17.constants.O_EXCL | (fs17.constants.O_NOFOLLOW ?? 0), 384);
|
|
5884
|
+
try {
|
|
5885
|
+
fs17.writeSync(fd, body);
|
|
5886
|
+
fs17.fchmodSync(fd, 384);
|
|
5887
|
+
} finally {
|
|
5888
|
+
fs17.closeSync(fd);
|
|
5889
|
+
}
|
|
5890
|
+
fs17.renameSync(tmp, path19.join(anchored, path19.basename(file)));
|
|
5891
|
+
} finally {
|
|
5892
|
+
fs17.closeSync(dfd);
|
|
5893
|
+
}
|
|
5894
|
+
}
|
|
5895
|
+
function renamableAncestor(dir, uid = process.getuid?.()) {
|
|
5896
|
+
for (let p = path19.dirname(path19.resolve(dir)); ; p = path19.dirname(p)) {
|
|
5897
|
+
let st;
|
|
5898
|
+
try {
|
|
5899
|
+
st = fs17.lstatSync(p);
|
|
5900
|
+
} catch {
|
|
5901
|
+
return p;
|
|
5902
|
+
}
|
|
5903
|
+
if (!st.isDirectory()) return p;
|
|
5904
|
+
const owner = st.uid === 0 || uid === void 0 || st.uid === uid;
|
|
5905
|
+
const sticky = (st.mode & 512) !== 0;
|
|
5906
|
+
if (!owner || (st.mode & 18) !== 0 && !sticky) return p;
|
|
5907
|
+
if (p === path19.dirname(p)) return null;
|
|
5908
|
+
}
|
|
5909
|
+
}
|
|
4792
5910
|
function writeFile600(file, body, mode = 384) {
|
|
4793
|
-
|
|
5911
|
+
fs17.mkdirSync(path19.dirname(file), { recursive: true, mode: 448 });
|
|
4794
5912
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
4795
|
-
|
|
4796
|
-
|
|
5913
|
+
fs17.writeFileSync(tmp, body, { mode });
|
|
5914
|
+
fs17.renameSync(tmp, file);
|
|
4797
5915
|
}
|
|
4798
5916
|
async function install(ctx, d, h, sup, extra) {
|
|
4799
5917
|
const problem = h.preflight(ctx);
|
|
@@ -4801,13 +5919,22 @@ async function install(ctx, d, h, sup, extra) {
|
|
|
4801
5919
|
ctx.err(`cannot install the service yet: ${problem}`);
|
|
4802
5920
|
return NEEDS_PERSON;
|
|
4803
5921
|
}
|
|
4804
|
-
if (!
|
|
5922
|
+
if (!fs17.existsSync(d.cli)) {
|
|
4805
5923
|
ctx.err(`${d.cli} does not exist (a source checkout needs "npm run build" first)`);
|
|
4806
5924
|
return 1;
|
|
4807
5925
|
}
|
|
4808
|
-
const { vars, refused, withheld } = carriedEnv(ctx.env, extra);
|
|
4809
|
-
for (const r of refused) ctx.err(`--env ${r}: not carried into the service (it
|
|
4810
|
-
for (const w of withheld)
|
|
5926
|
+
const { vars, secrets, refused, withheld } = carriedEnv(ctx.env, extra, [...AGENT_SECRETS, ...codexEnvKeys(ctx.env, d.home)]);
|
|
5927
|
+
for (const r of refused) ctx.err(`--env ${r}: not carried into the service (it is the connector's own key, kept in credentials.json)`);
|
|
5928
|
+
for (const w of withheld) {
|
|
5929
|
+
ctx.err(/[\r\n]/.test(ctx.env[w] ?? "") ? `${w}: not carried into the service because its value spans several lines` : `${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)`);
|
|
5930
|
+
}
|
|
5931
|
+
try {
|
|
5932
|
+
saveServiceEnv(ctx, secrets);
|
|
5933
|
+
} catch (e) {
|
|
5934
|
+
if (!(e instanceof UnsafeDirError)) throw e;
|
|
5935
|
+
ctx.err(`cannot install the service: ${e.message}`);
|
|
5936
|
+
return 1;
|
|
5937
|
+
}
|
|
4811
5938
|
const manual = h.runningPid(ctx);
|
|
4812
5939
|
if (manual !== null && supervisorPid(ctx) === null) {
|
|
4813
5940
|
await h.stop(ctx);
|
|
@@ -4817,7 +5944,7 @@ async function install(ctx, d, h, sup, extra) {
|
|
|
4817
5944
|
if (sup === "systemd") {
|
|
4818
5945
|
const unit = systemdUnitPath(ctx, d);
|
|
4819
5946
|
writeFile600(unit, systemdUnit(d, vars));
|
|
4820
|
-
recordInstalled(ctx, "systemd");
|
|
5947
|
+
recordInstalled(ctx, "systemd", vars);
|
|
4821
5948
|
if (!d.systemdBooted()) {
|
|
4822
5949
|
ctx.err("systemd is not this machine's init (no /run/systemd/system): use --supervisor script");
|
|
4823
5950
|
return 1;
|
|
@@ -4840,7 +5967,7 @@ async function install(ctx, d, h, sup, extra) {
|
|
|
4840
5967
|
} else if (sup === "launchd") {
|
|
4841
5968
|
const plist = launchdPlistPath(d);
|
|
4842
5969
|
writeFile600(plist, launchdPlist(d, vars, runLogFile(ctx.dirs)));
|
|
4843
|
-
recordInstalled(ctx, "launchd");
|
|
5970
|
+
recordInstalled(ctx, "launchd", vars);
|
|
4844
5971
|
d.exec("launchctl", ["bootout", `gui/${d.uid}/${LABEL}`]);
|
|
4845
5972
|
const r = d.exec("launchctl", ["bootstrap", `gui/${d.uid}`, plist]);
|
|
4846
5973
|
if (r.code !== 0) {
|
|
@@ -4851,33 +5978,126 @@ async function install(ctx, d, h, sup, extra) {
|
|
|
4851
5978
|
} else {
|
|
4852
5979
|
const script = keepaliveFile(ctx.dirs);
|
|
4853
5980
|
writeFile600(script, keepaliveScript(d, vars), 448);
|
|
4854
|
-
recordInstalled(ctx, "script");
|
|
4855
|
-
const code = await ensure(ctx, d, h);
|
|
5981
|
+
recordInstalled(ctx, "script", vars);
|
|
5982
|
+
const code = await ensure(ctx, d, h, { byInstall: true });
|
|
4856
5983
|
if (code !== 0) return code;
|
|
4857
|
-
ctx.out(`wrote ${script}. This machine has no systemd: the supervisor restarts the connector 5 s after it dies
|
|
4858
|
-
ctx
|
|
4859
|
-
ctx.out(` @reboot sh ${script}`);
|
|
4860
|
-
ctx.out(` */5 * * * * sh ${script}`);
|
|
5984
|
+
ctx.out(`wrote ${script}. This machine has no systemd: the supervisor restarts the connector 5 s after it dies.`);
|
|
5985
|
+
scheduleKeepalive(ctx, d, script);
|
|
4861
5986
|
}
|
|
4862
5987
|
return 0;
|
|
4863
5988
|
}
|
|
4864
|
-
|
|
4865
|
-
|
|
5989
|
+
var cronLines = (script) => [`@reboot sh ${sh(script)} >/dev/null 2>&1`, `*/5 * * * * sh ${sh(script)} >/dev/null 2>&1`];
|
|
5990
|
+
function scheduleKeepalive(ctx, d, script) {
|
|
5991
|
+
const tab = d.readCrontab();
|
|
5992
|
+
const want = cronLines(script);
|
|
5993
|
+
if (tab !== null) {
|
|
5994
|
+
const lines = tab.split("\n");
|
|
5995
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
5996
|
+
const kept = lines.filter((l) => !want.includes(l.trim()));
|
|
5997
|
+
if (d.writeCrontab([...kept, ...want].join("\n") + "\n")) {
|
|
5998
|
+
ctx.out(`added to your crontab, so it comes back after a reboot and within 5 minutes of the supervisor dying:`);
|
|
5999
|
+
for (const l of want) ctx.out(` ${l}`);
|
|
6000
|
+
return;
|
|
6001
|
+
}
|
|
6002
|
+
ctx.err("could not write your crontab (crontab -); add the lines below by hand");
|
|
6003
|
+
}
|
|
6004
|
+
ctx.out("No cron daemon runs here: after a reboot or a container restart NOTHING starts the connector again.");
|
|
6005
|
+
ctx.out("Run this from the container's startup command (it does nothing while the connector is up; ~/.bashrc works too, then it");
|
|
6006
|
+
ctx.out("comes back when you next open a shell):");
|
|
6007
|
+
ctx.out(` sh ${sh(script)} >/dev/null 2>&1`);
|
|
6008
|
+
const where = ephemeralWhere(ctx.dirs.config);
|
|
6009
|
+
if (where) ctx.out(`Note: ${where}`);
|
|
6010
|
+
}
|
|
6011
|
+
function withoutOurCron(d, script) {
|
|
6012
|
+
const tab = d.readCrontab();
|
|
6013
|
+
if (tab === null) return null;
|
|
6014
|
+
const ours = cronLines(script);
|
|
6015
|
+
const lines = tab.split("\n");
|
|
6016
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
6017
|
+
const kept = lines.filter((l) => !ours.includes(l.trim()));
|
|
6018
|
+
return { tab: kept.length ? kept.join("\n") + "\n" : "", removed: lines.length - kept.length };
|
|
6019
|
+
}
|
|
6020
|
+
var EPHEMERAL_FS = /* @__PURE__ */ new Set(["overlay", "overlayfs", "aufs", "tmpfs"]);
|
|
6021
|
+
var PERSISTENT_FS = /^(?:ceph|nfs|nfs4|lustre|wekafs|gpfs|beegfs|cifs|smb3|fuse\.(?:ceph|juicefs|s3fs|gcsfuse|sshfs|rclone))$/;
|
|
6022
|
+
function readMounts(text) {
|
|
6023
|
+
let raw = text;
|
|
6024
|
+
if (raw === void 0) {
|
|
6025
|
+
try {
|
|
6026
|
+
raw = fs17.readFileSync("/proc/self/mountinfo", "utf8");
|
|
6027
|
+
} catch {
|
|
6028
|
+
return [];
|
|
6029
|
+
}
|
|
6030
|
+
}
|
|
6031
|
+
const out = [];
|
|
6032
|
+
for (const line of raw.split("\n")) {
|
|
6033
|
+
const dash = line.indexOf(" - ");
|
|
6034
|
+
if (dash < 0) continue;
|
|
6035
|
+
const fields = line.slice(0, dash).split(" ");
|
|
6036
|
+
const point = fields[4]?.replace(/\\([0-7]{3})/g, (_m, o) => String.fromCharCode(parseInt(o, 8)));
|
|
6037
|
+
const fstype = line.slice(dash + 3).split(" ")[0];
|
|
6038
|
+
if (point && fstype) out.push({ point, fstype });
|
|
6039
|
+
}
|
|
6040
|
+
return out;
|
|
6041
|
+
}
|
|
6042
|
+
function mountOf(p, mounts) {
|
|
6043
|
+
let best = null;
|
|
6044
|
+
for (const m of mounts) {
|
|
6045
|
+
const inside2 = p === m.point || p.startsWith(m.point.endsWith("/") ? m.point : `${m.point}/`);
|
|
6046
|
+
if (inside2 && (!best || m.point.length >= best.point.length)) best = m;
|
|
6047
|
+
}
|
|
6048
|
+
return best;
|
|
6049
|
+
}
|
|
6050
|
+
function ephemeralWhere(configDir, mounts = readMounts()) {
|
|
6051
|
+
let real = configDir;
|
|
6052
|
+
for (let p = configDir; ; p = path19.dirname(p)) {
|
|
6053
|
+
try {
|
|
6054
|
+
real = path19.join(fs17.realpathSync(p), path19.relative(p, configDir));
|
|
6055
|
+
break;
|
|
6056
|
+
} catch {
|
|
6057
|
+
if (p === path19.dirname(p)) break;
|
|
6058
|
+
}
|
|
6059
|
+
}
|
|
6060
|
+
const m = mountOf(real, mounts);
|
|
6061
|
+
if (!m || !EPHEMERAL_FS.has(m.fstype)) return null;
|
|
6062
|
+
const persistent = [...new Set(mounts.filter((x) => PERSISTENT_FS.test(x.fstype) && !x.point.startsWith("/proc") && !x.point.startsWith("/sys")).map((x) => x.point))].filter((pt) => {
|
|
6063
|
+
try {
|
|
6064
|
+
fs17.accessSync(pt, fs17.constants.W_OK);
|
|
6065
|
+
return true;
|
|
6066
|
+
} catch {
|
|
6067
|
+
return false;
|
|
6068
|
+
}
|
|
6069
|
+
}).slice(0, 3);
|
|
6070
|
+
return `agents.toml and the sign-in are in ${configDir}, on the container's own file system (${m.fstype}), which is lost when the container is recreated. Keep them on storage that outlives it: export CLAW_CONNECTOR_CONFIG_DIR=<a directory of yours on ${persistent.length ? persistent.join(" or ") : "a persistent volume"}>/claw-connector in your shell profile and the container's startup command, then run init and login again (the state directory, with its pid and lock files, stays where it is).`;
|
|
6071
|
+
}
|
|
6072
|
+
function saveServiceEnv(ctx, secrets) {
|
|
6073
|
+
const file = serviceEnvFile(ctx.dirs);
|
|
6074
|
+
const before = loadServiceEnv(file);
|
|
6075
|
+
const merged = { ...before, ...Object.fromEntries(secrets) };
|
|
6076
|
+
const names = Object.keys(merged).sort();
|
|
6077
|
+
if (names.length === 0) return;
|
|
6078
|
+
writeSecretFile(file, JSON.stringify(merged, null, 2) + "\n");
|
|
6079
|
+
const fresh = secrets.map(([k]) => k);
|
|
6080
|
+
const kept = names.filter((n) => !fresh.includes(n));
|
|
6081
|
+
ctx.out(`the agents' sign-in for the service (in ${file}, mode 0600, never in a unit): ${[...fresh, ...kept.map((k) => `${k} (kept from before)`)].join(", ")}`);
|
|
6082
|
+
}
|
|
6083
|
+
function recordInstalled(ctx, sup, vars) {
|
|
6084
|
+
const p = vars.find(([k]) => k === "PATH")?.[1];
|
|
6085
|
+
writeFile600(serviceFile(ctx.dirs), JSON.stringify({ supervisor: sup, installed_at: (/* @__PURE__ */ new Date()).toISOString(), ...p !== void 0 ? { path: p } : {} }));
|
|
4866
6086
|
}
|
|
4867
6087
|
async function uninstall(ctx, d, sup) {
|
|
4868
|
-
|
|
6088
|
+
fs17.rmSync(serviceFile(ctx.dirs), { force: true });
|
|
4869
6089
|
if (sup === "systemd") {
|
|
4870
6090
|
const unit = systemdUnitPath(ctx, d);
|
|
4871
6091
|
d.exec("systemctl", ["--user", "disable", "--now", UNIT]);
|
|
4872
|
-
const had =
|
|
4873
|
-
|
|
6092
|
+
const had = fs17.existsSync(unit);
|
|
6093
|
+
fs17.rmSync(unit, { force: true });
|
|
4874
6094
|
d.exec("systemctl", ["--user", "daemon-reload"]);
|
|
4875
6095
|
ctx.out(had ? `stopped and removed ${unit}` : `no unit at ${unit}`);
|
|
4876
6096
|
} else if (sup === "launchd") {
|
|
4877
6097
|
const plist = launchdPlistPath(d);
|
|
4878
6098
|
d.exec("launchctl", ["bootout", `gui/${d.uid}/${LABEL}`]);
|
|
4879
|
-
const had =
|
|
4880
|
-
|
|
6099
|
+
const had = fs17.existsSync(plist);
|
|
6100
|
+
fs17.rmSync(plist, { force: true });
|
|
4881
6101
|
ctx.out(had ? `stopped and removed ${plist}` : `no LaunchAgent at ${plist}`);
|
|
4882
6102
|
} else {
|
|
4883
6103
|
const pid = supervisorPid(ctx);
|
|
@@ -4887,9 +6107,15 @@ async function uninstall(ctx, d, sup) {
|
|
|
4887
6107
|
while (Date.now() < end && isAlive(pid) && processStart(pid) !== null) await sleep(100);
|
|
4888
6108
|
}
|
|
4889
6109
|
const script = keepaliveFile(ctx.dirs);
|
|
4890
|
-
const had =
|
|
4891
|
-
|
|
4892
|
-
|
|
6110
|
+
const had = fs17.existsSync(script);
|
|
6111
|
+
fs17.rmSync(script, { force: true });
|
|
6112
|
+
const cron = withoutOurCron(d, script);
|
|
6113
|
+
const cronGone = cron !== null && cron.removed > 0 && d.writeCrontab(cron.tab);
|
|
6114
|
+
ctx.out(`${pid !== null ? `stopped the supervisor (pid ${pid}) and the connector; ` : ""}${had ? `removed ${script}` : "no keep-alive script"}${cronGone ? " and its crontab lines" : ". Remove any startup-hook or cron line that calls it."}`);
|
|
6115
|
+
}
|
|
6116
|
+
if (fs17.existsSync(serviceEnvFile(ctx.dirs))) {
|
|
6117
|
+
fs17.rmSync(serviceEnvFile(ctx.dirs), { force: true });
|
|
6118
|
+
ctx.out(`removed ${serviceEnvFile(ctx.dirs)} (the agents' sign-in variables kept for the service)`);
|
|
4893
6119
|
}
|
|
4894
6120
|
return 0;
|
|
4895
6121
|
}
|
|
@@ -4905,6 +6131,8 @@ function status(ctx, d, h, json) {
|
|
|
4905
6131
|
const st = {
|
|
4906
6132
|
installed: sup,
|
|
4907
6133
|
active,
|
|
6134
|
+
/** The names (never the values) of the agents' sign-in variables the service keeps. */
|
|
6135
|
+
agent_env: Object.keys(loadServiceEnv(serviceEnvFile(ctx.dirs))).sort(),
|
|
4908
6136
|
running: h.runningPid(ctx),
|
|
4909
6137
|
needs_login: needsLogin(ctx.dirs),
|
|
4910
6138
|
recent_disconnects: recentDisconnects(ctx.dirs, 5)
|
|
@@ -4915,18 +6143,19 @@ function status(ctx, d, h, json) {
|
|
|
4915
6143
|
}
|
|
4916
6144
|
ctx.out(`service: ${sup ? `${sup} (${active})` : "not installed; run: claw-connector service install"}`);
|
|
4917
6145
|
ctx.out(`connector: ${st.running !== null ? `running (pid ${st.running})` : "not running"}`);
|
|
6146
|
+
if (st.agent_env.length) ctx.out(`agent env: ${st.agent_env.join(", ")} (from ${serviceEnvFile(ctx.dirs)})`);
|
|
4918
6147
|
if (st.needs_login) ctx.out(`problem: needs a new sign-in: ${st.needs_login.reason}; run: claw-connector login`);
|
|
4919
6148
|
for (const x of st.recent_disconnects) ctx.out(`down: ${x.at} ${x.reason}${x.detail ? ` (${x.detail})` : ""}`);
|
|
4920
6149
|
return 0;
|
|
4921
6150
|
}
|
|
4922
6151
|
|
|
4923
6152
|
// src/version.ts
|
|
4924
|
-
var VERSION = "0.1.
|
|
6153
|
+
var VERSION = "0.1.6";
|
|
4925
6154
|
|
|
4926
6155
|
// src/probe.ts
|
|
4927
|
-
import
|
|
4928
|
-
import
|
|
4929
|
-
import
|
|
6156
|
+
import fs18 from "node:fs";
|
|
6157
|
+
import os8 from "node:os";
|
|
6158
|
+
import path20 from "node:path";
|
|
4930
6159
|
var BYPASS_FIX = `Claude Code offers bypassPermissions to root only with IS_SANDBOX set (the connector sets it for an agent with approval = "never" unless its env names IS_SANDBOX), and never when permissions.disableBypassPermissionsMode is "disable" in its settings; fix that, or set approval = "ask" on this agent`;
|
|
4931
6160
|
function modesOf(res) {
|
|
4932
6161
|
const m = isObject(res.modes) ? res.modes : null;
|
|
@@ -4937,17 +6166,17 @@ function modesOf(res) {
|
|
|
4937
6166
|
}
|
|
4938
6167
|
var probeDir = (a) => a.roots.find((r) => {
|
|
4939
6168
|
try {
|
|
4940
|
-
return
|
|
6169
|
+
return fs18.statSync(r).isDirectory();
|
|
4941
6170
|
} catch {
|
|
4942
6171
|
return false;
|
|
4943
6172
|
}
|
|
4944
|
-
}) ??
|
|
6173
|
+
}) ?? os8.homedir();
|
|
4945
6174
|
async function probeAgent(a, o) {
|
|
4946
6175
|
const cwd = probeDir(a);
|
|
4947
6176
|
const out = { agent: a.id, cwd, ok: false, modes: null, problems: [], notes: [] };
|
|
4948
6177
|
if (!a.roots.includes(cwd)) out.notes.push(`no root of this agent exists yet (${a.roots.join(", ")}); probed in ${cwd}`);
|
|
4949
|
-
const tmp =
|
|
4950
|
-
const registry = new ChildRegistry(
|
|
6178
|
+
const tmp = fs18.mkdtempSync(path20.join(os8.tmpdir(), "claw-probe-"));
|
|
6179
|
+
const registry = new ChildRegistry(path20.join(tmp, "children.json"));
|
|
4951
6180
|
const timeoutMs = o.timeoutMs ?? 12e4;
|
|
4952
6181
|
let adapter = null;
|
|
4953
6182
|
try {
|
|
@@ -4956,7 +6185,7 @@ async function probeAgent(a, o) {
|
|
|
4956
6185
|
registry,
|
|
4957
6186
|
sessionId: `probe-${a.id}`,
|
|
4958
6187
|
cwd,
|
|
4959
|
-
logFile:
|
|
6188
|
+
logFile: path20.join(o.logDir, `adapter-${a.id}.log`),
|
|
4960
6189
|
version: o.version,
|
|
4961
6190
|
initTimeoutMs: timeoutMs,
|
|
4962
6191
|
hooks: {
|
|
@@ -4987,10 +6216,10 @@ async function probeAgent(a, o) {
|
|
|
4987
6216
|
if (missing.length) out.notes.push(`safe_modes lists ${missing.join(", ")}, which the adapter does not offer here; a phone that picks one is refused`);
|
|
4988
6217
|
out.ok = out.problems.length === 0;
|
|
4989
6218
|
} catch (e) {
|
|
4990
|
-
out.problems.push(`the adapter did not open a session: ${errMessage(e)} (its log: ${
|
|
6219
|
+
out.problems.push(`the adapter did not open a session: ${errMessage(e)} (its log: ${path20.join(o.logDir, `adapter-${a.id}.log`)})`);
|
|
4991
6220
|
} finally {
|
|
4992
6221
|
await adapter?.kill().catch(() => void 0);
|
|
4993
|
-
|
|
6222
|
+
fs18.rmSync(tmp, { recursive: true, force: true });
|
|
4994
6223
|
}
|
|
4995
6224
|
return out;
|
|
4996
6225
|
}
|
|
@@ -5031,11 +6260,18 @@ Usage: claw-connector <command> [options]
|
|
|
5031
6260
|
service install|uninstall|status [--supervisor systemd|launchd|script] [--env NAME ...]
|
|
5032
6261
|
keep "run" up: started at login/boot, restarted 5 s after it dies, never restarted
|
|
5033
6262
|
after exit 78. systemd --user on Linux, launchd on macOS; "script" (containers without
|
|
5034
|
-
systemd) writes a keep-alive script
|
|
6263
|
+
systemd) writes a keep-alive script and adds it to crontab, or says what must call it.
|
|
6264
|
+
The agents' settings in this shell (PATH, proxies, ANTHROPIC_BASE_URL...) go into the
|
|
6265
|
+
service; their sign-in variables (ANTHROPIC_API_KEY, OPENAI_API_KEY...) and any --env
|
|
6266
|
+
named like a credential go into service-env.json (0600), never into the unit
|
|
5035
6267
|
status [--json] what is running, signed in, paused, the last disconnect and why
|
|
5036
6268
|
doctor [--agent ID] [--json]
|
|
5037
|
-
|
|
5038
|
-
|
|
6269
|
+
check this machine before a phone session fails on it, one fix per problem: Node, each
|
|
6270
|
+
agent's program on PATH (and on the installed service's PATH), Claude Code / Codex signed
|
|
6271
|
+
in, the roots writable, the proxy variables, Claw's certificate, the service and Claw
|
|
6272
|
+
itself; then start each agent's adapter, open a session (no prompt) and check that it
|
|
6273
|
+
offers the modes agents.toml needs. Exit 1 when something stops sessions. A running
|
|
6274
|
+
connector repeats the machine checks and shows them in the chat; init runs the adapter check
|
|
5039
6275
|
pause stop every agent now and stay offline until "resume"
|
|
5040
6276
|
resume undo "pause"
|
|
5041
6277
|
stop stop the running connector and every agent it started
|
|
@@ -5043,10 +6279,12 @@ Usage: claw-connector <command> [options]
|
|
|
5043
6279
|
--version, --help
|
|
5044
6280
|
|
|
5045
6281
|
Files: ${"$"}XDG_CONFIG_HOME/claw-connector (agents.toml, credentials.json) and ${"$"}XDG_STATE_HOME/claw-connector
|
|
5046
|
-
(audit.jsonl, logs, outbox); set CLAW_CONNECTOR_HOME to keep everything under one directory
|
|
6282
|
+
(audit.jsonl, logs, outbox); set CLAW_CONNECTOR_HOME to keep everything under one directory, and
|
|
6283
|
+
CLAW_CONNECTOR_CONFIG_DIR to keep only the configuration and sign-in elsewhere (storage that outlives a container).
|
|
5047
6284
|
`;
|
|
5048
6285
|
function loadCfg(ctx, notes = []) {
|
|
5049
|
-
const cfg = loadConfig(configFile(ctx.dirs), { hostname: ctx.hostname ??
|
|
6286
|
+
const cfg = loadConfig(configFile(ctx.dirs), { hostname: ctx.hostname ?? os9.hostname() });
|
|
6287
|
+
applyServiceEnv(cfg.agents, loadServiceEnv(serviceEnvFile(ctx.dirs)), ctx.env);
|
|
5050
6288
|
notes.push(...applyRootSandbox(cfg.agents, ctx.env, { euid: ctx.euid }));
|
|
5051
6289
|
return cfg;
|
|
5052
6290
|
}
|
|
@@ -5090,6 +6328,8 @@ async function main(argv, ctx) {
|
|
|
5090
6328
|
return cmdStatus(rest, ctx);
|
|
5091
6329
|
case "doctor":
|
|
5092
6330
|
return await cmdDoctor(rest, ctx);
|
|
6331
|
+
case "health-check":
|
|
6332
|
+
return await cmdHealthCheck(ctx, ctx.stdin ?? readStdin);
|
|
5093
6333
|
case "pause":
|
|
5094
6334
|
return await cmdPause(ctx);
|
|
5095
6335
|
case "resume":
|
|
@@ -5136,9 +6376,9 @@ async function cmdInit(args, ctx) {
|
|
|
5136
6376
|
return EXIT.usage;
|
|
5137
6377
|
}
|
|
5138
6378
|
const file = configFile(ctx.dirs);
|
|
5139
|
-
|
|
6379
|
+
fs19.mkdirSync(path21.dirname(file), { recursive: true, mode: 448 });
|
|
5140
6380
|
try {
|
|
5141
|
-
|
|
6381
|
+
fs19.writeFileSync(file, sampleConfig(approval), { mode: 384, flag: "wx" });
|
|
5142
6382
|
ctx.out(`wrote ${file}: edit the roots (the directories an agent may work in), then run: claw-connector login`);
|
|
5143
6383
|
const { unattended, parked } = sampleApprovals(approval);
|
|
5144
6384
|
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.`);
|
|
@@ -5147,9 +6387,11 @@ async function cmdInit(args, ctx) {
|
|
|
5147
6387
|
if (e.code !== "EEXIST") throw e;
|
|
5148
6388
|
ctx.out(`${file} already exists; left alone${values.approval?.length ? ` (to change an agent's approval, set approval = "ask" or "never" in its [[agents]] table)` : ""}`);
|
|
5149
6389
|
}
|
|
6390
|
+
const where = ephemeralWhere(ctx.dirs.config);
|
|
6391
|
+
if (where) ctx.out(`note: ${where}`);
|
|
5150
6392
|
if (!values["no-hooks"]) {
|
|
5151
6393
|
const dir = defaultClaudeDir(ctx);
|
|
5152
|
-
if (!
|
|
6394
|
+
if (!fs19.existsSync(dir)) ctx.out(`no Claude Code directory at ${dir}; once you use Claude Code, run: claw-connector hooks install`);
|
|
5153
6395
|
else installForInit(ctx, dir);
|
|
5154
6396
|
}
|
|
5155
6397
|
await installAdapters(ctx, true);
|
|
@@ -5164,27 +6406,6 @@ async function cmdInit(args, ctx) {
|
|
|
5164
6406
|
}
|
|
5165
6407
|
return EXIT.ok;
|
|
5166
6408
|
}
|
|
5167
|
-
async function cmdDoctor(args, ctx) {
|
|
5168
|
-
const { values } = parseArgs2({ args, strict: true, options: { agent: { type: "string" }, json: { type: "boolean" } } });
|
|
5169
|
-
const notes = [];
|
|
5170
|
-
const cfg = loadCfg(ctx, notes);
|
|
5171
|
-
const agents = values.agent ? cfg.agents.filter((a) => a.id === values.agent) : cfg.agents;
|
|
5172
|
-
if (agents.length === 0) {
|
|
5173
|
-
ctx.err(values.agent ? `no agent ${JSON.stringify(values.agent)} in agents.toml` : "agents.toml defines no agents; add an [[agents]] table");
|
|
5174
|
-
return EXIT.failure;
|
|
5175
|
-
}
|
|
5176
|
-
const warnings = noAsk(ctx, { ...cfg, agents });
|
|
5177
|
-
const results = await (ctx.probe ?? probeAgents)(agents, { logDir: logDir(ctx.dirs), version: VERSION });
|
|
5178
|
-
const ok = results.every((r) => r.ok);
|
|
5179
|
-
if (values.json) {
|
|
5180
|
-
ctx.out(JSON.stringify({ version: VERSION, ok, notes, warnings, agents: results }, null, 2));
|
|
5181
|
-
} else {
|
|
5182
|
-
for (const n of notes) ctx.out(`note: ${n}`);
|
|
5183
|
-
for (const w of warnings) ctx.out(`warning: ${w}`);
|
|
5184
|
-
for (const l of probeLines(results)) ctx.out(l);
|
|
5185
|
-
}
|
|
5186
|
-
return ok ? EXIT.ok : EXIT.failure;
|
|
5187
|
-
}
|
|
5188
6409
|
async function installAdapters(ctx, initFallback) {
|
|
5189
6410
|
const store2 = adapterStore();
|
|
5190
6411
|
if (!store2 || !ctx.installAdapters) return;
|
|
@@ -5204,19 +6425,19 @@ function installForInit(ctx, dir) {
|
|
|
5204
6425
|
ctx.err(`hooks: ${errMessage(e)} (everything else is set up; fix it and run: claw-connector hooks install)`);
|
|
5205
6426
|
}
|
|
5206
6427
|
}
|
|
5207
|
-
var defaultClaudeDir = (ctx) =>
|
|
6428
|
+
var defaultClaudeDir = (ctx) => path21.resolve(ctx.env.CLAUDE_CONFIG_DIR?.trim() || path21.join(os9.homedir(), ".claude"));
|
|
5208
6429
|
function claudeDirs(ctx, flag) {
|
|
5209
|
-
if (flag !== void 0) return [
|
|
6430
|
+
if (flag !== void 0) return [path21.resolve(flag)];
|
|
5210
6431
|
let dirs = [];
|
|
5211
6432
|
try {
|
|
5212
|
-
dirs = loadCfg(ctx).agents.filter((a) => a.attach).map((a) =>
|
|
6433
|
+
dirs = loadCfg(ctx).agents.filter((a) => a.attach).map((a) => path21.resolve(a.env.CLAUDE_CONFIG_DIR ?? ctx.env.CLAUDE_CONFIG_DIR?.trim() ?? path21.join(os9.homedir(), ".claude")));
|
|
5213
6434
|
} catch {
|
|
5214
6435
|
}
|
|
5215
6436
|
return dirs.length ? [...new Set(dirs)] : [defaultClaudeDir(ctx)];
|
|
5216
6437
|
}
|
|
5217
6438
|
function hookScript(ctx) {
|
|
5218
6439
|
const script = ctx.hookScript ?? fileURLToPath2(new URL("./terminal-hook.js", import.meta.url));
|
|
5219
|
-
if (!
|
|
6440
|
+
if (!fs19.existsSync(script)) throw new HooksError(`${script} does not exist (a source checkout needs "npm run build" first)`);
|
|
5220
6441
|
return script;
|
|
5221
6442
|
}
|
|
5222
6443
|
function installOne(ctx, dir) {
|
|
@@ -5236,7 +6457,7 @@ function cmdHooks(args, ctx) {
|
|
|
5236
6457
|
for (const dir of dirs) {
|
|
5237
6458
|
try {
|
|
5238
6459
|
if (sub === "install") {
|
|
5239
|
-
if (values["config-dir"] === void 0 && !
|
|
6460
|
+
if (values["config-dir"] === void 0 && !fs19.existsSync(dir)) {
|
|
5240
6461
|
ctx.err(`no Claude Code directory at ${dir}; pass --config-dir to name one`);
|
|
5241
6462
|
code = EXIT.failure;
|
|
5242
6463
|
continue;
|
|
@@ -5261,11 +6482,11 @@ function forgetRevokedIdentity(ctx) {
|
|
|
5261
6482
|
const file = identityFile(ctx.dirs);
|
|
5262
6483
|
let revoked = false;
|
|
5263
6484
|
try {
|
|
5264
|
-
const raw = JSON.parse(
|
|
6485
|
+
const raw = JSON.parse(fs19.readFileSync(file, "utf8"));
|
|
5265
6486
|
revoked = isObject(raw) && raw.revoked === true;
|
|
5266
6487
|
} catch {
|
|
5267
6488
|
}
|
|
5268
|
-
if (revoked)
|
|
6489
|
+
if (revoked) fs19.rmSync(file, { force: true });
|
|
5269
6490
|
}
|
|
5270
6491
|
async function cmdLogin(args, ctx) {
|
|
5271
6492
|
const { values } = parseArgs2({
|
|
@@ -5300,7 +6521,7 @@ async function cmdLogin(args, ctx) {
|
|
|
5300
6521
|
ctx.err("login needs --identity-url <identity service URL> (or CLAW_IDENTITY_URL, or identity_url = in agents.toml)");
|
|
5301
6522
|
return EXIT.usage;
|
|
5302
6523
|
}
|
|
5303
|
-
const deviceName = values.name ?? cfg?.name ?? ctx.hostname ??
|
|
6524
|
+
const deviceName = values.name ?? cfg?.name ?? ctx.hostname ?? os9.hostname();
|
|
5304
6525
|
const tok = await deviceLogin({ identityUrl, deviceName, fetch: ctx.fetch, print: ctx.out });
|
|
5305
6526
|
saveCredentials(file, {
|
|
5306
6527
|
v: 1,
|
|
@@ -5320,7 +6541,7 @@ async function cmdLogin(args, ctx) {
|
|
|
5320
6541
|
}
|
|
5321
6542
|
function runningPid(ctx) {
|
|
5322
6543
|
try {
|
|
5323
|
-
const raw = JSON.parse(
|
|
6544
|
+
const raw = JSON.parse(fs19.readFileSync(pidFile(ctx.dirs), "utf8"));
|
|
5324
6545
|
if (isObject(raw) && typeof raw.pid === "number" && typeof raw.start === "string" && raw.start !== "" && processStart(raw.pid) === raw.start) return raw.pid;
|
|
5325
6546
|
} catch {
|
|
5326
6547
|
}
|
|
@@ -5366,12 +6587,16 @@ async function cmdRun(args, ctx) {
|
|
|
5366
6587
|
const client2 = new ClawClient({ server, tokens, fetch: ctx.fetch });
|
|
5367
6588
|
const log = stderrLogger();
|
|
5368
6589
|
const audit = fileAudit(auditFile(ctx.dirs));
|
|
5369
|
-
const connector2 = new Connector({ config: cfg, dirs: ctx.dirs, client: client2, server, audit, log, version: VERSION, timing: ctx.timing });
|
|
6590
|
+
const connector2 = new Connector({ config: cfg, dirs: ctx.dirs, client: client2, server, audit, log, version: VERSION, timing: ctx.timing, health: runHealth(ctx, cfg) });
|
|
5370
6591
|
if (ctx.installAdapters) for (const { pkg, env } of pinnedOf(cfg.agents, ctx.env)) void adapterStore()?.install(pkg, env, (l) => ctx.out(`adapters: ${l}`));
|
|
5371
6592
|
ctx.onConnector?.(connector2);
|
|
5372
6593
|
ctx.out(`connecting ${cfg.agents.map((a) => a.unattended ? `${a.id} (no per-step approval)` : a.id).join(", ")} to ${server} as "${cfg.name}"`);
|
|
5373
6594
|
for (const n of notes) ctx.out(`note: ${n}`);
|
|
5374
6595
|
for (const w of noAsk(ctx, cfg)) ctx.out(`warning: ${w}`);
|
|
6596
|
+
for (const a of cfg.agents) if (a.readOnlyModes.includes(a.askMode)) ctx.out(`note: ${a.id}: its sessions start read-only (${a.askMode} mode changes no file); to let phone sessions write code, set approval = "never" on it in agents.toml`);
|
|
6597
|
+
void rootProblems(cfg.agents).then((ps) => {
|
|
6598
|
+
for (const p of ps) ctx.out(`warning: ${p}`);
|
|
6599
|
+
}, () => void 0);
|
|
5375
6600
|
try {
|
|
5376
6601
|
const result = await connector2.run();
|
|
5377
6602
|
if (result === "revoked") {
|
|
@@ -5402,6 +6627,94 @@ async function cmdRun(args, ctx) {
|
|
|
5402
6627
|
throw e;
|
|
5403
6628
|
}
|
|
5404
6629
|
}
|
|
6630
|
+
async function cmdDoctor(args, ctx) {
|
|
6631
|
+
const { values } = parseArgs2({ args, strict: true, options: { agent: { type: "string" }, json: { type: "boolean" } } });
|
|
6632
|
+
const before = [];
|
|
6633
|
+
const notes = [];
|
|
6634
|
+
let cfg = null;
|
|
6635
|
+
let creds = null;
|
|
6636
|
+
try {
|
|
6637
|
+
cfg = loadCfg(ctx, notes);
|
|
6638
|
+
} catch (e) {
|
|
6639
|
+
const missing = !fs19.existsSync(configFile(ctx.dirs));
|
|
6640
|
+
const roots = e instanceof ConfigError ? e.problems.map((p) => /^agent (\S+): root (.+) does not exist$/.exec(p)) : [];
|
|
6641
|
+
if (roots.length && roots.every((m) => m !== null)) {
|
|
6642
|
+
for (const m of roots) before.push({ code: "root_missing", severity: "fail", agent: m[1], detail: `${m[2]} (a root of agent ${m[1]}) does not exist`, fix: `mkdir -p ${shq(m[2])} (or change roots in ${configFile(ctx.dirs)})` });
|
|
6643
|
+
} else before.push({ code: "config_invalid", severity: "fail", detail: errMessage(e), fix: missing ? "claw-connector init" : `edit ${configFile(ctx.dirs)}` });
|
|
6644
|
+
}
|
|
6645
|
+
const agents = cfg ? values.agent ? cfg.agents.filter((a) => a.id === values.agent) : cfg.agents : [];
|
|
6646
|
+
if (cfg && agents.length === 0) {
|
|
6647
|
+
if (values.agent) {
|
|
6648
|
+
ctx.err(`no agent ${JSON.stringify(values.agent)} in agents.toml`);
|
|
6649
|
+
return EXIT.failure;
|
|
6650
|
+
}
|
|
6651
|
+
before.push({ code: "config_invalid", severity: "fail", detail: "agents.toml defines no agents", fix: `add an [[agents]] table to ${configFile(ctx.dirs)} (claw-connector init writes a sample)` });
|
|
6652
|
+
}
|
|
6653
|
+
try {
|
|
6654
|
+
creds = loadCredentials(credentialsFile(ctx.dirs));
|
|
6655
|
+
} catch (e) {
|
|
6656
|
+
before.push({ code: "not_signed_in", severity: "fail", detail: errMessage(e), fix: "claw-connector login" });
|
|
6657
|
+
}
|
|
6658
|
+
if (!creds && !before.some((p) => p.code === "not_signed_in")) before.push({ code: "not_signed_in", severity: "fail", detail: "this connector is not signed in to Claw", fix: "claw-connector login" });
|
|
6659
|
+
const nl = needsLogin(ctx.dirs);
|
|
6660
|
+
if (nl) before.push({ code: "needs_login", severity: "fail", detail: `needs a new sign-in: ${nl.reason}`, fix: "claw-connector login" });
|
|
6661
|
+
let server = null;
|
|
6662
|
+
try {
|
|
6663
|
+
server = serverUrl(ctx, void 0, cfg, creds);
|
|
6664
|
+
} catch {
|
|
6665
|
+
}
|
|
6666
|
+
const report = await checkAll({ dirs: ctx.dirs, agents, by: "doctor", server, before }, ctx.doctor ?? defaultDeps(ctx.env));
|
|
6667
|
+
writeReport(ctx.dirs, report);
|
|
6668
|
+
const warnings = cfg ? noAsk(ctx, { ...cfg, agents }) : [];
|
|
6669
|
+
const results = agents.length ? redactDeep(await (ctx.probe ?? probeAgents)(agents, { logDir: logDir(ctx.dirs), version: VERSION })) : [];
|
|
6670
|
+
const adaptersOk = results.every((r) => r.ok);
|
|
6671
|
+
const machineOk = !report.problems.some((p) => p.severity === "fail");
|
|
6672
|
+
if (values.json) {
|
|
6673
|
+
ctx.out(JSON.stringify({ version: VERSION, ok: adaptersOk, machine_ok: machineOk, notes, warnings, agents: results, environment: report }, null, 2));
|
|
6674
|
+
} else {
|
|
6675
|
+
ctx.out(`claw-connector ${VERSION} doctor${cfg ? ` (${cfg.name})` : ""}`);
|
|
6676
|
+
for (const l of formatReport(report)) ctx.out(l);
|
|
6677
|
+
if (results.length) {
|
|
6678
|
+
ctx.out("");
|
|
6679
|
+
ctx.out("each agent's adapter (a session opened without a prompt):");
|
|
6680
|
+
for (const n of notes) ctx.out(`note: ${n}`);
|
|
6681
|
+
for (const w of warnings) ctx.out(`warning: ${w}`);
|
|
6682
|
+
for (const l of probeLines(results)) ctx.out(l);
|
|
6683
|
+
}
|
|
6684
|
+
}
|
|
6685
|
+
const running = runningPid(ctx);
|
|
6686
|
+
if (running !== null) {
|
|
6687
|
+
try {
|
|
6688
|
+
(ctx.askRecheck ?? ((pid) => process.kill(pid, "SIGWINCH")))(running);
|
|
6689
|
+
if (!values.json) ctx.out("The running connector checks the machine again now and shows the result in the chat.");
|
|
6690
|
+
} catch {
|
|
6691
|
+
}
|
|
6692
|
+
}
|
|
6693
|
+
return adaptersOk && machineOk ? EXIT.ok : EXIT.failure;
|
|
6694
|
+
}
|
|
6695
|
+
var HEALTH_CHILD_MS = 6e4;
|
|
6696
|
+
function runHealth(ctx, cfg) {
|
|
6697
|
+
if (ctx.doctor) return () => checkAll({ dirs: ctx.dirs, agents: cfg.agents, by: "run" }, ctx.doctor);
|
|
6698
|
+
const cli = ctx.cli ?? ctx.service?.cli ?? fileURLToPath2(import.meta.url);
|
|
6699
|
+
const node = ctx.node ?? process.execPath;
|
|
6700
|
+
return async (signal) => {
|
|
6701
|
+
const r = await runProbe(node, [cli, "health-check"], ctx.env, signal, HEALTH_CHILD_MS, JSON.stringify({ agents: cfg.agents }));
|
|
6702
|
+
const v = JSON.parse(r.out.slice(r.out.indexOf("{")));
|
|
6703
|
+
if (!isObject(v) || typeof v.checked_at !== "string" || !Array.isArray(v.problems)) throw new Error(`health-check answered ${r.code === null ? "nothing in time" : `exit ${r.code}`}`);
|
|
6704
|
+
return v;
|
|
6705
|
+
};
|
|
6706
|
+
}
|
|
6707
|
+
async function cmdHealthCheck(ctx, stdin) {
|
|
6708
|
+
const v = JSON.parse(await stdin());
|
|
6709
|
+
const agents = isObject(v) && Array.isArray(v.agents) ? v.agents : [];
|
|
6710
|
+
ctx.out(JSON.stringify(await checkAll({ dirs: ctx.dirs, agents, by: "run" }, defaultDeps(ctx.env, { ownGroup: false }))));
|
|
6711
|
+
return EXIT.ok;
|
|
6712
|
+
}
|
|
6713
|
+
async function readStdin() {
|
|
6714
|
+
const chunks = [];
|
|
6715
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
6716
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
6717
|
+
}
|
|
5405
6718
|
function cmdStatus(args, ctx) {
|
|
5406
6719
|
const { values } = parseArgs2({ args, strict: true, options: { json: { type: "boolean" } } });
|
|
5407
6720
|
let creds = null;
|
|
@@ -5425,7 +6738,7 @@ function cmdStatus(args, ctx) {
|
|
|
5425
6738
|
}
|
|
5426
6739
|
let connectorId = null;
|
|
5427
6740
|
try {
|
|
5428
|
-
const raw = JSON.parse(
|
|
6741
|
+
const raw = JSON.parse(fs19.readFileSync(identityFile(ctx.dirs), "utf8"));
|
|
5429
6742
|
if (isObject(raw) && typeof raw.connector_id === "string") connectorId = raw.connector_id;
|
|
5430
6743
|
} catch {
|
|
5431
6744
|
}
|
|
@@ -5433,7 +6746,7 @@ function cmdStatus(args, ctx) {
|
|
|
5433
6746
|
const status2 = {
|
|
5434
6747
|
version: VERSION,
|
|
5435
6748
|
running: runningPid(ctx),
|
|
5436
|
-
paused:
|
|
6749
|
+
paused: fs19.existsSync(pauseFile(ctx.dirs)),
|
|
5437
6750
|
signed_in: creds ? { kind: creds.kind, server: creds.server, device_name: creds.device_name ?? null } : null,
|
|
5438
6751
|
connector_id: connectorId,
|
|
5439
6752
|
agents,
|
|
@@ -5445,7 +6758,9 @@ function cmdStatus(args, ctx) {
|
|
|
5445
6758
|
warnings,
|
|
5446
6759
|
/** Why the connection last went down, and whether only a new sign-in can bring it back. */
|
|
5447
6760
|
last_disconnect: recentDisconnects(ctx.dirs, 1)[0] ?? null,
|
|
5448
|
-
needs_login: needsLogin(ctx.dirs)
|
|
6761
|
+
needs_login: needsLogin(ctx.dirs),
|
|
6762
|
+
/** The last `doctor` report's problems (null: doctor never ran). */
|
|
6763
|
+
doctor: lastDoctor(ctx)
|
|
5449
6764
|
};
|
|
5450
6765
|
if (values.json) {
|
|
5451
6766
|
ctx.out(JSON.stringify(status2, null, 2));
|
|
@@ -5459,11 +6774,22 @@ function cmdStatus(args, ctx) {
|
|
|
5459
6774
|
if (status2.needs_login) ctx.out(`problem: needs a new sign-in: ${status2.needs_login.reason}; run: claw-connector login`);
|
|
5460
6775
|
for (const p of status2.problems) ctx.out(`problem: ${p}`);
|
|
5461
6776
|
for (const w of status2.warnings) ctx.out(`warning: ${w}`);
|
|
6777
|
+
if (status2.doctor) ctx.out(`doctor: ${status2.doctor.problems.length ? `${status2.doctor.problems.length} problem(s) at ${status2.doctor.checked_at}; run: claw-connector doctor` : `no problems at ${status2.doctor.checked_at}`}`);
|
|
6778
|
+
else ctx.out("doctor: never run; run: claw-connector doctor");
|
|
5462
6779
|
return EXIT.ok;
|
|
5463
6780
|
}
|
|
6781
|
+
function lastDoctor(ctx) {
|
|
6782
|
+
try {
|
|
6783
|
+
const raw = JSON.parse(fs19.readFileSync(doctorFile(ctx.dirs), "utf8"));
|
|
6784
|
+
if (!isObject(raw) || typeof raw.checked_at !== "string" || !Array.isArray(raw.problems)) return null;
|
|
6785
|
+
return { checked_at: raw.checked_at, problems: raw.problems.filter(isObject).map((p) => ({ code: String(p.code), ...typeof p.agent === "string" ? { agent: p.agent } : {}, fix: String(p.fix) })) };
|
|
6786
|
+
} catch {
|
|
6787
|
+
return null;
|
|
6788
|
+
}
|
|
6789
|
+
}
|
|
5464
6790
|
async function cmdPause(ctx) {
|
|
5465
|
-
|
|
5466
|
-
|
|
6791
|
+
fs19.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
6792
|
+
fs19.writeFileSync(pauseFile(ctx.dirs), (/* @__PURE__ */ new Date()).toISOString() + "\n", { mode: 384 });
|
|
5467
6793
|
if (runningPid(ctx) === null) {
|
|
5468
6794
|
const left = await new ChildRegistry(childrenFile(ctx.dirs)).killAll();
|
|
5469
6795
|
ctx.out(`paused (the connector is not running${left.killed.length ? `; stopped ${left.killed.length} leftover agent process group(s)` : ""})`);
|
|
@@ -5473,15 +6799,15 @@ async function cmdPause(ctx) {
|
|
|
5473
6799
|
return EXIT.ok;
|
|
5474
6800
|
}
|
|
5475
6801
|
function cmdResume(ctx) {
|
|
5476
|
-
|
|
6802
|
+
fs19.rmSync(pauseFile(ctx.dirs), { force: true });
|
|
5477
6803
|
ctx.out(runningPid(ctx) === null ? "resumed; start it with: claw-connector run" : "resumed: the running connector reconnects now");
|
|
5478
6804
|
return EXIT.ok;
|
|
5479
6805
|
}
|
|
5480
6806
|
async function cmdStop(ctx, quiet = false) {
|
|
5481
6807
|
const pid = runningPid(ctx);
|
|
5482
6808
|
if (pid !== null) {
|
|
5483
|
-
|
|
5484
|
-
|
|
6809
|
+
fs19.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
6810
|
+
fs19.writeFileSync(stopRequestFile(ctx.dirs), JSON.stringify({ pid, start: processStart(pid) ?? "", at: (/* @__PURE__ */ new Date()).toISOString() }), { mode: 384 });
|
|
5485
6811
|
process.kill(pid, "SIGTERM");
|
|
5486
6812
|
const deadline = Date.now() + 15e3;
|
|
5487
6813
|
while (Date.now() < deadline && isAlive(pid) && processStart(pid) !== null) await sleep(100);
|
|
@@ -5489,7 +6815,7 @@ async function cmdStop(ctx, quiet = false) {
|
|
|
5489
6815
|
process.kill(pid, "SIGKILL");
|
|
5490
6816
|
if (!quiet) ctx.out(`the connector (pid ${pid}) did not stop in 15 s and was killed`);
|
|
5491
6817
|
}
|
|
5492
|
-
|
|
6818
|
+
fs19.rmSync(stopRequestFile(ctx.dirs), { force: true });
|
|
5493
6819
|
}
|
|
5494
6820
|
const left = await new ChildRegistry(childrenFile(ctx.dirs)).killAll();
|
|
5495
6821
|
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)` : ""}`);
|
|
@@ -5499,17 +6825,17 @@ function takeStopRequest(ctx) {
|
|
|
5499
6825
|
const file = stopRequestFile(ctx.dirs);
|
|
5500
6826
|
let mine = false;
|
|
5501
6827
|
try {
|
|
5502
|
-
const raw = JSON.parse(
|
|
6828
|
+
const raw = JSON.parse(fs19.readFileSync(file, "utf8"));
|
|
5503
6829
|
mine = isObject(raw) && raw.pid === process.pid && raw.start === (processStart(process.pid) ?? "");
|
|
5504
6830
|
} catch {
|
|
5505
6831
|
return false;
|
|
5506
6832
|
}
|
|
5507
|
-
if (mine)
|
|
6833
|
+
if (mine) fs19.rmSync(file, { force: true });
|
|
5508
6834
|
return mine;
|
|
5509
6835
|
}
|
|
5510
6836
|
function storedConnector(ctx) {
|
|
5511
6837
|
try {
|
|
5512
|
-
const raw = JSON.parse(
|
|
6838
|
+
const raw = JSON.parse(fs19.readFileSync(identityFile(ctx.dirs), "utf8"));
|
|
5513
6839
|
if (isObject(raw) && typeof raw.server === "string" && typeof raw.connector_id === "string") {
|
|
5514
6840
|
return { server: raw.server, connector_id: raw.connector_id, revoked: raw.revoked === true };
|
|
5515
6841
|
}
|
|
@@ -5581,8 +6907,8 @@ async function cmdLogout(ctx) {
|
|
|
5581
6907
|
ctx.err(`could not revoke the sign-in at the identity service (${errMessage(e)}); it was removed from this machine`);
|
|
5582
6908
|
}
|
|
5583
6909
|
}
|
|
5584
|
-
|
|
5585
|
-
if (rowGone)
|
|
6910
|
+
fs19.rmSync(file, { force: true });
|
|
6911
|
+
if (rowGone) fs19.rmSync(identityFile(ctx.dirs), { force: true });
|
|
5586
6912
|
else forgetRevokedIdentity(ctx);
|
|
5587
6913
|
if (row && notRevoked !== null) {
|
|
5588
6914
|
ctx.err(`signed out on this machine, but connector ${row.connector_id} was NOT revoked at Claw (${notRevoked}).`);
|
|
@@ -5620,6 +6946,7 @@ var onSignal = () => {
|
|
|
5620
6946
|
};
|
|
5621
6947
|
main(process.argv.slice(2), {
|
|
5622
6948
|
dirs: resolveDirs(),
|
|
6949
|
+
cli: fileURLToPath3(import.meta.url),
|
|
5623
6950
|
env: process.env,
|
|
5624
6951
|
out: (l) => process.stdout.write(l + "\n"),
|
|
5625
6952
|
err: (l) => process.stderr.write(l + "\n"),
|
|
@@ -5628,6 +6955,7 @@ main(process.argv.slice(2), {
|
|
|
5628
6955
|
onConnector: (c) => {
|
|
5629
6956
|
connector = c;
|
|
5630
6957
|
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, onSignal);
|
|
6958
|
+
process.on("SIGWINCH", () => c.recheckHealth());
|
|
5631
6959
|
}
|
|
5632
6960
|
}).then((code) => process.exit(code), (e) => {
|
|
5633
6961
|
process.stderr.write(`fatal: ${e instanceof Error ? e.stack : String(e)}
|