@primusclaw/connector 0.1.2 → 0.1.3
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 +47 -8
- package/dist/cli.js +752 -129
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/commands.ts
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { parseArgs } from "node:util";
|
|
4
|
+
import fs16 from "node:fs";
|
|
5
|
+
import os6 from "node:os";
|
|
6
|
+
import path17 from "node:path";
|
|
7
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
8
|
+
import { parseArgs as parseArgs2 } from "node:util";
|
|
9
9
|
|
|
10
10
|
// src/audit.ts
|
|
11
11
|
import fs from "node:fs";
|
|
@@ -335,11 +335,11 @@ var RevokedError = class extends Error {
|
|
|
335
335
|
}
|
|
336
336
|
};
|
|
337
337
|
var HttpError = class extends Error {
|
|
338
|
-
constructor(
|
|
339
|
-
super(`${
|
|
340
|
-
this.status =
|
|
338
|
+
constructor(status2, code, path18) {
|
|
339
|
+
super(`${path18} answered ${status2} ${code}`);
|
|
340
|
+
this.status = status2;
|
|
341
341
|
this.code = code;
|
|
342
|
-
this.path =
|
|
342
|
+
this.path = path18;
|
|
343
343
|
}
|
|
344
344
|
status;
|
|
345
345
|
code;
|
|
@@ -363,8 +363,8 @@ var ClawClient = class {
|
|
|
363
363
|
...extra
|
|
364
364
|
};
|
|
365
365
|
}
|
|
366
|
-
async send(method,
|
|
367
|
-
const url = this.o.server +
|
|
366
|
+
async send(method, path18, body, signal) {
|
|
367
|
+
const url = this.o.server + path18;
|
|
368
368
|
for (let attempt = 0; ; attempt++) {
|
|
369
369
|
const headers = await this.headers(body === void 0 ? {} : { "content-type": "application/json" });
|
|
370
370
|
let res;
|
|
@@ -376,7 +376,7 @@ var ClawClient = class {
|
|
|
376
376
|
signal: signal ?? AbortSignal.timeout(this.o.requestTimeoutMs ?? 2e4)
|
|
377
377
|
});
|
|
378
378
|
} catch (e) {
|
|
379
|
-
throw new TransportError(`${method} ${
|
|
379
|
+
throw new TransportError(`${method} ${path18}: ${errMessage(e)}`);
|
|
380
380
|
}
|
|
381
381
|
if (res.status !== 401) return res;
|
|
382
382
|
const err = await errorCode(res);
|
|
@@ -385,12 +385,12 @@ var ClawClient = class {
|
|
|
385
385
|
this.o.tokens.invalidate();
|
|
386
386
|
continue;
|
|
387
387
|
}
|
|
388
|
-
throw new HttpError(401, err,
|
|
388
|
+
throw new HttpError(401, err, path18);
|
|
389
389
|
}
|
|
390
390
|
}
|
|
391
|
-
async json(method,
|
|
392
|
-
const res = await this.send(method,
|
|
393
|
-
if (!res.ok) throw new HttpError(res.status, await errorCode(res),
|
|
391
|
+
async json(method, path18, body) {
|
|
392
|
+
const res = await this.send(method, path18, body);
|
|
393
|
+
if (!res.ok) throw new HttpError(res.status, await errorCode(res), path18);
|
|
394
394
|
const parsed = await res.json().catch(() => null);
|
|
395
395
|
return isObject(parsed) && isObject(parsed.data) ? parsed.data : {};
|
|
396
396
|
}
|
|
@@ -428,21 +428,21 @@ var ClawClient = class {
|
|
|
428
428
|
};
|
|
429
429
|
try {
|
|
430
430
|
arm();
|
|
431
|
-
const
|
|
431
|
+
const path18 = `/v1/remote-agents/connectors/${cid}/commands`;
|
|
432
432
|
const headers = await this.headers({ accept: "text/event-stream", ...opts.lastEventId ? { "last-event-id": String(opts.lastEventId) } : {} });
|
|
433
433
|
let res;
|
|
434
434
|
try {
|
|
435
|
-
res = await this.f(this.o.server +
|
|
435
|
+
res = await this.f(this.o.server + path18, { headers, signal: stop.signal });
|
|
436
436
|
} catch (e) {
|
|
437
|
-
throw new TransportError(timedOut ? `no response for ${opts.watchdogMs} ms` : `GET ${
|
|
437
|
+
throw new TransportError(timedOut ? `no response for ${opts.watchdogMs} ms` : `GET ${path18}: ${errMessage(e)}`);
|
|
438
438
|
}
|
|
439
439
|
if (res.status === 401) {
|
|
440
440
|
const err = await errorCode(res);
|
|
441
441
|
if (err === "revoked") throw new RevokedError();
|
|
442
442
|
this.o.tokens.invalidate();
|
|
443
|
-
throw new HttpError(401, err,
|
|
443
|
+
throw new HttpError(401, err, path18);
|
|
444
444
|
}
|
|
445
|
-
if (!res.ok || !res.body) throw new HttpError(res.status, await errorCode(res),
|
|
445
|
+
if (!res.ok || !res.body) throw new HttpError(res.status, await errorCode(res), path18);
|
|
446
446
|
try {
|
|
447
447
|
for await (const frame of readSse(res.body, arm)) yield frame;
|
|
448
448
|
} catch (e) {
|
|
@@ -1307,7 +1307,7 @@ function hooksStatus(configDir) {
|
|
|
1307
1307
|
}
|
|
1308
1308
|
|
|
1309
1309
|
// src/connector.ts
|
|
1310
|
-
import
|
|
1310
|
+
import fs14 from "node:fs";
|
|
1311
1311
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1312
1312
|
|
|
1313
1313
|
// src/attach.ts
|
|
@@ -1859,8 +1859,106 @@ function fitResumable(base, items, limit = REMOTE_AGENT_LIMITS.maxCapabilitiesBy
|
|
|
1859
1859
|
}
|
|
1860
1860
|
}
|
|
1861
1861
|
|
|
1862
|
-
// src/
|
|
1862
|
+
// src/health.ts
|
|
1863
|
+
import fs11 from "node:fs";
|
|
1864
|
+
import path12 from "node:path";
|
|
1865
|
+
|
|
1866
|
+
// src/paths.ts
|
|
1867
|
+
import os4 from "node:os";
|
|
1863
1868
|
import path11 from "node:path";
|
|
1869
|
+
function resolveDirs(env = process.env, home = os4.homedir()) {
|
|
1870
|
+
const one = env.CLAW_CONNECTOR_HOME?.trim();
|
|
1871
|
+
if (one) return { config: path11.join(one, "config"), state: path11.join(one, "state") };
|
|
1872
|
+
const cfg = env.XDG_CONFIG_HOME?.trim() || path11.join(home, ".config");
|
|
1873
|
+
const st = env.XDG_STATE_HOME?.trim() || path11.join(home, ".local", "state");
|
|
1874
|
+
return { config: path11.join(cfg, "claw-connector"), state: path11.join(st, "claw-connector") };
|
|
1875
|
+
}
|
|
1876
|
+
var configFile = (d) => path11.join(d.config, "agents.toml");
|
|
1877
|
+
var credentialsFile = (d) => path11.join(d.config, "credentials.json");
|
|
1878
|
+
var identityFile = (d) => path11.join(d.state, "connector.json");
|
|
1879
|
+
var sessionsFile = (d) => path11.join(d.state, "sessions.json");
|
|
1880
|
+
var childrenFile = (d) => path11.join(d.state, "children.json");
|
|
1881
|
+
var pidFile = (d) => path11.join(d.state, "connector.pid");
|
|
1882
|
+
var pauseFile = (d) => path11.join(d.state, "paused");
|
|
1883
|
+
var auditFile = (d) => path11.join(d.state, "audit.jsonl");
|
|
1884
|
+
var outboxDir = (d) => path11.join(d.state, "outbox");
|
|
1885
|
+
var logDir = (d) => path11.join(d.state, "logs");
|
|
1886
|
+
var disconnectsFile = (d) => path11.join(d.state, "disconnects.jsonl");
|
|
1887
|
+
var needsLoginFile = (d) => path11.join(d.state, "needs-login.json");
|
|
1888
|
+
var supervisorPidFile = (d) => path11.join(d.state, "supervisor.pid");
|
|
1889
|
+
var runLogFile = (d) => path11.join(d.state, "run.log");
|
|
1890
|
+
var keepaliveFile = (d) => path11.join(d.state, "keepalive.sh");
|
|
1891
|
+
var serviceFile = (d) => path11.join(d.state, "service.json");
|
|
1892
|
+
|
|
1893
|
+
// src/health.ts
|
|
1894
|
+
var MAX_BYTES = 128 * 1024;
|
|
1895
|
+
function recordDisconnect(dirs, d) {
|
|
1896
|
+
const file = disconnectsFile(dirs);
|
|
1897
|
+
try {
|
|
1898
|
+
fs11.mkdirSync(path12.dirname(file), { recursive: true, mode: 448 });
|
|
1899
|
+
const fd = fs11.openSync(file, "a+", 384);
|
|
1900
|
+
let lines = null;
|
|
1901
|
+
try {
|
|
1902
|
+
fs11.writeSync(fd, JSON.stringify({ at: d.at ?? (/* @__PURE__ */ new Date()).toISOString(), reason: d.reason, ...d.detail ? { detail: d.detail.slice(0, 300) } : {} }) + "\n");
|
|
1903
|
+
const size = fs11.fstatSync(fd).size;
|
|
1904
|
+
if (size > MAX_BYTES) {
|
|
1905
|
+
const buf = Buffer.alloc(size);
|
|
1906
|
+
let got = 0;
|
|
1907
|
+
while (got < size) {
|
|
1908
|
+
const n = fs11.readSync(fd, buf, got, size - got, got);
|
|
1909
|
+
if (n === 0) break;
|
|
1910
|
+
got += n;
|
|
1911
|
+
}
|
|
1912
|
+
lines = buf.subarray(0, got).toString("utf8").split("\n").filter(Boolean);
|
|
1913
|
+
}
|
|
1914
|
+
} finally {
|
|
1915
|
+
fs11.closeSync(fd);
|
|
1916
|
+
}
|
|
1917
|
+
if (lines) {
|
|
1918
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
1919
|
+
fs11.writeFileSync(tmp, lines.slice(Math.floor(lines.length / 2)).join("\n") + "\n", { mode: 384 });
|
|
1920
|
+
fs11.renameSync(tmp, file);
|
|
1921
|
+
}
|
|
1922
|
+
} catch {
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
function recentDisconnects(dirs, n = 1) {
|
|
1926
|
+
try {
|
|
1927
|
+
const out = [];
|
|
1928
|
+
for (const line of fs11.readFileSync(disconnectsFile(dirs), "utf8").split("\n").filter(Boolean).slice(-n)) {
|
|
1929
|
+
try {
|
|
1930
|
+
const v = JSON.parse(line);
|
|
1931
|
+
if (isObject(v) && typeof v.at === "string" && typeof v.reason === "string") out.push({ at: v.at, reason: v.reason, ...typeof v.detail === "string" ? { detail: v.detail } : {} });
|
|
1932
|
+
} catch {
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
return out;
|
|
1936
|
+
} catch {
|
|
1937
|
+
return [];
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
function markNeedsLogin(dirs, reason) {
|
|
1941
|
+
try {
|
|
1942
|
+
fs11.mkdirSync(dirs.state, { recursive: true, mode: 448 });
|
|
1943
|
+
fs11.writeFileSync(needsLoginFile(dirs), JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), reason: reason.slice(0, 300) }), { mode: 384 });
|
|
1944
|
+
} catch {
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
function needsLogin(dirs) {
|
|
1948
|
+
try {
|
|
1949
|
+
const v = JSON.parse(fs11.readFileSync(needsLoginFile(dirs), "utf8"));
|
|
1950
|
+
if (isObject(v) && typeof v.reason === "string") return { at: typeof v.at === "string" ? v.at : "", reason: v.reason };
|
|
1951
|
+
return { at: "", reason: "unreadable needs-login.json" };
|
|
1952
|
+
} catch (e) {
|
|
1953
|
+
return e.code === "ENOENT" ? null : { at: "", reason: "unreadable needs-login.json" };
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
function clearNeedsLogin(dirs) {
|
|
1957
|
+
fs11.rmSync(needsLoginFile(dirs), { force: true });
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
// src/session.ts
|
|
1961
|
+
import path13 from "node:path";
|
|
1864
1962
|
|
|
1865
1963
|
// src/permissions.ts
|
|
1866
1964
|
import { randomUUID } from "node:crypto";
|
|
@@ -2206,7 +2304,7 @@ var Session = class {
|
|
|
2206
2304
|
sessionId: this.id,
|
|
2207
2305
|
cwd: this.cwd,
|
|
2208
2306
|
hooks: this.hooks(),
|
|
2209
|
-
logFile:
|
|
2307
|
+
logFile: path13.join(this.env.logDir, `adapter-${this.agent.id}.log`),
|
|
2210
2308
|
version: this.env.version
|
|
2211
2309
|
}).catch((e) => {
|
|
2212
2310
|
throw new SessionRefusal("adapter_start_failed", errMessage(e));
|
|
@@ -2906,8 +3004,8 @@ var SessionManager = class {
|
|
|
2906
3004
|
};
|
|
2907
3005
|
|
|
2908
3006
|
// src/outbox.ts
|
|
2909
|
-
import
|
|
2910
|
-
import
|
|
3007
|
+
import fs12 from "node:fs";
|
|
3008
|
+
import path14 from "node:path";
|
|
2911
3009
|
var DROPPABLE_TYPES = /* @__PURE__ */ new Set([
|
|
2912
3010
|
"agent_message_chunk",
|
|
2913
3011
|
"agent_thought_chunk",
|
|
@@ -2934,35 +3032,35 @@ var Outbox = class {
|
|
|
2934
3032
|
this.dir = opts.dir;
|
|
2935
3033
|
this.maxBytes = opts.maxBytesPerSession ?? 5 * 1024 * 1024;
|
|
2936
3034
|
this.now = opts.now ?? Date.now;
|
|
2937
|
-
|
|
3035
|
+
fs12.mkdirSync(this.dir, { recursive: true, mode: 448 });
|
|
2938
3036
|
this.load();
|
|
2939
3037
|
}
|
|
2940
|
-
eventsFile = (sid) =>
|
|
2941
|
-
metaFile = (sid) =>
|
|
3038
|
+
eventsFile = (sid) => path14.join(this.dir, `${safeName(sid)}.jsonl`);
|
|
3039
|
+
metaFile = (sid) => path14.join(this.dir, `${safeName(sid)}.meta.json`);
|
|
2942
3040
|
writeMeta(sid) {
|
|
2943
3041
|
const tmp = this.metaFile(sid) + ".tmp";
|
|
2944
|
-
|
|
2945
|
-
|
|
3042
|
+
fs12.writeFileSync(tmp, JSON.stringify(this.meta.get(sid)), { mode: 384 });
|
|
3043
|
+
fs12.renameSync(tmp, this.metaFile(sid));
|
|
2946
3044
|
}
|
|
2947
3045
|
/** Rebuild the queue from disk. The highest sequence number ever used is the larger of the meta file and the log itself. */
|
|
2948
3046
|
load() {
|
|
2949
3047
|
const loaded = [];
|
|
2950
3048
|
const bases = /* @__PURE__ */ new Set();
|
|
2951
|
-
for (const name of
|
|
3049
|
+
for (const name of fs12.readdirSync(this.dir)) {
|
|
2952
3050
|
if (name.endsWith(".meta.json")) bases.add(name.slice(0, -".meta.json".length));
|
|
2953
3051
|
else if (name.endsWith(".jsonl")) bases.add(name.slice(0, -".jsonl".length));
|
|
2954
3052
|
}
|
|
2955
3053
|
for (const base of bases) {
|
|
2956
3054
|
let meta = { lastSeq: 0, acked: 0 };
|
|
2957
3055
|
try {
|
|
2958
|
-
meta = JSON.parse(
|
|
3056
|
+
meta = JSON.parse(fs12.readFileSync(path14.join(this.dir, `${base}.meta.json`), "utf8"));
|
|
2959
3057
|
} catch {
|
|
2960
3058
|
}
|
|
2961
3059
|
let sid = null;
|
|
2962
|
-
const file =
|
|
3060
|
+
const file = path14.join(this.dir, `${base}.jsonl`);
|
|
2963
3061
|
let text = "";
|
|
2964
3062
|
try {
|
|
2965
|
-
text =
|
|
3063
|
+
text = fs12.readFileSync(file, "utf8");
|
|
2966
3064
|
} catch {
|
|
2967
3065
|
}
|
|
2968
3066
|
for (const line of text.split("\n")) {
|
|
@@ -2980,8 +3078,8 @@ var Outbox = class {
|
|
|
2980
3078
|
if (sid === null) {
|
|
2981
3079
|
sid = base;
|
|
2982
3080
|
try {
|
|
2983
|
-
if (this.now() -
|
|
2984
|
-
for (const f of [`${base}.meta.json`, `${base}.jsonl`])
|
|
3081
|
+
if (this.now() - fs12.statSync(path14.join(this.dir, `${base}.meta.json`)).mtimeMs > TOMBSTONE_MS) {
|
|
3082
|
+
for (const f of [`${base}.meta.json`, `${base}.jsonl`]) fs12.rmSync(path14.join(this.dir, f), { force: true });
|
|
2985
3083
|
continue;
|
|
2986
3084
|
}
|
|
2987
3085
|
} catch {
|
|
@@ -3003,7 +3101,7 @@ var Outbox = class {
|
|
|
3003
3101
|
meta.lastSeq = ev.seq;
|
|
3004
3102
|
this.meta.set(sessionId, meta);
|
|
3005
3103
|
const line = JSON.stringify(ev);
|
|
3006
|
-
|
|
3104
|
+
fs12.appendFileSync(this.eventsFile(sessionId), line + "\n", { mode: 384 });
|
|
3007
3105
|
if (ev.seq === 1) this.writeMeta(sessionId);
|
|
3008
3106
|
this.queue.push(ev);
|
|
3009
3107
|
this.bytes.set(sessionId, (this.bytes.get(sessionId) ?? 0) + line.length);
|
|
@@ -3032,8 +3130,8 @@ var Outbox = class {
|
|
|
3032
3130
|
this.writeMeta(sessionId);
|
|
3033
3131
|
const mine = this.queue.filter((e) => e.session_id === sessionId);
|
|
3034
3132
|
const tmp = this.eventsFile(sessionId) + ".tmp";
|
|
3035
|
-
|
|
3036
|
-
|
|
3133
|
+
fs12.writeFileSync(tmp, mine.map((e) => JSON.stringify(e) + "\n").join(""), { mode: 384 });
|
|
3134
|
+
fs12.renameSync(tmp, this.eventsFile(sessionId));
|
|
3037
3135
|
}
|
|
3038
3136
|
/** The oldest events not yet acknowledged, in the order they were appended. */
|
|
3039
3137
|
pending(max) {
|
|
@@ -3071,7 +3169,7 @@ var Outbox = class {
|
|
|
3071
3169
|
this.writeMeta(sid);
|
|
3072
3170
|
const left = this.queue.filter((e) => e.session_id === sid);
|
|
3073
3171
|
if (left.length === 0) {
|
|
3074
|
-
|
|
3172
|
+
fs12.writeFileSync(this.eventsFile(sid), "", { mode: 384 });
|
|
3075
3173
|
this.bytes.set(sid, 0);
|
|
3076
3174
|
if (this.closed.has(sid)) this.forget(sid);
|
|
3077
3175
|
} else if (left.length < this.queue.length / 2 || meta.acked > 1e3) {
|
|
@@ -3090,48 +3188,27 @@ var Outbox = class {
|
|
|
3090
3188
|
* repeat. What stays is the small meta file, a tombstone, removed after {@link TOMBSTONE_MS}.
|
|
3091
3189
|
*/
|
|
3092
3190
|
forget(sid) {
|
|
3093
|
-
|
|
3191
|
+
fs12.rmSync(this.eventsFile(sid), { force: true });
|
|
3094
3192
|
if (this.meta.has(sid)) this.writeMeta(sid);
|
|
3095
3193
|
this.bytes.delete(sid);
|
|
3096
3194
|
this.closed.delete(sid);
|
|
3097
3195
|
}
|
|
3098
3196
|
};
|
|
3099
3197
|
|
|
3100
|
-
// src/paths.ts
|
|
3101
|
-
import os4 from "node:os";
|
|
3102
|
-
import path13 from "node:path";
|
|
3103
|
-
function resolveDirs(env = process.env, home = os4.homedir()) {
|
|
3104
|
-
const one = env.CLAW_CONNECTOR_HOME?.trim();
|
|
3105
|
-
if (one) return { config: path13.join(one, "config"), state: path13.join(one, "state") };
|
|
3106
|
-
const cfg = env.XDG_CONFIG_HOME?.trim() || path13.join(home, ".config");
|
|
3107
|
-
const st = env.XDG_STATE_HOME?.trim() || path13.join(home, ".local", "state");
|
|
3108
|
-
return { config: path13.join(cfg, "claw-connector"), state: path13.join(st, "claw-connector") };
|
|
3109
|
-
}
|
|
3110
|
-
var configFile = (d) => path13.join(d.config, "agents.toml");
|
|
3111
|
-
var credentialsFile = (d) => path13.join(d.config, "credentials.json");
|
|
3112
|
-
var identityFile = (d) => path13.join(d.state, "connector.json");
|
|
3113
|
-
var sessionsFile = (d) => path13.join(d.state, "sessions.json");
|
|
3114
|
-
var childrenFile = (d) => path13.join(d.state, "children.json");
|
|
3115
|
-
var pidFile = (d) => path13.join(d.state, "connector.pid");
|
|
3116
|
-
var pauseFile = (d) => path13.join(d.state, "paused");
|
|
3117
|
-
var auditFile = (d) => path13.join(d.state, "audit.jsonl");
|
|
3118
|
-
var outboxDir = (d) => path13.join(d.state, "outbox");
|
|
3119
|
-
var logDir = (d) => path13.join(d.state, "logs");
|
|
3120
|
-
|
|
3121
3198
|
// src/session-store.ts
|
|
3122
|
-
import
|
|
3123
|
-
import
|
|
3199
|
+
import fs13 from "node:fs";
|
|
3200
|
+
import path15 from "node:path";
|
|
3124
3201
|
var SessionStore = class {
|
|
3125
3202
|
constructor(file) {
|
|
3126
3203
|
this.file = file;
|
|
3127
3204
|
this.pendingFile = `${file}.pending`;
|
|
3128
3205
|
try {
|
|
3129
|
-
const raw = JSON.parse(
|
|
3206
|
+
const raw = JSON.parse(fs13.readFileSync(this.pendingFile, "utf8"));
|
|
3130
3207
|
if (Array.isArray(raw)) this.pending = raw.filter((p) => p && typeof p.session_id === "string" && typeof p.action_id === "string");
|
|
3131
3208
|
} catch {
|
|
3132
3209
|
}
|
|
3133
3210
|
try {
|
|
3134
|
-
const raw = JSON.parse(
|
|
3211
|
+
const raw = JSON.parse(fs13.readFileSync(file, "utf8"));
|
|
3135
3212
|
if (raw && typeof raw === "object" && !Array.isArray(raw)) this.recs = raw;
|
|
3136
3213
|
} catch {
|
|
3137
3214
|
}
|
|
@@ -3174,19 +3251,19 @@ var SessionStore = class {
|
|
|
3174
3251
|
return out;
|
|
3175
3252
|
}
|
|
3176
3253
|
savePending() {
|
|
3177
|
-
|
|
3254
|
+
fs13.mkdirSync(path15.dirname(this.pendingFile), { recursive: true, mode: 448 });
|
|
3178
3255
|
const tmp = `${this.pendingFile}.${process.pid}.tmp`;
|
|
3179
|
-
|
|
3180
|
-
|
|
3256
|
+
fs13.writeFileSync(tmp, JSON.stringify(this.pending), { mode: 384 });
|
|
3257
|
+
fs13.renameSync(tmp, this.pendingFile);
|
|
3181
3258
|
}
|
|
3182
3259
|
ids() {
|
|
3183
3260
|
return Object.keys(this.recs);
|
|
3184
3261
|
}
|
|
3185
3262
|
save() {
|
|
3186
|
-
|
|
3263
|
+
fs13.mkdirSync(path15.dirname(this.file), { recursive: true, mode: 448 });
|
|
3187
3264
|
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
3188
|
-
|
|
3189
|
-
|
|
3265
|
+
fs13.writeFileSync(tmp, JSON.stringify(this.recs), { mode: 384 });
|
|
3266
|
+
fs13.renameSync(tmp, this.file);
|
|
3190
3267
|
}
|
|
3191
3268
|
};
|
|
3192
3269
|
|
|
@@ -3202,7 +3279,9 @@ var DEFAULT_TIMING = {
|
|
|
3202
3279
|
pausePollMs: 1e3,
|
|
3203
3280
|
catalogMs: 12e4,
|
|
3204
3281
|
attachCliMs: 8e3,
|
|
3205
|
-
recentWriteMs: 1e4
|
|
3282
|
+
recentWriteMs: 1e4,
|
|
3283
|
+
authFailLimit: 4,
|
|
3284
|
+
authFailWindowMs: 12e4
|
|
3206
3285
|
};
|
|
3207
3286
|
var CommandLedger = class {
|
|
3208
3287
|
inflight = /* @__PURE__ */ new Set();
|
|
@@ -3242,6 +3321,7 @@ var CommandLedger = class {
|
|
|
3242
3321
|
return this.highest;
|
|
3243
3322
|
}
|
|
3244
3323
|
};
|
|
3324
|
+
var isAuthRefusal = (e) => e instanceof HttpError && (e.status === 401 || e.status === 403);
|
|
3245
3325
|
function isTransient(e) {
|
|
3246
3326
|
return e instanceof TransientAuthError || e instanceof TransportError || e instanceof HttpError && (e.status >= 500 || e.status === 429);
|
|
3247
3327
|
}
|
|
@@ -3294,6 +3374,13 @@ var Connector = class {
|
|
|
3294
3374
|
/** The connector row itself was revoked (not just the sign-in): recorded in connector.json for `login`. */
|
|
3295
3375
|
rowRevoked = false;
|
|
3296
3376
|
paused = false;
|
|
3377
|
+
/** The credential was refused `authFailLimit` times in a row (see {@link ConnectorTiming.authFailLimit}). */
|
|
3378
|
+
authFailed = false;
|
|
3379
|
+
authFailures = 0;
|
|
3380
|
+
authFirstAt = 0;
|
|
3381
|
+
authWhy = "";
|
|
3382
|
+
/** The last event on the command stream, to tell why the server ended it (`superseded`, `token_expired`, a release). */
|
|
3383
|
+
lastFrame = "";
|
|
3297
3384
|
wakeSender = null;
|
|
3298
3385
|
wakeHeartbeat = null;
|
|
3299
3386
|
streamCtl = null;
|
|
@@ -3326,7 +3413,7 @@ var Connector = class {
|
|
|
3326
3413
|
// --- identity -------------------------------------------------------------------
|
|
3327
3414
|
readIdentity() {
|
|
3328
3415
|
try {
|
|
3329
|
-
const raw = JSON.parse(
|
|
3416
|
+
const raw = JSON.parse(fs14.readFileSync(identityFile(this.d.dirs), "utf8"));
|
|
3330
3417
|
if (isObject(raw) && raw.server === this.d.server && typeof raw.connector_id === "string") {
|
|
3331
3418
|
return { server: raw.server, connector_id: raw.connector_id, cursor: typeof raw.cursor === "number" ? raw.cursor : 0, handled: Array.isArray(raw.handled) ? raw.handled.filter((x) => typeof x === "string") : [] };
|
|
3332
3419
|
}
|
|
@@ -3337,9 +3424,9 @@ var Connector = class {
|
|
|
3337
3424
|
writeIdentity() {
|
|
3338
3425
|
const st = { server: this.d.server, connector_id: this.cid, cursor: this.ledger.cursor, handled: this.ledger.recentIds, ...this.rowRevoked ? { revoked: true } : {} };
|
|
3339
3426
|
const file = identityFile(this.d.dirs);
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3427
|
+
fs14.mkdirSync(this.d.dirs.state, { recursive: true, mode: 448 });
|
|
3428
|
+
fs14.writeFileSync(`${file}.tmp`, JSON.stringify(st), { mode: 384 });
|
|
3429
|
+
fs14.renameSync(`${file}.tmp`, file);
|
|
3343
3430
|
}
|
|
3344
3431
|
/** Use the connector id this machine already has, or register a new one. */
|
|
3345
3432
|
async identify(forceNew = false) {
|
|
@@ -3424,12 +3511,29 @@ var Connector = class {
|
|
|
3424
3511
|
this.streamCtl?.abort();
|
|
3425
3512
|
await this.manager.killAll("revoked");
|
|
3426
3513
|
}
|
|
3514
|
+
/** One more refusal of the credential; the run ends once `authFailLimit` come in a row. Returns whether it ended. */
|
|
3515
|
+
async authRefused(e) {
|
|
3516
|
+
if (this.authFailures++ === 0) this.authFirstAt = Date.now();
|
|
3517
|
+
if (this.authFailed) return true;
|
|
3518
|
+
if (this.authFailures < this.timing.authFailLimit || Date.now() - this.authFirstAt < this.timing.authFailWindowMs) return false;
|
|
3519
|
+
this.authFailed = true;
|
|
3520
|
+
this.authWhy = errMessage(e);
|
|
3521
|
+
this.d.log.error("Claw keeps refusing this sign-in: stopping every agent; run login again", { why: this.authWhy, refusals: this.authFailures });
|
|
3522
|
+
this.d.audit.record("auth_failed", { why: this.authWhy });
|
|
3523
|
+
this.ctl.abort();
|
|
3524
|
+
this.streamCtl?.abort();
|
|
3525
|
+
await this.manager.killAll("stopped");
|
|
3526
|
+
return true;
|
|
3527
|
+
}
|
|
3528
|
+
get authFailure() {
|
|
3529
|
+
return this.authWhy;
|
|
3530
|
+
}
|
|
3427
3531
|
isRevocation(e) {
|
|
3428
3532
|
return e instanceof RevokedError || e instanceof GrantRevokedError;
|
|
3429
3533
|
}
|
|
3430
3534
|
async pauseWatcher() {
|
|
3431
3535
|
while (!this.ctl.signal.aborted) {
|
|
3432
|
-
const want =
|
|
3536
|
+
const want = fs14.existsSync(pauseFile(this.d.dirs));
|
|
3433
3537
|
if (want && !this.paused) {
|
|
3434
3538
|
this.paused = true;
|
|
3435
3539
|
this.d.audit.record("paused");
|
|
@@ -3500,14 +3604,17 @@ var Connector = class {
|
|
|
3500
3604
|
if (this.paused) continue;
|
|
3501
3605
|
try {
|
|
3502
3606
|
await this.heartbeat();
|
|
3607
|
+
this.authFailures = 0;
|
|
3503
3608
|
this.writeIdentity();
|
|
3504
3609
|
} catch (e) {
|
|
3505
3610
|
if (this.isRevocation(e)) return void await this.onRevoked(e);
|
|
3611
|
+
if (isAuthRefusal(e) && await this.authRefused(e)) return;
|
|
3506
3612
|
this.d.log.warn("heartbeat failed", { error: errMessage(e) });
|
|
3507
3613
|
}
|
|
3508
3614
|
}
|
|
3509
3615
|
}
|
|
3510
3616
|
onFrame(f, reconnect) {
|
|
3617
|
+
if (f.event) this.lastFrame = f.event;
|
|
3511
3618
|
switch (f.event) {
|
|
3512
3619
|
case "ready":
|
|
3513
3620
|
if (reconnect) this.manager.resendPending();
|
|
@@ -3556,17 +3663,26 @@ var Connector = class {
|
|
|
3556
3663
|
try {
|
|
3557
3664
|
const sig = AbortSignal.any([this.ctl.signal, this.streamCtl.signal]);
|
|
3558
3665
|
let got = false;
|
|
3666
|
+
this.lastFrame = "";
|
|
3559
3667
|
for await (const f of this.d.client.commands(this.cid, { lastEventId: this.ledger.highestSeen || void 0, signal: sig, watchdogMs: this.timing.watchdogMs })) {
|
|
3560
3668
|
if (!got) {
|
|
3561
3669
|
got = true;
|
|
3562
3670
|
attempt = 0;
|
|
3671
|
+
this.authFailures = 0;
|
|
3563
3672
|
}
|
|
3564
3673
|
this.onFrame(f, reconnect);
|
|
3565
3674
|
if (f.event === "ready") reconnect = true;
|
|
3566
3675
|
}
|
|
3676
|
+
if (!this.ctl.signal.aborted && !this.paused) {
|
|
3677
|
+
const why = this.lastFrame === "superseded" || this.lastFrame === "token_expired" ? this.lastFrame : "the server closed the stream (a Claw release or restart)";
|
|
3678
|
+
this.d.log.info("command stream ended by the server; reconnecting", { why });
|
|
3679
|
+
recordDisconnect(this.d.dirs, { reason: "server_closed", detail: why });
|
|
3680
|
+
}
|
|
3567
3681
|
} catch (e) {
|
|
3568
3682
|
if (this.isRevocation(e)) return void await this.onRevoked(e);
|
|
3569
3683
|
if (this.ctl.signal.aborted) break;
|
|
3684
|
+
if (isAuthRefusal(e) && await this.authRefused(e)) return;
|
|
3685
|
+
recordDisconnect(this.d.dirs, { reason: "stream_lost", detail: errMessage(e) });
|
|
3570
3686
|
if (e instanceof HttpError && e.status === 404) {
|
|
3571
3687
|
try {
|
|
3572
3688
|
await this.identify(true);
|
|
@@ -3585,18 +3701,34 @@ var Connector = class {
|
|
|
3585
3701
|
}
|
|
3586
3702
|
/** Run until stopped or revoked. Always leaves no adapter process behind. */
|
|
3587
3703
|
async run() {
|
|
3588
|
-
|
|
3704
|
+
fs14.mkdirSync(this.d.dirs.state, { recursive: true, mode: 448 });
|
|
3589
3705
|
await this.registry.killAll();
|
|
3590
3706
|
for (const p of this.store.takePending()) {
|
|
3591
3707
|
this.d.audit.record("permission_orphaned", { session_id: p.session_id, action_id: p.action_id });
|
|
3592
3708
|
this.emit(p.session_id, "permission_resolved", { actionId: p.action_id, optionId: null, outcome: "cancelled", by: "connector_restart" });
|
|
3593
3709
|
}
|
|
3594
3710
|
const pidf = pidFile(this.d.dirs);
|
|
3595
|
-
|
|
3596
|
-
|
|
3711
|
+
let leftover = false;
|
|
3712
|
+
try {
|
|
3713
|
+
fs14.readFileSync(pidf);
|
|
3714
|
+
leftover = true;
|
|
3715
|
+
} catch {
|
|
3716
|
+
}
|
|
3717
|
+
if (leftover) {
|
|
3718
|
+
let last;
|
|
3719
|
+
try {
|
|
3720
|
+
last = fs14.statSync(identityFile(this.d.dirs)).mtime.toISOString();
|
|
3721
|
+
} catch {
|
|
3722
|
+
}
|
|
3723
|
+
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 } : {} });
|
|
3724
|
+
}
|
|
3725
|
+
fs14.writeFileSync(pidf, JSON.stringify({ pid: process.pid, start: processStart(process.pid) ?? "" }), { mode: 384 });
|
|
3726
|
+
this.paused = fs14.existsSync(pauseFile(this.d.dirs));
|
|
3727
|
+
let result = "stopped";
|
|
3728
|
+
let failure = "";
|
|
3597
3729
|
try {
|
|
3598
3730
|
const up = await this.startup();
|
|
3599
|
-
if (up !== "ok") return up;
|
|
3731
|
+
if (up !== "ok") return result = up;
|
|
3600
3732
|
this.d.audit.record("connector_online", {
|
|
3601
3733
|
connector_id: this.cid,
|
|
3602
3734
|
agents: this.d.config.agents.map((a) => a.id),
|
|
@@ -3604,11 +3736,17 @@ var Connector = class {
|
|
|
3604
3736
|
unattended_agents: this.d.config.agents.filter((a) => a.unattended).map((a) => a.id)
|
|
3605
3737
|
});
|
|
3606
3738
|
await Promise.all([this.sender(), this.heartbeater(), this.streamLoop(), this.pauseWatcher()]);
|
|
3739
|
+
result = this.revoked ? "revoked" : this.authFailed ? "auth_failed" : "stopped";
|
|
3740
|
+
} catch (e) {
|
|
3741
|
+
failure = errMessage(e);
|
|
3742
|
+
throw e;
|
|
3607
3743
|
} finally {
|
|
3608
3744
|
await this.manager.killAll(this.revoked ? "revoked" : "stopped").catch(() => void 0);
|
|
3609
|
-
|
|
3745
|
+
const reason = this.revoked ? "revoked" : this.authFailed ? "auth_failed" : failure ? "failed" : this.paused ? "stopped_while_paused" : result;
|
|
3746
|
+
recordDisconnect(this.d.dirs, { reason, detail: this.authWhy || failure || void 0 });
|
|
3747
|
+
fs14.rmSync(pidf, { force: true });
|
|
3610
3748
|
}
|
|
3611
|
-
return
|
|
3749
|
+
return result;
|
|
3612
3750
|
}
|
|
3613
3751
|
/** For tests: wait until every event is acknowledged. */
|
|
3614
3752
|
async drained(timeoutMs = 5e3) {
|
|
@@ -3621,11 +3759,432 @@ var Connector = class {
|
|
|
3621
3759
|
}
|
|
3622
3760
|
};
|
|
3623
3761
|
|
|
3762
|
+
// src/service.ts
|
|
3763
|
+
import { spawn as spawn2, spawnSync } from "node:child_process";
|
|
3764
|
+
import fs15 from "node:fs";
|
|
3765
|
+
import os5 from "node:os";
|
|
3766
|
+
import path16 from "node:path";
|
|
3767
|
+
import { fileURLToPath } from "node:url";
|
|
3768
|
+
import { parseArgs } from "node:util";
|
|
3769
|
+
var SUPERVISORS = ["systemd", "launchd", "script"];
|
|
3770
|
+
var UNIT = "claw-connector.service";
|
|
3771
|
+
var LABEL = "com.primusclaw.connector";
|
|
3772
|
+
var RESTART_SEC = 5;
|
|
3773
|
+
var NEEDS_PERSON = 78;
|
|
3774
|
+
var CARRIED_ENV = [
|
|
3775
|
+
"PATH",
|
|
3776
|
+
"CLAW_CONNECTOR_HOME",
|
|
3777
|
+
"XDG_CONFIG_HOME",
|
|
3778
|
+
"XDG_STATE_HOME",
|
|
3779
|
+
"CLAUDE_CONFIG_DIR",
|
|
3780
|
+
"CODEX_HOME",
|
|
3781
|
+
"NODE_EXTRA_CA_CERTS",
|
|
3782
|
+
"SSL_CERT_FILE",
|
|
3783
|
+
"HTTPS_PROXY",
|
|
3784
|
+
"https_proxy",
|
|
3785
|
+
"HTTP_PROXY",
|
|
3786
|
+
"http_proxy",
|
|
3787
|
+
"NO_PROXY",
|
|
3788
|
+
"no_proxy",
|
|
3789
|
+
"LANG"
|
|
3790
|
+
];
|
|
3791
|
+
var SECRET_NAME = /KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL/i;
|
|
3792
|
+
function deps(ctx) {
|
|
3793
|
+
const d = ctx.service ?? {};
|
|
3794
|
+
return {
|
|
3795
|
+
platform: d.platform ?? process.platform,
|
|
3796
|
+
home: d.home ?? os5.homedir(),
|
|
3797
|
+
uid: d.uid ?? (process.getuid?.() ?? 0),
|
|
3798
|
+
user: d.user ?? (ctx.env.USER || os5.userInfo().username),
|
|
3799
|
+
exec: d.exec ?? ((cmd, args) => {
|
|
3800
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
|
|
3801
|
+
return { code: r.error ? null : r.status, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
|
|
3802
|
+
}),
|
|
3803
|
+
node: d.node ?? ctx.node ?? process.execPath,
|
|
3804
|
+
cli: d.cli ?? fileURLToPath(import.meta.url),
|
|
3805
|
+
systemdBooted: d.systemdBooted ?? (() => fs15.existsSync("/run/systemd/system"))
|
|
3806
|
+
};
|
|
3807
|
+
}
|
|
3808
|
+
var systemdUnitPath = (ctx, d) => path16.join(ctx.env.XDG_CONFIG_HOME?.trim() || path16.join(d.home, ".config"), "systemd", "user", UNIT);
|
|
3809
|
+
var launchdPlistPath = (d) => path16.join(d.home, "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
3810
|
+
function detectSupervisor(d) {
|
|
3811
|
+
if (d.platform === "darwin") return "launchd";
|
|
3812
|
+
if (!d.systemdBooted()) return "script";
|
|
3813
|
+
const state = d.exec("systemctl", ["--user", "is-system-running"]).out.trim();
|
|
3814
|
+
return ["running", "degraded", "starting", "initializing"].includes(state) ? "systemd" : "script";
|
|
3815
|
+
}
|
|
3816
|
+
var hasUserinfo = (v) => {
|
|
3817
|
+
try {
|
|
3818
|
+
const u = new URL(v);
|
|
3819
|
+
return u.username !== "" || u.password !== "";
|
|
3820
|
+
} catch {
|
|
3821
|
+
return /\/\/[^/]*@/.test(v);
|
|
3822
|
+
}
|
|
3823
|
+
};
|
|
3824
|
+
function carriedEnv(env, extra = []) {
|
|
3825
|
+
const vars = [];
|
|
3826
|
+
const refused = [];
|
|
3827
|
+
const withheld = [];
|
|
3828
|
+
for (const name of /* @__PURE__ */ new Set([...CARRIED_ENV, ...extra])) {
|
|
3829
|
+
if (!CARRIED_ENV.includes(name) && SECRET_NAME.test(name)) {
|
|
3830
|
+
refused.push(name);
|
|
3831
|
+
continue;
|
|
3832
|
+
}
|
|
3833
|
+
const v = env[name];
|
|
3834
|
+
if (v === void 0 || v === "") continue;
|
|
3835
|
+
if (/proxy$/i.test(name) && hasUserinfo(v) && !extra.includes(name)) {
|
|
3836
|
+
withheld.push(name);
|
|
3837
|
+
continue;
|
|
3838
|
+
}
|
|
3839
|
+
vars.push([name, v]);
|
|
3840
|
+
}
|
|
3841
|
+
return { vars, refused, withheld };
|
|
3842
|
+
}
|
|
3843
|
+
var sdEnv = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
|
|
3844
|
+
var sdQuote = (s) => sdEnv(s).replace(/\$/g, "$$$$");
|
|
3845
|
+
var xml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
3846
|
+
var sh = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
3847
|
+
function systemdUnit(d, vars) {
|
|
3848
|
+
return `# Written by "claw-connector service install"; remove with "claw-connector service uninstall".
|
|
3849
|
+
[Unit]
|
|
3850
|
+
Description=Claw connector (claw-connector run)
|
|
3851
|
+
Wants=network-online.target
|
|
3852
|
+
After=network-online.target
|
|
3853
|
+
# Never give up: a machine that was offline for an hour must still come back by itself.
|
|
3854
|
+
StartLimitIntervalSec=0
|
|
3855
|
+
|
|
3856
|
+
[Service]
|
|
3857
|
+
Type=simple
|
|
3858
|
+
ExecStart=${sdQuote(d.node)} ${sdQuote(d.cli)} run --supervised
|
|
3859
|
+
${vars.map(([k, v]) => `Environment=${sdEnv(`${k}=${v}`)}`).join("\n")}
|
|
3860
|
+
Restart=on-failure
|
|
3861
|
+
RestartSec=${RESTART_SEC}
|
|
3862
|
+
# 78: revoked, sign-in refused, or no usable config. Restarting cannot help; "claw-connector login" can.
|
|
3863
|
+
RestartPreventExitStatus=${NEEDS_PERSON}
|
|
3864
|
+
# "claw-connector stop" and "systemctl --user stop" both end it cleanly (exit 0), which is not restarted.
|
|
3865
|
+
KillMode=mixed
|
|
3866
|
+
TimeoutStopSec=20
|
|
3867
|
+
|
|
3868
|
+
[Install]
|
|
3869
|
+
WantedBy=default.target
|
|
3870
|
+
`;
|
|
3871
|
+
}
|
|
3872
|
+
function launchdPlist(d, vars, log) {
|
|
3873
|
+
const env = [...vars, ["CLAW_CONNECTOR_SUPERVISOR", "launchd"]];
|
|
3874
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
3875
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3876
|
+
<!-- Written by "claw-connector service install"; remove with "claw-connector service uninstall". -->
|
|
3877
|
+
<plist version="1.0">
|
|
3878
|
+
<dict>
|
|
3879
|
+
<key>Label</key><string>${LABEL}</string>
|
|
3880
|
+
<key>ProgramArguments</key>
|
|
3881
|
+
<array><string>${xml(d.node)}</string><string>${xml(d.cli)}</string><string>run</string><string>--supervised</string></array>
|
|
3882
|
+
<key>EnvironmentVariables</key>
|
|
3883
|
+
<dict>
|
|
3884
|
+
${env.map(([k, v]) => ` <key>${xml(k)}</key><string>${xml(v)}</string>`).join("\n")}
|
|
3885
|
+
</dict>
|
|
3886
|
+
<key>RunAtLoad</key><true/>
|
|
3887
|
+
<!-- Restarted after any exit but a clean one. After a revocation a restarted run sees needs-login.json and exits 0. -->
|
|
3888
|
+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
3889
|
+
<key>ThrottleInterval</key><integer>${RESTART_SEC}</integer>
|
|
3890
|
+
<key>StandardOutPath</key><string>${xml(log)}</string>
|
|
3891
|
+
<key>StandardErrorPath</key><string>${xml(log)}</string>
|
|
3892
|
+
</dict>
|
|
3893
|
+
</plist>
|
|
3894
|
+
`;
|
|
3895
|
+
}
|
|
3896
|
+
function keepaliveScript(d, vars) {
|
|
3897
|
+
return `#!/bin/sh
|
|
3898
|
+
# Written by "claw-connector service install --supervisor script". Idempotent: starts the connector's supervisor
|
|
3899
|
+
# unless it is already running, and does nothing after a revocation until "claw-connector login".
|
|
3900
|
+
# Call it from the container's startup hook and every 5 minutes, for example:
|
|
3901
|
+
# @reboot sh ${"$"}HOME/.../keepalive.sh */5 * * * * sh ${"$"}HOME/.../keepalive.sh
|
|
3902
|
+
${vars.map(([k, v]) => `export ${k}=${sh(v)}`).join("\n")}
|
|
3903
|
+
exec ${sh(d.node)} ${sh(d.cli)} service ensure
|
|
3904
|
+
`;
|
|
3905
|
+
}
|
|
3906
|
+
function readPidRec(file) {
|
|
3907
|
+
try {
|
|
3908
|
+
const raw = JSON.parse(fs15.readFileSync(file, "utf8"));
|
|
3909
|
+
if (isObject(raw) && typeof raw.pid === "number" && typeof raw.start === "string" && raw.start !== "") return { pid: raw.pid, start: raw.start };
|
|
3910
|
+
} catch {
|
|
3911
|
+
}
|
|
3912
|
+
return null;
|
|
3913
|
+
}
|
|
3914
|
+
function supervisorPid(ctx) {
|
|
3915
|
+
const r = readPidRec(supervisorPidFile(ctx.dirs));
|
|
3916
|
+
return r && processStart(r.pid) === r.start ? r.pid : null;
|
|
3917
|
+
}
|
|
3918
|
+
async function supervise(ctx, o = {}) {
|
|
3919
|
+
const d = deps(ctx);
|
|
3920
|
+
const [cmd, args] = o.command ?? [d.node, [d.cli, "run", "--supervised"]];
|
|
3921
|
+
const restartMs = o.restartMs ?? RESTART_SEC * 1e3;
|
|
3922
|
+
const pidf = supervisorPidFile(ctx.dirs);
|
|
3923
|
+
const other = supervisorPid(ctx);
|
|
3924
|
+
if (other !== null && other !== process.pid) {
|
|
3925
|
+
ctx.err(`a supervisor is already running (pid ${other})`);
|
|
3926
|
+
return 1;
|
|
3927
|
+
}
|
|
3928
|
+
fs15.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
3929
|
+
fs15.writeFileSync(pidf, JSON.stringify({ pid: process.pid, start: processStart(process.pid) ?? "" }), { mode: 384 });
|
|
3930
|
+
let stopping2 = false;
|
|
3931
|
+
let child = null;
|
|
3932
|
+
const wake = new AbortController();
|
|
3933
|
+
const onSig = (sig) => {
|
|
3934
|
+
stopping2 = true;
|
|
3935
|
+
wake.abort();
|
|
3936
|
+
child?.kill(sig);
|
|
3937
|
+
};
|
|
3938
|
+
const sigs = ["SIGTERM", "SIGINT", "SIGHUP"];
|
|
3939
|
+
const handlers = sigs.map((s) => [s, () => onSig(s)]);
|
|
3940
|
+
for (const [s, h] of handlers) process.on(s, h);
|
|
3941
|
+
o.signal?.addEventListener("abort", () => onSig("SIGTERM"), { once: true });
|
|
3942
|
+
try {
|
|
3943
|
+
for (let round = 0; !stopping2; round++) {
|
|
3944
|
+
child = spawn2(cmd, args, { stdio: ["ignore", "inherit", "inherit"], env: ctx.env });
|
|
3945
|
+
const c = child;
|
|
3946
|
+
const { code, signal } = await new Promise((resolve) => {
|
|
3947
|
+
c.once("exit", (code2, signal2) => resolve({ code: code2, signal: signal2 }));
|
|
3948
|
+
c.once("error", (e) => {
|
|
3949
|
+
ctx.err(`supervisor: cannot start the connector: ${errMessage(e)}`);
|
|
3950
|
+
resolve({ code: 1, signal: null });
|
|
3951
|
+
});
|
|
3952
|
+
});
|
|
3953
|
+
child = null;
|
|
3954
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
3955
|
+
if (stopping2) return 0;
|
|
3956
|
+
if (code === 0) {
|
|
3957
|
+
ctx.err(`${at} supervisor: the connector stopped on purpose (exit 0); not restarting`);
|
|
3958
|
+
return 0;
|
|
3959
|
+
}
|
|
3960
|
+
if (code === NEEDS_PERSON) {
|
|
3961
|
+
ctx.err(`${at} supervisor: the connector needs a new sign-in (exit 78); not restarting until: claw-connector login`);
|
|
3962
|
+
return NEEDS_PERSON;
|
|
3963
|
+
}
|
|
3964
|
+
const how = signal ? `killed by ${signal}` : `exit ${code}`;
|
|
3965
|
+
if (signal) recordDisconnect(ctx.dirs, { reason: "process_died", detail: `the connector was ${how}; restarted by the supervisor` });
|
|
3966
|
+
ctx.err(`${at} supervisor: the connector ended (${how}); restarting in ${restartMs / 1e3} s`);
|
|
3967
|
+
await sleep(restartMs, wake.signal);
|
|
3968
|
+
}
|
|
3969
|
+
return 0;
|
|
3970
|
+
} finally {
|
|
3971
|
+
for (const [s, h] of handlers) process.off(s, h);
|
|
3972
|
+
const rec = readPidRec(pidf);
|
|
3973
|
+
if (rec?.pid === process.pid) fs15.rmSync(pidf, { force: true });
|
|
3974
|
+
}
|
|
3975
|
+
}
|
|
3976
|
+
async function cmdSupervise(_args, ctx) {
|
|
3977
|
+
return supervise(ctx);
|
|
3978
|
+
}
|
|
3979
|
+
async function ensure(ctx, d, h) {
|
|
3980
|
+
const sup = supervisorPid(ctx);
|
|
3981
|
+
if (sup !== null) {
|
|
3982
|
+
ctx.out(`supervisor running (pid ${sup})`);
|
|
3983
|
+
return 0;
|
|
3984
|
+
}
|
|
3985
|
+
const problem = h.preflight(ctx);
|
|
3986
|
+
if (problem) {
|
|
3987
|
+
ctx.err(`not starting: ${problem}`);
|
|
3988
|
+
return NEEDS_PERSON;
|
|
3989
|
+
}
|
|
3990
|
+
const run2 = h.runningPid(ctx);
|
|
3991
|
+
if (run2 !== null) {
|
|
3992
|
+
ctx.out(`the connector is running (pid ${run2}) without the supervisor; leaving it`);
|
|
3993
|
+
return 0;
|
|
3994
|
+
}
|
|
3995
|
+
fs15.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
3996
|
+
const log = fs15.openSync(runLogFile(ctx.dirs), "a", 384);
|
|
3997
|
+
try {
|
|
3998
|
+
const p = spawn2(d.node, [d.cli, "supervise"], { detached: true, cwd: d.home, stdio: ["ignore", log, log], env: ctx.env });
|
|
3999
|
+
p.unref();
|
|
4000
|
+
ctx.out(`started the supervisor (pid ${p.pid}); log: ${runLogFile(ctx.dirs)}`);
|
|
4001
|
+
} finally {
|
|
4002
|
+
fs15.closeSync(log);
|
|
4003
|
+
}
|
|
4004
|
+
return 0;
|
|
4005
|
+
}
|
|
4006
|
+
async function cmdService(args, ctx, h) {
|
|
4007
|
+
const [sub, ...rest] = args;
|
|
4008
|
+
const usage = "usage: claw-connector service install|uninstall|status [--supervisor systemd|launchd|script] [--env NAME ...]";
|
|
4009
|
+
if (!["install", "uninstall", "status", "ensure"].includes(sub ?? "")) {
|
|
4010
|
+
ctx.err(usage);
|
|
4011
|
+
return 2;
|
|
4012
|
+
}
|
|
4013
|
+
const { values } = parseArgs({ args: rest, strict: true, options: { supervisor: { type: "string" }, env: { type: "string", multiple: true }, json: { type: "boolean" } } });
|
|
4014
|
+
const d = deps(ctx);
|
|
4015
|
+
if (sub === "ensure") return ensure(ctx, d, h);
|
|
4016
|
+
let sup;
|
|
4017
|
+
if (values.supervisor !== void 0) {
|
|
4018
|
+
if (!SUPERVISORS.includes(values.supervisor)) {
|
|
4019
|
+
ctx.err(usage);
|
|
4020
|
+
return 2;
|
|
4021
|
+
}
|
|
4022
|
+
sup = values.supervisor;
|
|
4023
|
+
} else {
|
|
4024
|
+
sup = sub === "install" ? detectSupervisor(d) : installedSupervisor(ctx) ?? detectSupervisor(d);
|
|
4025
|
+
}
|
|
4026
|
+
if (sub === "install") return install(ctx, d, h, sup, values.env ?? []);
|
|
4027
|
+
if (sub === "uninstall") return uninstall(ctx, d, sup);
|
|
4028
|
+
return status(ctx, d, h, values.json === true);
|
|
4029
|
+
}
|
|
4030
|
+
function installedSupervisor(ctx) {
|
|
4031
|
+
try {
|
|
4032
|
+
const raw = JSON.parse(fs15.readFileSync(serviceFile(ctx.dirs), "utf8"));
|
|
4033
|
+
if (isObject(raw) && typeof raw.supervisor === "string" && SUPERVISORS.includes(raw.supervisor)) return raw.supervisor;
|
|
4034
|
+
} catch {
|
|
4035
|
+
}
|
|
4036
|
+
return null;
|
|
4037
|
+
}
|
|
4038
|
+
function startAfterLogin(ctx, h) {
|
|
4039
|
+
const sup = installedSupervisor(ctx);
|
|
4040
|
+
if (!sup) return;
|
|
4041
|
+
const d = deps(ctx);
|
|
4042
|
+
if (h.runningPid(ctx) !== null) return;
|
|
4043
|
+
let r;
|
|
4044
|
+
if (sup === "systemd") r = d.exec("systemctl", ["--user", "restart", UNIT]);
|
|
4045
|
+
else if (sup === "launchd") r = d.exec("launchctl", ["kickstart", "-k", `gui/${d.uid}/${LABEL}`]);
|
|
4046
|
+
else {
|
|
4047
|
+
void ensure(ctx, d, h);
|
|
4048
|
+
return;
|
|
4049
|
+
}
|
|
4050
|
+
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`);
|
|
4051
|
+
}
|
|
4052
|
+
function writeFile600(file, body, mode = 384) {
|
|
4053
|
+
fs15.mkdirSync(path16.dirname(file), { recursive: true, mode: 448 });
|
|
4054
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
4055
|
+
fs15.writeFileSync(tmp, body, { mode });
|
|
4056
|
+
fs15.renameSync(tmp, file);
|
|
4057
|
+
}
|
|
4058
|
+
async function install(ctx, d, h, sup, extra) {
|
|
4059
|
+
const problem = h.preflight(ctx);
|
|
4060
|
+
if (problem) {
|
|
4061
|
+
ctx.err(`cannot install the service yet: ${problem}`);
|
|
4062
|
+
return NEEDS_PERSON;
|
|
4063
|
+
}
|
|
4064
|
+
if (!fs15.existsSync(d.cli)) {
|
|
4065
|
+
ctx.err(`${d.cli} does not exist (a source checkout needs "npm run build" first)`);
|
|
4066
|
+
return 1;
|
|
4067
|
+
}
|
|
4068
|
+
const { vars, refused, withheld } = carriedEnv(ctx.env, extra);
|
|
4069
|
+
for (const r of refused) ctx.err(`--env ${r}: not carried into the service (it looks like a credential)`);
|
|
4070
|
+
for (const w of withheld) ctx.err(`${w}: not carried into the service because it holds a password; if the connector needs it, install again with --env ${w} (it is then written into the 0600 unit file)`);
|
|
4071
|
+
const manual = h.runningPid(ctx);
|
|
4072
|
+
if (manual !== null && supervisorPid(ctx) === null) {
|
|
4073
|
+
await h.stop(ctx);
|
|
4074
|
+
ctx.out(`stopped the connector that was running by hand (pid ${manual}) so the service takes over`);
|
|
4075
|
+
}
|
|
4076
|
+
ctx.out(`carrying into the service: ${vars.map(([k]) => k).join(", ") || "nothing"}`);
|
|
4077
|
+
if (sup === "systemd") {
|
|
4078
|
+
const unit = systemdUnitPath(ctx, d);
|
|
4079
|
+
writeFile600(unit, systemdUnit(d, vars));
|
|
4080
|
+
recordInstalled(ctx, "systemd");
|
|
4081
|
+
if (!d.systemdBooted()) {
|
|
4082
|
+
ctx.err("systemd is not this machine's init (no /run/systemd/system): use --supervisor script");
|
|
4083
|
+
return 1;
|
|
4084
|
+
}
|
|
4085
|
+
for (const a of [["--user", "daemon-reload"], ["--user", "enable", UNIT], ["--user", "restart", UNIT]]) {
|
|
4086
|
+
const r = d.exec("systemctl", a);
|
|
4087
|
+
if (r.code !== 0) {
|
|
4088
|
+
ctx.err(`systemctl ${a.join(" ")} failed: ${r.out || `exit ${r.code}`}`);
|
|
4089
|
+
return 1;
|
|
4090
|
+
}
|
|
4091
|
+
}
|
|
4092
|
+
const st = d.exec("systemctl", ["--user", "is-active", UNIT]).out.trim();
|
|
4093
|
+
if (st !== "active" && st !== "activating") {
|
|
4094
|
+
ctx.err(`the unit did not start (is-active: ${st || "nothing"}); see: journalctl --user -u ${UNIT}`);
|
|
4095
|
+
return 1;
|
|
4096
|
+
}
|
|
4097
|
+
ctx.out(`installed ${unit} and started it (systemctl --user status ${UNIT}; logs: journalctl --user -u ${UNIT})`);
|
|
4098
|
+
const linger = d.exec("loginctl", ["show-user", d.user, "-p", "Linger", "--value"]);
|
|
4099
|
+
if (linger.out.trim() !== "yes") ctx.out(`to keep it running when you are not logged in and start it at boot, run once: loginctl enable-linger ${d.user}`);
|
|
4100
|
+
} else if (sup === "launchd") {
|
|
4101
|
+
const plist = launchdPlistPath(d);
|
|
4102
|
+
writeFile600(plist, launchdPlist(d, vars, runLogFile(ctx.dirs)));
|
|
4103
|
+
recordInstalled(ctx, "launchd");
|
|
4104
|
+
d.exec("launchctl", ["bootout", `gui/${d.uid}/${LABEL}`]);
|
|
4105
|
+
const r = d.exec("launchctl", ["bootstrap", `gui/${d.uid}`, plist]);
|
|
4106
|
+
if (r.code !== 0) {
|
|
4107
|
+
ctx.err(`launchctl bootstrap failed: ${r.out || `exit ${r.code}`}`);
|
|
4108
|
+
return 1;
|
|
4109
|
+
}
|
|
4110
|
+
ctx.out(`installed ${plist} and started it (launchctl print gui/${d.uid}/${LABEL}; log: ${runLogFile(ctx.dirs)})`);
|
|
4111
|
+
} else {
|
|
4112
|
+
const script = keepaliveFile(ctx.dirs);
|
|
4113
|
+
writeFile600(script, keepaliveScript(d, vars), 448);
|
|
4114
|
+
recordInstalled(ctx, "script");
|
|
4115
|
+
const code = await ensure(ctx, d, h);
|
|
4116
|
+
if (code !== 0) return code;
|
|
4117
|
+
ctx.out(`wrote ${script}. This machine has no systemd: the supervisor restarts the connector 5 s after it dies, but`);
|
|
4118
|
+
ctx.out("nothing starts the supervisor after a reboot or a container restart. Add both of these (crontab -e, or your container's startup hook):");
|
|
4119
|
+
ctx.out(` @reboot sh ${script}`);
|
|
4120
|
+
ctx.out(` */5 * * * * sh ${script}`);
|
|
4121
|
+
}
|
|
4122
|
+
return 0;
|
|
4123
|
+
}
|
|
4124
|
+
function recordInstalled(ctx, sup) {
|
|
4125
|
+
writeFile600(serviceFile(ctx.dirs), JSON.stringify({ supervisor: sup, installed_at: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
4126
|
+
}
|
|
4127
|
+
async function uninstall(ctx, d, sup) {
|
|
4128
|
+
fs15.rmSync(serviceFile(ctx.dirs), { force: true });
|
|
4129
|
+
if (sup === "systemd") {
|
|
4130
|
+
const unit = systemdUnitPath(ctx, d);
|
|
4131
|
+
d.exec("systemctl", ["--user", "disable", "--now", UNIT]);
|
|
4132
|
+
const had = fs15.existsSync(unit);
|
|
4133
|
+
fs15.rmSync(unit, { force: true });
|
|
4134
|
+
d.exec("systemctl", ["--user", "daemon-reload"]);
|
|
4135
|
+
ctx.out(had ? `stopped and removed ${unit}` : `no unit at ${unit}`);
|
|
4136
|
+
} else if (sup === "launchd") {
|
|
4137
|
+
const plist = launchdPlistPath(d);
|
|
4138
|
+
d.exec("launchctl", ["bootout", `gui/${d.uid}/${LABEL}`]);
|
|
4139
|
+
const had = fs15.existsSync(plist);
|
|
4140
|
+
fs15.rmSync(plist, { force: true });
|
|
4141
|
+
ctx.out(had ? `stopped and removed ${plist}` : `no LaunchAgent at ${plist}`);
|
|
4142
|
+
} else {
|
|
4143
|
+
const pid = supervisorPid(ctx);
|
|
4144
|
+
if (pid !== null) {
|
|
4145
|
+
process.kill(pid, "SIGTERM");
|
|
4146
|
+
const end = Date.now() + 2e4;
|
|
4147
|
+
while (Date.now() < end && isAlive(pid) && processStart(pid) !== null) await sleep(100);
|
|
4148
|
+
}
|
|
4149
|
+
const script = keepaliveFile(ctx.dirs);
|
|
4150
|
+
const had = fs15.existsSync(script);
|
|
4151
|
+
fs15.rmSync(script, { force: true });
|
|
4152
|
+
ctx.out(`${pid !== null ? `stopped the supervisor (pid ${pid}) and the connector; ` : ""}${had ? `removed ${script}` : "no keep-alive script"}. Remove the @reboot and */5 lines that call it.`);
|
|
4153
|
+
}
|
|
4154
|
+
return 0;
|
|
4155
|
+
}
|
|
4156
|
+
function status(ctx, d, h, json) {
|
|
4157
|
+
const sup = installedSupervisor(ctx);
|
|
4158
|
+
let active = null;
|
|
4159
|
+
if (sup === "systemd") active = d.exec("systemctl", ["--user", "is-active", UNIT]).out.trim() || null;
|
|
4160
|
+
else if (sup === "launchd") active = d.exec("launchctl", ["print", `gui/${d.uid}/${LABEL}`]).code === 0 ? "loaded" : "not loaded";
|
|
4161
|
+
else if (sup === "script") {
|
|
4162
|
+
const p = supervisorPid(ctx);
|
|
4163
|
+
active = p !== null ? `supervisor pid ${p}` : "supervisor not running";
|
|
4164
|
+
}
|
|
4165
|
+
const st = {
|
|
4166
|
+
installed: sup,
|
|
4167
|
+
active,
|
|
4168
|
+
running: h.runningPid(ctx),
|
|
4169
|
+
needs_login: needsLogin(ctx.dirs),
|
|
4170
|
+
recent_disconnects: recentDisconnects(ctx.dirs, 5)
|
|
4171
|
+
};
|
|
4172
|
+
if (json) {
|
|
4173
|
+
ctx.out(JSON.stringify(st, null, 2));
|
|
4174
|
+
return 0;
|
|
4175
|
+
}
|
|
4176
|
+
ctx.out(`service: ${sup ? `${sup} (${active})` : "not installed; run: claw-connector service install"}`);
|
|
4177
|
+
ctx.out(`connector: ${st.running !== null ? `running (pid ${st.running})` : "not running"}`);
|
|
4178
|
+
if (st.needs_login) ctx.out(`problem: needs a new sign-in: ${st.needs_login.reason}; run: claw-connector login`);
|
|
4179
|
+
for (const x of st.recent_disconnects) ctx.out(`down: ${x.at} ${x.reason}${x.detail ? ` (${x.detail})` : ""}`);
|
|
4180
|
+
return 0;
|
|
4181
|
+
}
|
|
4182
|
+
|
|
3624
4183
|
// src/version.ts
|
|
3625
|
-
var VERSION = "0.1.
|
|
4184
|
+
var VERSION = "0.1.3";
|
|
3626
4185
|
|
|
3627
4186
|
// src/commands.ts
|
|
3628
|
-
var EXIT = { ok: 0, failure: 1, usage: 2, revoked:
|
|
4187
|
+
var EXIT = { ok: 0, failure: 1, usage: 2, needsPerson: 78, revoked: 78 };
|
|
3629
4188
|
var HELP = `claw-connector ${VERSION}: connect the coding agents on this machine to Claw (outbound HTTPS only).
|
|
3630
4189
|
|
|
3631
4190
|
Usage: claw-connector <command> [options]
|
|
@@ -3641,8 +4200,13 @@ Usage: claw-connector <command> [options]
|
|
|
3641
4200
|
directories, else $CLAUDE_CONFIG_DIR or ~/.claude); only its own entries are ever changed
|
|
3642
4201
|
login sign in (device flow); --server URL --identity-url URL [--name HOST]
|
|
3643
4202
|
development only: --key <api key> (or CLAW_CONNECTOR_KEY)
|
|
3644
|
-
run connect and serve (Ctrl-C or SIGTERM stops it and every agent it started)
|
|
3645
|
-
|
|
4203
|
+
run connect and serve (Ctrl-C or SIGTERM stops it and every agent it started). Exit code 78:
|
|
4204
|
+
revoked, sign-in refused, or no usable config; starting it again will not help
|
|
4205
|
+
service install|uninstall|status [--supervisor systemd|launchd|script] [--env NAME ...]
|
|
4206
|
+
keep "run" up: started at login/boot, restarted 5 s after it dies, never restarted
|
|
4207
|
+
after exit 78. systemd --user on Linux, launchd on macOS; "script" (containers without
|
|
4208
|
+
systemd) writes a keep-alive script for a startup hook and a 5-minute cron line
|
|
4209
|
+
status [--json] what is running, signed in, paused, the last disconnect and why
|
|
3646
4210
|
pause stop every agent now and stay offline until "resume"
|
|
3647
4211
|
resume undo "pause"
|
|
3648
4212
|
stop stop the running connector and every agent it started
|
|
@@ -3653,7 +4217,7 @@ Files: ${"$"}XDG_CONFIG_HOME/claw-connector (agents.toml, credentials.json) and
|
|
|
3653
4217
|
(audit.jsonl, logs, outbox); set CLAW_CONNECTOR_HOME to keep everything under one directory.
|
|
3654
4218
|
`;
|
|
3655
4219
|
function loadCfg(ctx) {
|
|
3656
|
-
return loadConfig(configFile(ctx.dirs), { hostname: ctx.hostname ??
|
|
4220
|
+
return loadConfig(configFile(ctx.dirs), { hostname: ctx.hostname ?? os6.hostname() });
|
|
3657
4221
|
}
|
|
3658
4222
|
var noAsk = (ctx, cfg) => noAskWarnings(cfg.agents, ctx.env, { managedFile: ctx.claudeManagedSettings, euid: ctx.euid });
|
|
3659
4223
|
function serverUrl(ctx, flag, cfg, creds) {
|
|
@@ -3681,8 +4245,14 @@ async function main(argv, ctx) {
|
|
|
3681
4245
|
return cmdHooks(rest, ctx);
|
|
3682
4246
|
case "login":
|
|
3683
4247
|
return await cmdLogin(rest, ctx);
|
|
3684
|
-
case "run":
|
|
3685
|
-
|
|
4248
|
+
case "run": {
|
|
4249
|
+
const code = await cmdRun(rest, ctx);
|
|
4250
|
+
return code === EXIT.needsPerson && ctx.env.CLAW_CONNECTOR_SUPERVISOR === "launchd" ? EXIT.ok : code;
|
|
4251
|
+
}
|
|
4252
|
+
case "service":
|
|
4253
|
+
return await cmdService(rest, ctx, serviceHelpers);
|
|
4254
|
+
case "supervise":
|
|
4255
|
+
return await cmdSupervise(rest, ctx);
|
|
3686
4256
|
case "status":
|
|
3687
4257
|
return cmdStatus(rest, ctx);
|
|
3688
4258
|
case "pause":
|
|
@@ -3724,16 +4294,16 @@ function parseApproval(values) {
|
|
|
3724
4294
|
return out;
|
|
3725
4295
|
}
|
|
3726
4296
|
async function cmdInit(args, ctx) {
|
|
3727
|
-
const { values } =
|
|
4297
|
+
const { values } = parseArgs2({ args, strict: true, options: { approval: { type: "string", multiple: true }, "no-hooks": { type: "boolean" } } });
|
|
3728
4298
|
const approval = parseApproval(values.approval ?? []);
|
|
3729
4299
|
if (typeof approval === "string") {
|
|
3730
4300
|
ctx.err(approval);
|
|
3731
4301
|
return EXIT.usage;
|
|
3732
4302
|
}
|
|
3733
4303
|
const file = configFile(ctx.dirs);
|
|
3734
|
-
|
|
4304
|
+
fs16.mkdirSync(path17.dirname(file), { recursive: true, mode: 448 });
|
|
3735
4305
|
try {
|
|
3736
|
-
|
|
4306
|
+
fs16.writeFileSync(file, sampleConfig(approval), { mode: 384, flag: "wx" });
|
|
3737
4307
|
ctx.out(`wrote ${file}: edit the roots (the directories an agent may work in), then run: claw-connector login`);
|
|
3738
4308
|
const { unattended, parked } = sampleApprovals(approval);
|
|
3739
4309
|
ctx.out(unattended.length ? `no per-step approval for: ${unattended.join(", ")}. Those agents write files and run commands without asking; the phone shows such a session as unattended. To go back, set approval = "ask" on the agent.` : `every agent in this file asks before it writes a file or runs a command. To let phone sessions run like your terminal instead: claw-connector init --approval never (on a new file), or set approval = "never" on an agent.`);
|
|
@@ -3744,7 +4314,7 @@ async function cmdInit(args, ctx) {
|
|
|
3744
4314
|
}
|
|
3745
4315
|
if (!values["no-hooks"]) {
|
|
3746
4316
|
const dir = defaultClaudeDir(ctx);
|
|
3747
|
-
if (!
|
|
4317
|
+
if (!fs16.existsSync(dir)) ctx.out(`no Claude Code directory at ${dir}; once you use Claude Code, run: claw-connector hooks install`);
|
|
3748
4318
|
else installForInit(ctx, dir);
|
|
3749
4319
|
}
|
|
3750
4320
|
return EXIT.ok;
|
|
@@ -3756,19 +4326,19 @@ function installForInit(ctx, dir) {
|
|
|
3756
4326
|
ctx.err(`hooks: ${errMessage(e)} (everything else is set up; fix it and run: claw-connector hooks install)`);
|
|
3757
4327
|
}
|
|
3758
4328
|
}
|
|
3759
|
-
var defaultClaudeDir = (ctx) =>
|
|
4329
|
+
var defaultClaudeDir = (ctx) => path17.resolve(ctx.env.CLAUDE_CONFIG_DIR?.trim() || path17.join(os6.homedir(), ".claude"));
|
|
3760
4330
|
function claudeDirs(ctx, flag) {
|
|
3761
|
-
if (flag !== void 0) return [
|
|
4331
|
+
if (flag !== void 0) return [path17.resolve(flag)];
|
|
3762
4332
|
let dirs = [];
|
|
3763
4333
|
try {
|
|
3764
|
-
dirs = loadCfg(ctx).agents.filter((a) => a.attach).map((a) =>
|
|
4334
|
+
dirs = loadCfg(ctx).agents.filter((a) => a.attach).map((a) => path17.resolve(a.env.CLAUDE_CONFIG_DIR ?? ctx.env.CLAUDE_CONFIG_DIR?.trim() ?? path17.join(os6.homedir(), ".claude")));
|
|
3765
4335
|
} catch {
|
|
3766
4336
|
}
|
|
3767
4337
|
return dirs.length ? [...new Set(dirs)] : [defaultClaudeDir(ctx)];
|
|
3768
4338
|
}
|
|
3769
4339
|
function hookScript(ctx) {
|
|
3770
|
-
const script = ctx.hookScript ??
|
|
3771
|
-
if (!
|
|
4340
|
+
const script = ctx.hookScript ?? fileURLToPath2(new URL("./terminal-hook.js", import.meta.url));
|
|
4341
|
+
if (!fs16.existsSync(script)) throw new HooksError(`${script} does not exist (a source checkout needs "npm run build" first)`);
|
|
3772
4342
|
return script;
|
|
3773
4343
|
}
|
|
3774
4344
|
function installOne(ctx, dir) {
|
|
@@ -3781,14 +4351,14 @@ function cmdHooks(args, ctx) {
|
|
|
3781
4351
|
ctx.err("usage: claw-connector hooks install|uninstall|status [--config-dir DIR]");
|
|
3782
4352
|
return EXIT.usage;
|
|
3783
4353
|
}
|
|
3784
|
-
const { values } =
|
|
4354
|
+
const { values } = parseArgs2({ args: rest, strict: true, options: { "config-dir": { type: "string" }, json: { type: "boolean" } } });
|
|
3785
4355
|
const dirs = claudeDirs(ctx, values["config-dir"]);
|
|
3786
4356
|
let code = EXIT.ok;
|
|
3787
4357
|
const report = [];
|
|
3788
4358
|
for (const dir of dirs) {
|
|
3789
4359
|
try {
|
|
3790
4360
|
if (sub === "install") {
|
|
3791
|
-
if (values["config-dir"] === void 0 && !
|
|
4361
|
+
if (values["config-dir"] === void 0 && !fs16.existsSync(dir)) {
|
|
3792
4362
|
ctx.err(`no Claude Code directory at ${dir}; pass --config-dir to name one`);
|
|
3793
4363
|
code = EXIT.failure;
|
|
3794
4364
|
continue;
|
|
@@ -3813,14 +4383,14 @@ function forgetRevokedIdentity(ctx) {
|
|
|
3813
4383
|
const file = identityFile(ctx.dirs);
|
|
3814
4384
|
let revoked = false;
|
|
3815
4385
|
try {
|
|
3816
|
-
const raw = JSON.parse(
|
|
4386
|
+
const raw = JSON.parse(fs16.readFileSync(file, "utf8"));
|
|
3817
4387
|
revoked = isObject(raw) && raw.revoked === true;
|
|
3818
4388
|
} catch {
|
|
3819
4389
|
}
|
|
3820
|
-
if (revoked)
|
|
4390
|
+
if (revoked) fs16.rmSync(file, { force: true });
|
|
3821
4391
|
}
|
|
3822
4392
|
async function cmdLogin(args, ctx) {
|
|
3823
|
-
const { values } =
|
|
4393
|
+
const { values } = parseArgs2({
|
|
3824
4394
|
args,
|
|
3825
4395
|
strict: true,
|
|
3826
4396
|
options: { server: { type: "string" }, "identity-url": { type: "string" }, key: { type: "string" }, name: { type: "string" } }
|
|
@@ -3841,7 +4411,9 @@ async function cmdLogin(args, ctx) {
|
|
|
3841
4411
|
if (key) {
|
|
3842
4412
|
saveCredentials(file, { v: 1, server, kind: "key", key, saved_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
3843
4413
|
forgetRevokedIdentity(ctx);
|
|
4414
|
+
clearNeedsLogin(ctx.dirs);
|
|
3844
4415
|
ctx.out("saved a development key. This is for trying things out: a device sign-in is the normal way (omit --key).");
|
|
4416
|
+
startAfterLogin(ctx, serviceHelpers);
|
|
3845
4417
|
return EXIT.ok;
|
|
3846
4418
|
}
|
|
3847
4419
|
const identityRaw = (values["identity-url"] ?? ctx.env.CLAW_IDENTITY_URL ?? cfg?.identityUrl ?? "").trim();
|
|
@@ -3850,7 +4422,7 @@ async function cmdLogin(args, ctx) {
|
|
|
3850
4422
|
ctx.err("login needs --identity-url <identity service URL> (or CLAW_IDENTITY_URL, or identity_url = in agents.toml)");
|
|
3851
4423
|
return EXIT.usage;
|
|
3852
4424
|
}
|
|
3853
|
-
const deviceName = values.name ?? cfg?.name ?? ctx.hostname ??
|
|
4425
|
+
const deviceName = values.name ?? cfg?.name ?? ctx.hostname ?? os6.hostname();
|
|
3854
4426
|
const tok = await deviceLogin({ identityUrl, deviceName, fetch: ctx.fetch, print: ctx.out });
|
|
3855
4427
|
saveCredentials(file, {
|
|
3856
4428
|
v: 1,
|
|
@@ -3863,27 +4435,47 @@ async function cmdLogin(args, ctx) {
|
|
|
3863
4435
|
saved_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3864
4436
|
});
|
|
3865
4437
|
forgetRevokedIdentity(ctx);
|
|
3866
|
-
ctx.
|
|
4438
|
+
clearNeedsLogin(ctx.dirs);
|
|
4439
|
+
ctx.out(`signed in as ${deviceName}. Next: claw-connector service install (or run)`);
|
|
4440
|
+
startAfterLogin(ctx, serviceHelpers);
|
|
3867
4441
|
return EXIT.ok;
|
|
3868
4442
|
}
|
|
3869
4443
|
function runningPid(ctx) {
|
|
3870
4444
|
try {
|
|
3871
|
-
const raw = JSON.parse(
|
|
4445
|
+
const raw = JSON.parse(fs16.readFileSync(pidFile(ctx.dirs), "utf8"));
|
|
3872
4446
|
if (isObject(raw) && typeof raw.pid === "number" && typeof raw.start === "string" && raw.start !== "" && processStart(raw.pid) === raw.start) return raw.pid;
|
|
3873
4447
|
} catch {
|
|
3874
4448
|
}
|
|
3875
4449
|
return null;
|
|
3876
4450
|
}
|
|
3877
|
-
async function cmdRun(
|
|
3878
|
-
|
|
4451
|
+
async function cmdRun(args, ctx) {
|
|
4452
|
+
parseArgs2({ args, strict: true, options: { supervised: { type: "boolean" } } });
|
|
4453
|
+
{
|
|
4454
|
+
const nl = needsLogin(ctx.dirs);
|
|
4455
|
+
if (nl) {
|
|
4456
|
+
ctx.err(`not starting: ${nl.reason}${nl.at ? ` (${nl.at})` : ""}. Sign in again (claw-connector login); the service then starts it.`);
|
|
4457
|
+
return EXIT.needsPerson;
|
|
4458
|
+
}
|
|
4459
|
+
}
|
|
4460
|
+
let cfg;
|
|
4461
|
+
let creds;
|
|
4462
|
+
try {
|
|
4463
|
+
cfg = loadCfg(ctx);
|
|
4464
|
+
creds = loadCredentials(credentialsFile(ctx.dirs));
|
|
4465
|
+
} catch (e) {
|
|
4466
|
+
if (e instanceof ConfigError || e instanceof CredentialsError) {
|
|
4467
|
+
ctx.err(e.message);
|
|
4468
|
+
return EXIT.needsPerson;
|
|
4469
|
+
}
|
|
4470
|
+
throw e;
|
|
4471
|
+
}
|
|
3879
4472
|
if (cfg.agents.length === 0) {
|
|
3880
4473
|
ctx.err("agents.toml defines no agents; add an [[agents]] table");
|
|
3881
|
-
return EXIT.
|
|
4474
|
+
return EXIT.needsPerson;
|
|
3882
4475
|
}
|
|
3883
|
-
const creds = loadCredentials(credentialsFile(ctx.dirs));
|
|
3884
4476
|
if (!creds) {
|
|
3885
4477
|
ctx.err("not signed in: run claw-connector login");
|
|
3886
|
-
return EXIT.
|
|
4478
|
+
return EXIT.needsPerson;
|
|
3887
4479
|
}
|
|
3888
4480
|
const running = runningPid(ctx);
|
|
3889
4481
|
if (running !== null) {
|
|
@@ -3902,12 +4494,23 @@ async function cmdRun(_args, ctx) {
|
|
|
3902
4494
|
try {
|
|
3903
4495
|
const result = await connector2.run();
|
|
3904
4496
|
if (result === "revoked") {
|
|
4497
|
+
markNeedsLogin(ctx.dirs, "this connector was revoked");
|
|
3905
4498
|
ctx.err("this connector was revoked: every agent was stopped. Run login again to connect it anew.");
|
|
3906
4499
|
return EXIT.revoked;
|
|
3907
4500
|
}
|
|
4501
|
+
if (result === "auth_failed") {
|
|
4502
|
+
markNeedsLogin(ctx.dirs, `Claw refused the sign-in (${connector2.authFailure})`);
|
|
4503
|
+
ctx.err(`Claw keeps refusing this sign-in (${connector2.authFailure}): every agent was stopped. Run login again.`);
|
|
4504
|
+
return EXIT.needsPerson;
|
|
4505
|
+
}
|
|
3908
4506
|
return EXIT.ok;
|
|
3909
4507
|
} catch (e) {
|
|
3910
|
-
if (e
|
|
4508
|
+
if (isAuthRefusal(e) || e instanceof RevokedError || e instanceof GrantRevokedError || e instanceof CredentialsError) {
|
|
4509
|
+
markNeedsLogin(ctx.dirs, e instanceof GrantRevokedError || e instanceof RevokedError ? e.message : `Claw refused the sign-in (${errMessage(e)})`);
|
|
4510
|
+
ctx.err(`error: ${errMessage(e)}. Run login again.`);
|
|
4511
|
+
return EXIT.needsPerson;
|
|
4512
|
+
}
|
|
4513
|
+
if (e instanceof HttpError || e instanceof TransportError) {
|
|
3911
4514
|
ctx.err(`error: ${e.message}`);
|
|
3912
4515
|
return EXIT.failure;
|
|
3913
4516
|
}
|
|
@@ -3915,7 +4518,7 @@ async function cmdRun(_args, ctx) {
|
|
|
3915
4518
|
}
|
|
3916
4519
|
}
|
|
3917
4520
|
function cmdStatus(args, ctx) {
|
|
3918
|
-
const { values } =
|
|
4521
|
+
const { values } = parseArgs2({ args, strict: true, options: { json: { type: "boolean" } } });
|
|
3919
4522
|
let creds = null;
|
|
3920
4523
|
let credsProblem = null;
|
|
3921
4524
|
try {
|
|
@@ -3937,15 +4540,15 @@ function cmdStatus(args, ctx) {
|
|
|
3937
4540
|
}
|
|
3938
4541
|
let connectorId = null;
|
|
3939
4542
|
try {
|
|
3940
|
-
const raw = JSON.parse(
|
|
4543
|
+
const raw = JSON.parse(fs16.readFileSync(identityFile(ctx.dirs), "utf8"));
|
|
3941
4544
|
if (isObject(raw) && typeof raw.connector_id === "string") connectorId = raw.connector_id;
|
|
3942
4545
|
} catch {
|
|
3943
4546
|
}
|
|
3944
4547
|
const reg = new ChildRegistry(childrenFile(ctx.dirs));
|
|
3945
|
-
const
|
|
4548
|
+
const status2 = {
|
|
3946
4549
|
version: VERSION,
|
|
3947
4550
|
running: runningPid(ctx),
|
|
3948
|
-
paused:
|
|
4551
|
+
paused: fs16.existsSync(pauseFile(ctx.dirs)),
|
|
3949
4552
|
signed_in: creds ? { kind: creds.kind, server: creds.server, device_name: creds.device_name ?? null } : null,
|
|
3950
4553
|
connector_id: connectorId,
|
|
3951
4554
|
agents,
|
|
@@ -3954,23 +4557,28 @@ function cmdStatus(args, ctx) {
|
|
|
3954
4557
|
adapters: reg.list().filter((r) => reg.isOurs(r)).map((r) => ({ pid: r.pid, agent: r.agent_id, session: r.session_id })),
|
|
3955
4558
|
problems: [credsProblem, configProblem].filter((x) => !!x),
|
|
3956
4559
|
/** Agents whose sessions will be refused as things stand (Claude's own settings start them in a mode that never asks). */
|
|
3957
|
-
warnings
|
|
4560
|
+
warnings,
|
|
4561
|
+
/** Why the connection last went down, and whether only a new sign-in can bring it back. */
|
|
4562
|
+
last_disconnect: recentDisconnects(ctx.dirs, 1)[0] ?? null,
|
|
4563
|
+
needs_login: needsLogin(ctx.dirs)
|
|
3958
4564
|
};
|
|
3959
4565
|
if (values.json) {
|
|
3960
|
-
ctx.out(JSON.stringify(
|
|
4566
|
+
ctx.out(JSON.stringify(status2, null, 2));
|
|
3961
4567
|
return EXIT.ok;
|
|
3962
4568
|
}
|
|
3963
|
-
ctx.out(`connector: ${
|
|
4569
|
+
ctx.out(`connector: ${status2.running ? `running (pid ${status2.running})` : "not running"}${status2.paused ? ", PAUSED" : ""}`);
|
|
3964
4570
|
ctx.out(`signed in: ${creds ? `${creds.kind} to ${creds.server}` : "no"}`);
|
|
3965
4571
|
ctx.out(`agents: ${agents.map((a) => unattended.includes(a) ? `${a} (no per-step approval)` : a).join(", ") || "none configured"}`);
|
|
3966
|
-
ctx.out(`adapters: ${
|
|
3967
|
-
|
|
3968
|
-
|
|
4572
|
+
ctx.out(`adapters: ${status2.adapters.length ? status2.adapters.map((a) => `${a.agent} pid ${a.pid}`).join(", ") : "none running"}`);
|
|
4573
|
+
if (status2.last_disconnect) ctx.out(`last down: ${status2.last_disconnect.at} ${status2.last_disconnect.reason}${status2.last_disconnect.detail ? ` (${status2.last_disconnect.detail})` : ""}`);
|
|
4574
|
+
if (status2.needs_login) ctx.out(`problem: needs a new sign-in: ${status2.needs_login.reason}; run: claw-connector login`);
|
|
4575
|
+
for (const p of status2.problems) ctx.out(`problem: ${p}`);
|
|
4576
|
+
for (const w of status2.warnings) ctx.out(`warning: ${w}`);
|
|
3969
4577
|
return EXIT.ok;
|
|
3970
4578
|
}
|
|
3971
4579
|
async function cmdPause(ctx) {
|
|
3972
|
-
|
|
3973
|
-
|
|
4580
|
+
fs16.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
4581
|
+
fs16.writeFileSync(pauseFile(ctx.dirs), (/* @__PURE__ */ new Date()).toISOString() + "\n", { mode: 384 });
|
|
3974
4582
|
if (runningPid(ctx) === null) {
|
|
3975
4583
|
const left = await new ChildRegistry(childrenFile(ctx.dirs)).killAll();
|
|
3976
4584
|
ctx.out(`paused (the connector is not running${left.killed.length ? `; stopped ${left.killed.length} leftover agent process group(s)` : ""})`);
|
|
@@ -3980,7 +4588,7 @@ async function cmdPause(ctx) {
|
|
|
3980
4588
|
return EXIT.ok;
|
|
3981
4589
|
}
|
|
3982
4590
|
function cmdResume(ctx) {
|
|
3983
|
-
|
|
4591
|
+
fs16.rmSync(pauseFile(ctx.dirs), { force: true });
|
|
3984
4592
|
ctx.out(runningPid(ctx) === null ? "resumed; start it with: claw-connector run" : "resumed: the running connector reconnects now");
|
|
3985
4593
|
return EXIT.ok;
|
|
3986
4594
|
}
|
|
@@ -4020,11 +4628,26 @@ async function cmdLogout(ctx) {
|
|
|
4020
4628
|
ctx.err(`could not revoke the sign-in at the identity service (${errMessage(e)}); it was removed from this machine`);
|
|
4021
4629
|
}
|
|
4022
4630
|
}
|
|
4023
|
-
|
|
4631
|
+
fs16.rmSync(file, { force: true });
|
|
4024
4632
|
forgetRevokedIdentity(ctx);
|
|
4025
4633
|
ctx.out("signed out");
|
|
4026
4634
|
return EXIT.ok;
|
|
4027
4635
|
}
|
|
4636
|
+
var serviceHelpers = {
|
|
4637
|
+
runningPid,
|
|
4638
|
+
stop: (ctx) => cmdStop(ctx, true),
|
|
4639
|
+
preflight: (ctx) => {
|
|
4640
|
+
try {
|
|
4641
|
+
const cfg = loadCfg(ctx);
|
|
4642
|
+
if (cfg.agents.length === 0) return "agents.toml defines no agents; add an [[agents]] table";
|
|
4643
|
+
if (!loadCredentials(credentialsFile(ctx.dirs))) return "not signed in: run claw-connector login";
|
|
4644
|
+
} catch (e) {
|
|
4645
|
+
return errMessage(e);
|
|
4646
|
+
}
|
|
4647
|
+
const nl = needsLogin(ctx.dirs);
|
|
4648
|
+
return nl ? `${nl.reason}: run claw-connector login first` : null;
|
|
4649
|
+
}
|
|
4650
|
+
};
|
|
4028
4651
|
|
|
4029
4652
|
// src/cli.ts
|
|
4030
4653
|
var connector = null;
|