@jabezpauls/agentbox 0.1.9 → 0.1.10
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/dist/agentbox.mjs +3481 -159
- package/package.json +1 -1
package/dist/agentbox.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// agentbox-cli v0.1.
|
|
2
|
+
// agentbox-cli v0.1.10
|
|
3
3
|
import { createRequire as __agentboxCreateRequire } from "node:module";
|
|
4
4
|
const require = __agentboxCreateRequire(import.meta.url);
|
|
5
5
|
var __create = Object.create;
|
|
@@ -1397,7 +1397,7 @@ var require_receiver = __commonJS({
|
|
|
1397
1397
|
var require_sender = __commonJS({
|
|
1398
1398
|
"../node_modules/ws/lib/sender.js"(exports, module) {
|
|
1399
1399
|
"use strict";
|
|
1400
|
-
var { Duplex:
|
|
1400
|
+
var { Duplex: Duplex3 } = __require("stream");
|
|
1401
1401
|
var { randomFillSync } = __require("crypto");
|
|
1402
1402
|
var {
|
|
1403
1403
|
types: { isUint8Array }
|
|
@@ -2124,7 +2124,7 @@ var require_extension = __commonJS({
|
|
|
2124
2124
|
if (dest[name] === void 0) dest[name] = [elem];
|
|
2125
2125
|
else dest[name].push(elem);
|
|
2126
2126
|
}
|
|
2127
|
-
function
|
|
2127
|
+
function parse2(header) {
|
|
2128
2128
|
const offers = /* @__PURE__ */ Object.create(null);
|
|
2129
2129
|
let params = /* @__PURE__ */ Object.create(null);
|
|
2130
2130
|
let mustUnescape = false;
|
|
@@ -2264,7 +2264,7 @@ var require_extension = __commonJS({
|
|
|
2264
2264
|
}).join(", ");
|
|
2265
2265
|
}).join(", ");
|
|
2266
2266
|
}
|
|
2267
|
-
module.exports = { format, parse };
|
|
2267
|
+
module.exports = { format, parse: parse2 };
|
|
2268
2268
|
}
|
|
2269
2269
|
});
|
|
2270
2270
|
|
|
@@ -2272,13 +2272,13 @@ var require_extension = __commonJS({
|
|
|
2272
2272
|
var require_websocket = __commonJS({
|
|
2273
2273
|
"../node_modules/ws/lib/websocket.js"(exports, module) {
|
|
2274
2274
|
"use strict";
|
|
2275
|
-
var
|
|
2275
|
+
var EventEmitter4 = __require("events");
|
|
2276
2276
|
var https2 = __require("https");
|
|
2277
|
-
var
|
|
2278
|
-
var
|
|
2277
|
+
var http4 = __require("http");
|
|
2278
|
+
var net5 = __require("net");
|
|
2279
2279
|
var tls = __require("tls");
|
|
2280
|
-
var { randomBytes:
|
|
2281
|
-
var { Duplex:
|
|
2280
|
+
var { randomBytes: randomBytes10, createHash: createHash5 } = __require("crypto");
|
|
2281
|
+
var { Duplex: Duplex3, Readable: Readable2 } = __require("stream");
|
|
2282
2282
|
var { URL: URL2 } = __require("url");
|
|
2283
2283
|
var PerMessageDeflate2 = require_permessage_deflate();
|
|
2284
2284
|
var Receiver2 = require_receiver();
|
|
@@ -2298,13 +2298,13 @@ var require_websocket = __commonJS({
|
|
|
2298
2298
|
var {
|
|
2299
2299
|
EventTarget: { addEventListener, removeEventListener }
|
|
2300
2300
|
} = require_event_target();
|
|
2301
|
-
var { format, parse } = require_extension();
|
|
2301
|
+
var { format, parse: parse2 } = require_extension();
|
|
2302
2302
|
var { toBuffer } = require_buffer_util();
|
|
2303
2303
|
var kAborted = /* @__PURE__ */ Symbol("kAborted");
|
|
2304
2304
|
var protocolVersions = [8, 13];
|
|
2305
2305
|
var readyStates = ["CONNECTING", "OPEN", "CLOSING", "CLOSED"];
|
|
2306
2306
|
var subprotocolRegex = /^[!#$%&'*+\-.0-9A-Z^_`|a-z~]+$/;
|
|
2307
|
-
var WebSocket2 = class _WebSocket extends
|
|
2307
|
+
var WebSocket2 = class _WebSocket extends EventEmitter4 {
|
|
2308
2308
|
/**
|
|
2309
2309
|
* Create a new `WebSocket`.
|
|
2310
2310
|
*
|
|
@@ -2815,8 +2815,8 @@ var require_websocket = __commonJS({
|
|
|
2815
2815
|
}
|
|
2816
2816
|
}
|
|
2817
2817
|
const defaultPort = isSecure ? 443 : 80;
|
|
2818
|
-
const key =
|
|
2819
|
-
const
|
|
2818
|
+
const key = randomBytes10(16).toString("base64");
|
|
2819
|
+
const request2 = isSecure ? https2.request : http4.request;
|
|
2820
2820
|
const protocolSet = /* @__PURE__ */ new Set();
|
|
2821
2821
|
let perMessageDeflate;
|
|
2822
2822
|
opts.createConnection = opts.createConnection || (isSecure ? tlsConnect : netConnect);
|
|
@@ -2893,12 +2893,12 @@ var require_websocket = __commonJS({
|
|
|
2893
2893
|
if (opts.auth && !options.headers.authorization) {
|
|
2894
2894
|
options.headers.authorization = "Basic " + Buffer.from(opts.auth).toString("base64");
|
|
2895
2895
|
}
|
|
2896
|
-
req = websocket._req =
|
|
2896
|
+
req = websocket._req = request2(opts);
|
|
2897
2897
|
if (websocket._redirects) {
|
|
2898
2898
|
websocket.emit("redirect", websocket.url, req);
|
|
2899
2899
|
}
|
|
2900
2900
|
} else {
|
|
2901
|
-
req = websocket._req =
|
|
2901
|
+
req = websocket._req = request2(opts);
|
|
2902
2902
|
}
|
|
2903
2903
|
if (opts.timeout) {
|
|
2904
2904
|
req.on("timeout", () => {
|
|
@@ -2945,7 +2945,7 @@ var require_websocket = __commonJS({
|
|
|
2945
2945
|
abortHandshake(websocket, socket2, "Invalid Upgrade header");
|
|
2946
2946
|
return;
|
|
2947
2947
|
}
|
|
2948
|
-
const digest =
|
|
2948
|
+
const digest = createHash5("sha1").update(key + GUID).digest("base64");
|
|
2949
2949
|
if (res.headers["sec-websocket-accept"] !== digest) {
|
|
2950
2950
|
abortHandshake(websocket, socket2, "Invalid Sec-WebSocket-Accept header");
|
|
2951
2951
|
return;
|
|
@@ -2975,7 +2975,7 @@ var require_websocket = __commonJS({
|
|
|
2975
2975
|
}
|
|
2976
2976
|
let extensions;
|
|
2977
2977
|
try {
|
|
2978
|
-
extensions =
|
|
2978
|
+
extensions = parse2(secWebSocketExtensions);
|
|
2979
2979
|
} catch (err) {
|
|
2980
2980
|
const message = "Invalid Sec-WebSocket-Extensions header";
|
|
2981
2981
|
abortHandshake(websocket, socket2, message);
|
|
@@ -3019,12 +3019,12 @@ var require_websocket = __commonJS({
|
|
|
3019
3019
|
}
|
|
3020
3020
|
function netConnect(options) {
|
|
3021
3021
|
options.path = options.socketPath;
|
|
3022
|
-
return
|
|
3022
|
+
return net5.connect(options);
|
|
3023
3023
|
}
|
|
3024
3024
|
function tlsConnect(options) {
|
|
3025
3025
|
options.path = void 0;
|
|
3026
3026
|
if (!options.servername && options.servername !== "") {
|
|
3027
|
-
options.servername =
|
|
3027
|
+
options.servername = net5.isIP(options.host) ? "" : options.host;
|
|
3028
3028
|
}
|
|
3029
3029
|
return tls.connect(options);
|
|
3030
3030
|
}
|
|
@@ -3065,7 +3065,7 @@ var require_websocket = __commonJS({
|
|
|
3065
3065
|
websocket._closeCode = code2;
|
|
3066
3066
|
if (websocket._socket[kWebSocket] === void 0) return;
|
|
3067
3067
|
websocket._socket.removeListener("data", socketOnData);
|
|
3068
|
-
process.nextTick(
|
|
3068
|
+
process.nextTick(resume2, websocket._socket);
|
|
3069
3069
|
if (code2 === 1005) websocket.close();
|
|
3070
3070
|
else websocket.close(code2, reason);
|
|
3071
3071
|
}
|
|
@@ -3077,7 +3077,7 @@ var require_websocket = __commonJS({
|
|
|
3077
3077
|
const websocket = this[kWebSocket];
|
|
3078
3078
|
if (websocket._socket[kWebSocket] !== void 0) {
|
|
3079
3079
|
websocket._socket.removeListener("data", socketOnData);
|
|
3080
|
-
process.nextTick(
|
|
3080
|
+
process.nextTick(resume2, websocket._socket);
|
|
3081
3081
|
websocket.close(err[kStatusCode]);
|
|
3082
3082
|
}
|
|
3083
3083
|
if (!websocket._errorEmitted) {
|
|
@@ -3099,7 +3099,7 @@ var require_websocket = __commonJS({
|
|
|
3099
3099
|
function receiverOnPong(data) {
|
|
3100
3100
|
this[kWebSocket].emit("pong", data);
|
|
3101
3101
|
}
|
|
3102
|
-
function
|
|
3102
|
+
function resume2(stream) {
|
|
3103
3103
|
stream.resume();
|
|
3104
3104
|
}
|
|
3105
3105
|
function senderOnError(err) {
|
|
@@ -3169,7 +3169,7 @@ var require_stream = __commonJS({
|
|
|
3169
3169
|
"../node_modules/ws/lib/stream.js"(exports, module) {
|
|
3170
3170
|
"use strict";
|
|
3171
3171
|
var WebSocket2 = require_websocket();
|
|
3172
|
-
var { Duplex:
|
|
3172
|
+
var { Duplex: Duplex3 } = __require("stream");
|
|
3173
3173
|
function emitClose(stream) {
|
|
3174
3174
|
stream.emit("close");
|
|
3175
3175
|
}
|
|
@@ -3187,7 +3187,7 @@ var require_stream = __commonJS({
|
|
|
3187
3187
|
}
|
|
3188
3188
|
function createWebSocketStream2(ws, options) {
|
|
3189
3189
|
let terminateOnDestroy = true;
|
|
3190
|
-
const duplex = new
|
|
3190
|
+
const duplex = new Duplex3({
|
|
3191
3191
|
...options,
|
|
3192
3192
|
autoDestroy: false,
|
|
3193
3193
|
emitClose: false,
|
|
@@ -3267,7 +3267,7 @@ var require_subprotocol = __commonJS({
|
|
|
3267
3267
|
"../node_modules/ws/lib/subprotocol.js"(exports, module) {
|
|
3268
3268
|
"use strict";
|
|
3269
3269
|
var { tokenChars } = require_validation();
|
|
3270
|
-
function
|
|
3270
|
+
function parse2(header) {
|
|
3271
3271
|
const protocols = /* @__PURE__ */ new Set();
|
|
3272
3272
|
let start = -1;
|
|
3273
3273
|
let end = -1;
|
|
@@ -3303,7 +3303,7 @@ var require_subprotocol = __commonJS({
|
|
|
3303
3303
|
protocols.add(protocol);
|
|
3304
3304
|
return protocols;
|
|
3305
3305
|
}
|
|
3306
|
-
module.exports = { parse };
|
|
3306
|
+
module.exports = { parse: parse2 };
|
|
3307
3307
|
}
|
|
3308
3308
|
});
|
|
3309
3309
|
|
|
@@ -3311,10 +3311,10 @@ var require_subprotocol = __commonJS({
|
|
|
3311
3311
|
var require_websocket_server = __commonJS({
|
|
3312
3312
|
"../node_modules/ws/lib/websocket-server.js"(exports, module) {
|
|
3313
3313
|
"use strict";
|
|
3314
|
-
var
|
|
3315
|
-
var
|
|
3316
|
-
var { Duplex:
|
|
3317
|
-
var { createHash:
|
|
3314
|
+
var EventEmitter4 = __require("events");
|
|
3315
|
+
var http4 = __require("http");
|
|
3316
|
+
var { Duplex: Duplex3 } = __require("stream");
|
|
3317
|
+
var { createHash: createHash5 } = __require("crypto");
|
|
3318
3318
|
var extension2 = require_extension();
|
|
3319
3319
|
var PerMessageDeflate2 = require_permessage_deflate();
|
|
3320
3320
|
var subprotocol2 = require_subprotocol();
|
|
@@ -3324,7 +3324,7 @@ var require_websocket_server = __commonJS({
|
|
|
3324
3324
|
var RUNNING = 0;
|
|
3325
3325
|
var CLOSING = 1;
|
|
3326
3326
|
var CLOSED = 2;
|
|
3327
|
-
var WebSocketServer2 = class extends
|
|
3327
|
+
var WebSocketServer2 = class extends EventEmitter4 {
|
|
3328
3328
|
/**
|
|
3329
3329
|
* Create a `WebSocketServer` instance.
|
|
3330
3330
|
*
|
|
@@ -3393,8 +3393,8 @@ var require_websocket_server = __commonJS({
|
|
|
3393
3393
|
);
|
|
3394
3394
|
}
|
|
3395
3395
|
if (options.port != null) {
|
|
3396
|
-
this._server =
|
|
3397
|
-
const body =
|
|
3396
|
+
this._server = http4.createServer((req, res) => {
|
|
3397
|
+
const body = http4.STATUS_CODES[426];
|
|
3398
3398
|
res.writeHead(426, {
|
|
3399
3399
|
"Content-Length": body.length,
|
|
3400
3400
|
"Content-Type": "text/plain"
|
|
@@ -3621,7 +3621,7 @@ var require_websocket_server = __commonJS({
|
|
|
3621
3621
|
);
|
|
3622
3622
|
}
|
|
3623
3623
|
if (this._state > RUNNING) return abortHandshake(socket2, 503);
|
|
3624
|
-
const digest =
|
|
3624
|
+
const digest = createHash5("sha1").update(key + GUID).digest("base64");
|
|
3625
3625
|
const headers = [
|
|
3626
3626
|
"HTTP/1.1 101 Switching Protocols",
|
|
3627
3627
|
"Upgrade: websocket",
|
|
@@ -3683,7 +3683,7 @@ var require_websocket_server = __commonJS({
|
|
|
3683
3683
|
this.destroy();
|
|
3684
3684
|
}
|
|
3685
3685
|
function abortHandshake(socket2, code2, message, headers) {
|
|
3686
|
-
message = message ||
|
|
3686
|
+
message = message || http4.STATUS_CODES[code2];
|
|
3687
3687
|
headers = {
|
|
3688
3688
|
Connection: "close",
|
|
3689
3689
|
"Content-Type": "text/html",
|
|
@@ -3692,7 +3692,7 @@ var require_websocket_server = __commonJS({
|
|
|
3692
3692
|
};
|
|
3693
3693
|
socket2.once("finish", socket2.destroy);
|
|
3694
3694
|
socket2.end(
|
|
3695
|
-
`HTTP/1.1 ${code2} ${
|
|
3695
|
+
`HTTP/1.1 ${code2} ${http4.STATUS_CODES[code2]}\r
|
|
3696
3696
|
` + Object.keys(headers).map((h) => `${h}: ${headers[h]}`).join("\r\n") + "\r\n\r\n" + message
|
|
3697
3697
|
);
|
|
3698
3698
|
}
|
|
@@ -3739,9 +3739,9 @@ var UsageError = class extends CliError {
|
|
|
3739
3739
|
}
|
|
3740
3740
|
};
|
|
3741
3741
|
var ApiError = class extends CliError {
|
|
3742
|
-
constructor(
|
|
3743
|
-
super(message,
|
|
3744
|
-
this.status =
|
|
3742
|
+
constructor(status3, message, code2 = null) {
|
|
3743
|
+
super(message, status3 === 401 ? EXIT.AUTH : status3 === 404 ? EXIT.NOT_FOUND : EXIT.FAILURE);
|
|
3744
|
+
this.status = status3;
|
|
3745
3745
|
this.code = code2;
|
|
3746
3746
|
this.name = "ApiError";
|
|
3747
3747
|
}
|
|
@@ -3868,7 +3868,7 @@ function parseSize(raw) {
|
|
|
3868
3868
|
}
|
|
3869
3869
|
|
|
3870
3870
|
// src/version.ts
|
|
3871
|
-
var VERSION = true ? "v0.1.
|
|
3871
|
+
var VERSION = true ? "v0.1.10" : "0.0.0-dev";
|
|
3872
3872
|
var STANDALONE = typeof __AGENTBOX_STANDALONE__ === "boolean" && __AGENTBOX_STANDALONE__;
|
|
3873
3873
|
var MIN_NODE_MAJOR = 20;
|
|
3874
3874
|
function nodeMajor(version = process.versions.node) {
|
|
@@ -4083,8 +4083,8 @@ var status = command({
|
|
|
4083
4083
|
else if (list.length === 0) row("apps", "none");
|
|
4084
4084
|
else {
|
|
4085
4085
|
const isShared = (a) => Boolean(a.visibility?.mode && a.visibility.mode !== "private" && (a.visibility.expiresAt == null || a.visibility.expiresAt > ctx.now()));
|
|
4086
|
-
const
|
|
4087
|
-
row("apps", `${list.length}${
|
|
4086
|
+
const shared2 = list.filter(isShared).length;
|
|
4087
|
+
row("apps", `${list.length}${shared2 ? ` (${shared2} shared)` : ""}`);
|
|
4088
4088
|
const lines = table(
|
|
4089
4089
|
["NAME", "PORT", "STATE", "SHARING"],
|
|
4090
4090
|
list.map((a) => [
|
|
@@ -4266,13 +4266,13 @@ var TunnelStream = class extends Duplex {
|
|
|
4266
4266
|
this.ws = ws;
|
|
4267
4267
|
ws.on("message", (data, isBinary) => {
|
|
4268
4268
|
if (!isBinary) {
|
|
4269
|
-
let
|
|
4269
|
+
let control2 = null;
|
|
4270
4270
|
try {
|
|
4271
|
-
|
|
4271
|
+
control2 = JSON.parse(data.toString("utf8"));
|
|
4272
4272
|
} catch {
|
|
4273
4273
|
}
|
|
4274
|
-
if (
|
|
4275
|
-
const err = new TunnelError(
|
|
4274
|
+
if (control2?.type === "error") {
|
|
4275
|
+
const err = new TunnelError(control2.message ?? "the tunnel failed");
|
|
4276
4276
|
setImmediate(() => this.destroy(err));
|
|
4277
4277
|
}
|
|
4278
4278
|
return;
|
|
@@ -4317,8 +4317,8 @@ function openTunnel(client, target, opts = {}) {
|
|
|
4317
4317
|
const early = (data, isBinary) => {
|
|
4318
4318
|
if (isBinary) return;
|
|
4319
4319
|
try {
|
|
4320
|
-
const
|
|
4321
|
-
if (
|
|
4320
|
+
const control2 = JSON.parse(data.toString("utf8"));
|
|
4321
|
+
if (control2.type === "error") fail(new TunnelError(control2.message ?? "the tunnel failed"));
|
|
4322
4322
|
} catch {
|
|
4323
4323
|
}
|
|
4324
4324
|
};
|
|
@@ -4336,10 +4336,10 @@ function openTunnel(client, target, opts = {}) {
|
|
|
4336
4336
|
});
|
|
4337
4337
|
ws.on("message", early);
|
|
4338
4338
|
ws.once("unexpected-response", (_req, res) => {
|
|
4339
|
-
const
|
|
4339
|
+
const status3 = res.statusCode ?? 0;
|
|
4340
4340
|
res.resume();
|
|
4341
4341
|
fail(
|
|
4342
|
-
|
|
4342
|
+
status3 === 401 ? new CliError("the box did not accept this device's sign-in; run `agentbox login` again", EXIT.AUTH) : status3 === 404 ? new CliError(`${client.origin} has no tunnel endpoint yet; update the box`, EXIT.NOT_FOUND) : new TunnelError(`the box refused the tunnel (HTTP ${status3})`)
|
|
4343
4343
|
);
|
|
4344
4344
|
});
|
|
4345
4345
|
ws.once("error", (err) => fail(new CliError(`could not open a tunnel to ${client.origin}: ${err.message}`, EXIT.UNREACHABLE)));
|
|
@@ -4529,20 +4529,20 @@ var share = command({
|
|
|
4529
4529
|
const body = { mode: passcode ? "passcode" : "link" };
|
|
4530
4530
|
if (expires !== void 0) body.expiresIn = parseExpiry(expires);
|
|
4531
4531
|
if (chosen !== void 0) body.passcode = chosen;
|
|
4532
|
-
const
|
|
4532
|
+
const shared2 = await client.json("PUT", `/_gate/apps/${encodeURIComponent(app.id)}/visibility`, {
|
|
4533
4533
|
body: { json: body },
|
|
4534
4534
|
what: `sharing ${app.name}`
|
|
4535
4535
|
});
|
|
4536
4536
|
const url = `${box.url}/a/${app.id}/`;
|
|
4537
4537
|
if (ctx.json) {
|
|
4538
|
-
ctx.printJson({ ...
|
|
4538
|
+
ctx.printJson({ ...shared2, url });
|
|
4539
4539
|
return;
|
|
4540
4540
|
}
|
|
4541
4541
|
ctx.out(`${url}
|
|
4542
4542
|
`);
|
|
4543
|
-
if (
|
|
4543
|
+
if (shared2.passcode) ctx.out(`passcode ${shared2.passcode}
|
|
4544
4544
|
`);
|
|
4545
|
-
ctx.err(`${safeText(app.name)} is ${safeText(sharing(
|
|
4545
|
+
ctx.err(`${safeText(app.name)} is ${safeText(sharing(shared2, ctx.now()))}${shared2.passcode ? "; the passcode is shown only this once" : ""}.
|
|
4546
4546
|
`);
|
|
4547
4547
|
}
|
|
4548
4548
|
});
|
|
@@ -4677,10 +4677,10 @@ function hasBox(boxes2, name) {
|
|
|
4677
4677
|
function emptyConfig() {
|
|
4678
4678
|
return { version: 1, current: null, boxes: boxTable() };
|
|
4679
4679
|
}
|
|
4680
|
-
function configDir(env = process.env,
|
|
4680
|
+
function configDir(env = process.env, home2 = os.homedir(), platform = process.platform) {
|
|
4681
4681
|
if (platform === "win32" && env.APPDATA && path3.win32.isAbsolute(env.APPDATA)) return path3.win32.join(env.APPDATA, "agentbox");
|
|
4682
4682
|
const xdg = env.XDG_CONFIG_HOME;
|
|
4683
|
-
const base = xdg && path3.isAbsolute(xdg) ? xdg : path3.join(
|
|
4683
|
+
const base = xdg && path3.isAbsolute(xdg) ? xdg : path3.join(home2, ".config");
|
|
4684
4684
|
return path3.join(base, "agentbox");
|
|
4685
4685
|
}
|
|
4686
4686
|
var BOX_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
@@ -4915,7 +4915,7 @@ async function pollForToken(client, start, deps = {}) {
|
|
|
4915
4915
|
await wait(delay, deps.signal);
|
|
4916
4916
|
delay = interval;
|
|
4917
4917
|
if (now() > deadline) throw new CliError("the code expired before it was approved; run `agentbox login` again");
|
|
4918
|
-
let
|
|
4918
|
+
let status3;
|
|
4919
4919
|
let body;
|
|
4920
4920
|
let retryAfter;
|
|
4921
4921
|
try {
|
|
@@ -4925,7 +4925,7 @@ async function pollForToken(client, start, deps = {}) {
|
|
|
4925
4925
|
headers: { accept: "application/json" },
|
|
4926
4926
|
...deps.signal ? { signal: deps.signal } : {}
|
|
4927
4927
|
});
|
|
4928
|
-
|
|
4928
|
+
status3 = res.status;
|
|
4929
4929
|
retryAfter = typeof res.headers["retry-after"] === "string" ? res.headers["retry-after"] : void 0;
|
|
4930
4930
|
const text = await res.text();
|
|
4931
4931
|
try {
|
|
@@ -4940,17 +4940,17 @@ async function pollForToken(client, start, deps = {}) {
|
|
|
4940
4940
|
}
|
|
4941
4941
|
throw err;
|
|
4942
4942
|
}
|
|
4943
|
-
if (
|
|
4944
|
-
if (++failures > maxFailures) throw new CliError(`the box kept failing to answer the sign-in (HTTP ${
|
|
4943
|
+
if (status3 >= 500) {
|
|
4944
|
+
if (++failures > maxFailures) throw new CliError(`the box kept failing to answer the sign-in (HTTP ${status3}); try again later`);
|
|
4945
4945
|
delay = backoff(interval, failures);
|
|
4946
4946
|
continue;
|
|
4947
4947
|
}
|
|
4948
4948
|
failures = 0;
|
|
4949
|
-
if (
|
|
4949
|
+
if (status3 === 200) {
|
|
4950
4950
|
if (typeof body.token === "string" && TOKEN_SHAPE.test(body.token)) return body.token;
|
|
4951
4951
|
throw new CliError("the box answered with something that is not a device token");
|
|
4952
4952
|
}
|
|
4953
|
-
if (
|
|
4953
|
+
if (status3 === 429) {
|
|
4954
4954
|
const s = Number(retryAfter);
|
|
4955
4955
|
if (Number.isFinite(s) && s > 0) await wait(s * 1e3, deps.signal);
|
|
4956
4956
|
continue;
|
|
@@ -4967,7 +4967,7 @@ async function pollForToken(client, start, deps = {}) {
|
|
|
4967
4967
|
case "expired_token":
|
|
4968
4968
|
throw new CliError("the code expired, or was already used; run `agentbox login` again");
|
|
4969
4969
|
default:
|
|
4970
|
-
throw new CliError(`the box refused the sign-in (HTTP ${
|
|
4970
|
+
throw new CliError(`the box refused the sign-in (HTTP ${status3}${typeof body.error === "string" ? `: ${body.error}` : ""})`);
|
|
4971
4971
|
}
|
|
4972
4972
|
}
|
|
4973
4973
|
}
|
|
@@ -4991,9 +4991,9 @@ import os3 from "node:os";
|
|
|
4991
4991
|
import path5 from "node:path";
|
|
4992
4992
|
|
|
4993
4993
|
// ../shared/src/index.ts
|
|
4994
|
-
function encodePathParam(
|
|
4994
|
+
function encodePathParam(path22) {
|
|
4995
4995
|
let out = "";
|
|
4996
|
-
for (const ch of
|
|
4996
|
+
for (const ch of path22) {
|
|
4997
4997
|
const cp = ch.codePointAt(0) ?? 0;
|
|
4998
4998
|
out += cp >= 56448 && cp <= 56575 ? `%${(cp - 56320).toString(16).toUpperCase()}` : encodeURIComponent(ch);
|
|
4999
4999
|
}
|
|
@@ -5065,8 +5065,8 @@ var FilesApi = class {
|
|
|
5065
5065
|
copy(from, to, overwrite) {
|
|
5066
5066
|
return this.client.json("POST", "/api/files/copy", { body: { json: { from, to, overwrite } }, what: `copying ${from}`, idleMs: 6e5 });
|
|
5067
5067
|
}
|
|
5068
|
-
trash(
|
|
5069
|
-
return this.client.json("POST", "/api/files/trash", { body: { json: { paths } }, what: "moving to the trash" });
|
|
5068
|
+
trash(paths2) {
|
|
5069
|
+
return this.client.json("POST", "/api/files/trash", { body: { json: { paths: paths2 } }, what: "moving to the trash" });
|
|
5070
5070
|
}
|
|
5071
5071
|
startUpload(p, size, overwrite) {
|
|
5072
5072
|
return this.client.json("POST", "/api/files/uploads", { body: { json: { path: p, size, overwrite } }, what: `uploading to ${p}` });
|
|
@@ -5402,8 +5402,8 @@ function readIfThere(file) {
|
|
|
5402
5402
|
}
|
|
5403
5403
|
}
|
|
5404
5404
|
function tilde(ctx, p) {
|
|
5405
|
-
const
|
|
5406
|
-
return p.startsWith(
|
|
5405
|
+
const home2 = homeDir(ctx);
|
|
5406
|
+
return p.startsWith(home2 + path5.sep) ? `~${p.slice(home2.length)}` : p;
|
|
5407
5407
|
}
|
|
5408
5408
|
function sshConfigArgs(ctx) {
|
|
5409
5409
|
const mine = path5.join(sshDir(ctx), "config");
|
|
@@ -6405,7 +6405,7 @@ var put = command({
|
|
|
6405
6405
|
const force = bool(p.options, "force");
|
|
6406
6406
|
const chunkRaw = typeof p.options["chunk-size"] === "string" ? parseSize(p.options["chunk-size"]) : MAX_CHUNK;
|
|
6407
6407
|
if (chunkRaw < 1024 || chunkRaw > MAX_CHUNK) throw new UsageError(`--chunk-size must be from 1K to ${MAX_CHUNK / 1024 / 1024}M`);
|
|
6408
|
-
const
|
|
6408
|
+
const resume2 = new ResumeStore(ctx.config.dir);
|
|
6409
6409
|
let target = await files.stat(remote);
|
|
6410
6410
|
if (!target && remote.endsWith("/")) target = await files.mkdir(remote);
|
|
6411
6411
|
const intoDir = isDir(target);
|
|
@@ -6420,7 +6420,7 @@ var put = command({
|
|
|
6420
6420
|
remote: dest,
|
|
6421
6421
|
overwrite: force,
|
|
6422
6422
|
chunkSize: chunkRaw,
|
|
6423
|
-
resume,
|
|
6423
|
+
resume: resume2,
|
|
6424
6424
|
signal: ctx.abort.signal,
|
|
6425
6425
|
onProgress: (sent) => bar.update(sent),
|
|
6426
6426
|
onResume: (received, total) => ctx.err(`${file}: resuming at ${formatBytes(received)} of ${formatBytes(total)}
|
|
@@ -6492,7 +6492,7 @@ var rm = command({
|
|
|
6492
6492
|
details: "Nothing is deleted outright: restore from Files \u2192 Trash in the app.",
|
|
6493
6493
|
async run(ctx, p) {
|
|
6494
6494
|
const files = await filesApi(ctx);
|
|
6495
|
-
const
|
|
6495
|
+
const paths2 = [];
|
|
6496
6496
|
for (const target of p.operands) {
|
|
6497
6497
|
const e = await files.stat(target);
|
|
6498
6498
|
if (!e) {
|
|
@@ -6500,10 +6500,10 @@ var rm = command({
|
|
|
6500
6500
|
throw new CliError(`${target}: no such file or folder on the box`, EXIT.NOT_FOUND);
|
|
6501
6501
|
}
|
|
6502
6502
|
if (e.type === "dir" && !bool(p.options, "recursive")) throw new CliError(`${e.path} is a folder; add -r to trash it and everything in it`);
|
|
6503
|
-
|
|
6503
|
+
paths2.push(e.path);
|
|
6504
6504
|
}
|
|
6505
|
-
if (
|
|
6506
|
-
const result = await files.trash(
|
|
6505
|
+
if (paths2.length === 0) return;
|
|
6506
|
+
const result = await files.trash(paths2);
|
|
6507
6507
|
for (const t2 of result.trashed) ctx.err(`trashed ${safeText(t2.originalPath)}
|
|
6508
6508
|
`);
|
|
6509
6509
|
}
|
|
@@ -6578,8 +6578,8 @@ function editorCommand(editor, file, platform, env = process.env) {
|
|
|
6578
6578
|
if (!CMD_SAFE.test(file)) {
|
|
6579
6579
|
throw new CliError(`the temporary file's path (${file}) has characters cmd.exe would act on; set TEMP to a plain folder, or use an editor that is an .exe`);
|
|
6580
6580
|
}
|
|
6581
|
-
const
|
|
6582
|
-
return { command: [resolved, ...rest, file].map(
|
|
6581
|
+
const quote2 = (w) => `"${w.replace(/"/g, "")}"`;
|
|
6582
|
+
return { command: [resolved, ...rest, file].map(quote2).join(" "), args: [], shell: true };
|
|
6583
6583
|
}
|
|
6584
6584
|
function runEditor(editor, file, platform = process.platform, env = process.env) {
|
|
6585
6585
|
return new Promise((resolve, reject) => {
|
|
@@ -6806,14 +6806,3335 @@ var add = command({
|
|
|
6806
6806
|
});
|
|
6807
6807
|
var HERDR_COMMANDS = [add, call, socket];
|
|
6808
6808
|
|
|
6809
|
+
// src/commands/node.ts
|
|
6810
|
+
import { spawn as spawn7 } from "node:child_process";
|
|
6811
|
+
import fs20 from "node:fs";
|
|
6812
|
+
import os11 from "node:os";
|
|
6813
|
+
import path18 from "node:path";
|
|
6814
|
+
|
|
6815
|
+
// src/node/control.ts
|
|
6816
|
+
import { randomBytes as randomBytes4, timingSafeEqual } from "node:crypto";
|
|
6817
|
+
import fs9 from "node:fs";
|
|
6818
|
+
import net3 from "node:net";
|
|
6819
|
+
|
|
6820
|
+
// src/node/paths.ts
|
|
6821
|
+
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
6822
|
+
import fs8 from "node:fs";
|
|
6823
|
+
import os7 from "node:os";
|
|
6824
|
+
import path9 from "node:path";
|
|
6825
|
+
function nodeDir(env = process.env, home2 = os7.homedir(), platform = process.platform) {
|
|
6826
|
+
if (platform === "win32") {
|
|
6827
|
+
const local = env.LOCALAPPDATA && path9.win32.isAbsolute(env.LOCALAPPDATA) ? env.LOCALAPPDATA : path9.win32.join(home2, "AppData", "Local");
|
|
6828
|
+
return path9.win32.join(local, "agentbox", "node");
|
|
6829
|
+
}
|
|
6830
|
+
const xdg = env.XDG_DATA_HOME;
|
|
6831
|
+
const base = xdg && path9.isAbsolute(xdg) ? xdg : path9.join(home2, ".local", "share");
|
|
6832
|
+
return path9.join(base, "agentbox", "node");
|
|
6833
|
+
}
|
|
6834
|
+
function nodePaths(dir) {
|
|
6835
|
+
return {
|
|
6836
|
+
dir,
|
|
6837
|
+
policy: path9.join(dir, "policy.json"),
|
|
6838
|
+
credentials: path9.join(dir, "credentials.json"),
|
|
6839
|
+
secrets: path9.join(dir, "secrets"),
|
|
6840
|
+
bin: path9.join(dir, "bin"),
|
|
6841
|
+
state: path9.join(dir, "state"),
|
|
6842
|
+
control: path9.join(dir, "control.sock"),
|
|
6843
|
+
controlSecret: path9.join(dir, "control.secret")
|
|
6844
|
+
};
|
|
6845
|
+
}
|
|
6846
|
+
function ensurePrivateDir(dir) {
|
|
6847
|
+
fs8.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
6848
|
+
if (process.platform !== "win32") fs8.chmodSync(dir, 448);
|
|
6849
|
+
}
|
|
6850
|
+
function writePrivate2(file, contents, mode = 384) {
|
|
6851
|
+
ensurePrivateDir(path9.dirname(file));
|
|
6852
|
+
const tmp = path9.join(path9.dirname(file), `.${path9.basename(file)}.${randomBytes3(6).toString("hex")}.tmp`);
|
|
6853
|
+
const fd = fs8.openSync(tmp, "wx", mode);
|
|
6854
|
+
try {
|
|
6855
|
+
fs8.writeFileSync(fd, contents);
|
|
6856
|
+
if (process.platform !== "win32") fs8.fchmodSync(fd, mode);
|
|
6857
|
+
fs8.fsyncSync(fd);
|
|
6858
|
+
} catch (err) {
|
|
6859
|
+
fs8.closeSync(fd);
|
|
6860
|
+
fs8.rmSync(tmp, { force: true });
|
|
6861
|
+
throw err;
|
|
6862
|
+
}
|
|
6863
|
+
fs8.closeSync(fd);
|
|
6864
|
+
try {
|
|
6865
|
+
fs8.renameSync(tmp, file);
|
|
6866
|
+
} catch (err) {
|
|
6867
|
+
fs8.rmSync(tmp, { force: true });
|
|
6868
|
+
throw err;
|
|
6869
|
+
}
|
|
6870
|
+
}
|
|
6871
|
+
|
|
6872
|
+
// src/node/control.ts
|
|
6873
|
+
var COMMANDS = /* @__PURE__ */ new Set(["status", "pause", "resume", "leave"]);
|
|
6874
|
+
async function startControl(socketPath, secretFile, handle) {
|
|
6875
|
+
const secret = randomBytes4(32).toString("base64url");
|
|
6876
|
+
writePrivate2(secretFile, secret);
|
|
6877
|
+
const want = Buffer.from(secret);
|
|
6878
|
+
fs9.rmSync(socketPath, { force: true });
|
|
6879
|
+
const server = net3.createServer((sock) => {
|
|
6880
|
+
let buf = "";
|
|
6881
|
+
sock.setTimeout(1e4, () => sock.destroy());
|
|
6882
|
+
sock.on("error", () => sock.destroy());
|
|
6883
|
+
sock.on("data", (c) => {
|
|
6884
|
+
buf += c.toString("utf8");
|
|
6885
|
+
if (buf.length > 16 * 1024) return sock.destroy();
|
|
6886
|
+
const nl = buf.indexOf("\n");
|
|
6887
|
+
if (nl < 0) return;
|
|
6888
|
+
const reply = (o) => {
|
|
6889
|
+
sock.end(`${JSON.stringify(o)}
|
|
6890
|
+
`);
|
|
6891
|
+
};
|
|
6892
|
+
let req;
|
|
6893
|
+
try {
|
|
6894
|
+
req = JSON.parse(buf.slice(0, nl));
|
|
6895
|
+
} catch {
|
|
6896
|
+
return reply({ ok: false, error: "bad request" });
|
|
6897
|
+
}
|
|
6898
|
+
const got = Buffer.from(typeof req.secret === "string" ? req.secret : "");
|
|
6899
|
+
if (got.length !== want.length || !timingSafeEqual(got, want)) return reply({ ok: false, error: "refused" });
|
|
6900
|
+
if (typeof req.command !== "string" || !COMMANDS.has(req.command)) return reply({ ok: false, error: "unknown command" });
|
|
6901
|
+
handle(req.command).then(
|
|
6902
|
+
(out) => reply({ ok: true, ...out }),
|
|
6903
|
+
(err) => reply({ ok: false, error: err.message })
|
|
6904
|
+
);
|
|
6905
|
+
});
|
|
6906
|
+
});
|
|
6907
|
+
await new Promise((resolve, reject) => {
|
|
6908
|
+
server.once("error", reject);
|
|
6909
|
+
server.listen(socketPath, () => {
|
|
6910
|
+
server.off("error", reject);
|
|
6911
|
+
resolve();
|
|
6912
|
+
});
|
|
6913
|
+
});
|
|
6914
|
+
if (process.platform !== "win32") fs9.chmodSync(socketPath, 384);
|
|
6915
|
+
return {
|
|
6916
|
+
close: () => {
|
|
6917
|
+
server.close();
|
|
6918
|
+
fs9.rmSync(socketPath, { force: true });
|
|
6919
|
+
}
|
|
6920
|
+
};
|
|
6921
|
+
}
|
|
6922
|
+
function callControl(socketPath, secretFile, command2, timeoutMs = 1e4) {
|
|
6923
|
+
let secret;
|
|
6924
|
+
try {
|
|
6925
|
+
secret = fs9.readFileSync(secretFile, "utf8").trim();
|
|
6926
|
+
} catch {
|
|
6927
|
+
return Promise.resolve(null);
|
|
6928
|
+
}
|
|
6929
|
+
return new Promise((resolve) => {
|
|
6930
|
+
const sock = net3.connect(socketPath);
|
|
6931
|
+
let buf = "";
|
|
6932
|
+
const timer = setTimeout(() => {
|
|
6933
|
+
sock.destroy();
|
|
6934
|
+
resolve(null);
|
|
6935
|
+
}, timeoutMs);
|
|
6936
|
+
sock.on("connect", () => sock.write(`${JSON.stringify({ secret, command: command2 })}
|
|
6937
|
+
`));
|
|
6938
|
+
sock.on("data", (c) => buf += c.toString("utf8"));
|
|
6939
|
+
sock.on("end", () => {
|
|
6940
|
+
clearTimeout(timer);
|
|
6941
|
+
try {
|
|
6942
|
+
resolve(JSON.parse(buf));
|
|
6943
|
+
} catch {
|
|
6944
|
+
resolve(null);
|
|
6945
|
+
}
|
|
6946
|
+
});
|
|
6947
|
+
sock.on("error", () => {
|
|
6948
|
+
clearTimeout(timer);
|
|
6949
|
+
resolve(null);
|
|
6950
|
+
});
|
|
6951
|
+
});
|
|
6952
|
+
}
|
|
6953
|
+
|
|
6954
|
+
// src/node/credentials.ts
|
|
6955
|
+
import fs10 from "node:fs";
|
|
6956
|
+
|
|
6957
|
+
// ../hostcore/src/link/frame.ts
|
|
6958
|
+
var FrameType = {
|
|
6959
|
+
open: 1,
|
|
6960
|
+
data: 2,
|
|
6961
|
+
close: 3,
|
|
6962
|
+
window: 4,
|
|
6963
|
+
control: 5
|
|
6964
|
+
};
|
|
6965
|
+
var TYPES = new Set(Object.values(FrameType));
|
|
6966
|
+
var HEADER_BYTES = 5;
|
|
6967
|
+
var CONTROL_MAX_BYTES = 64 * 1024;
|
|
6968
|
+
var HELLO_MAX_BYTES = 16 * 1024;
|
|
6969
|
+
var MAX_STREAM_ID = 4294967295;
|
|
6970
|
+
var FrameError = class extends Error {
|
|
6971
|
+
};
|
|
6972
|
+
function encodeFrame(type, stream, payload = Buffer.alloc(0)) {
|
|
6973
|
+
if (!TYPES.has(type)) throw new FrameError(`unknown frame type ${type}`);
|
|
6974
|
+
if (!Number.isInteger(stream) || stream < 0 || stream > MAX_STREAM_ID) throw new FrameError(`bad stream id ${stream}`);
|
|
6975
|
+
if (type === FrameType.control && stream !== 0) throw new FrameError("control frames are on stream 0");
|
|
6976
|
+
if (type !== FrameType.control && stream === 0) throw new FrameError("stream 0 is the link's own");
|
|
6977
|
+
const out = Buffer.allocUnsafe(HEADER_BYTES + payload.length);
|
|
6978
|
+
out.writeUInt8(type, 0);
|
|
6979
|
+
out.writeUInt32BE(stream, 1);
|
|
6980
|
+
out.set(payload, HEADER_BYTES);
|
|
6981
|
+
return out;
|
|
6982
|
+
}
|
|
6983
|
+
function decodeFrame(buf) {
|
|
6984
|
+
if (buf.length < HEADER_BYTES) throw new FrameError("frame too short");
|
|
6985
|
+
const type = buf.readUInt8(0);
|
|
6986
|
+
if (!TYPES.has(type)) throw new FrameError(`unknown frame type ${type}`);
|
|
6987
|
+
const stream = buf.readUInt32BE(1);
|
|
6988
|
+
const payload = buf.subarray(HEADER_BYTES);
|
|
6989
|
+
if (type === FrameType.control) {
|
|
6990
|
+
if (stream !== 0) throw new FrameError("control frames are on stream 0");
|
|
6991
|
+
if (payload.length > CONTROL_MAX_BYTES) throw new FrameError("control message too large");
|
|
6992
|
+
} else if (stream === 0) {
|
|
6993
|
+
throw new FrameError("stream 0 is the link's own");
|
|
6994
|
+
}
|
|
6995
|
+
return { type, stream, payload };
|
|
6996
|
+
}
|
|
6997
|
+
function encodeControl(message) {
|
|
6998
|
+
const json2 = Buffer.from(JSON.stringify(message), "utf8");
|
|
6999
|
+
if (json2.length > CONTROL_MAX_BYTES) throw new FrameError("control message too large");
|
|
7000
|
+
return encodeFrame(FrameType.control, 0, json2);
|
|
7001
|
+
}
|
|
7002
|
+
function decodeControl(payload) {
|
|
7003
|
+
let value;
|
|
7004
|
+
try {
|
|
7005
|
+
value = JSON.parse(payload.toString("utf8"));
|
|
7006
|
+
} catch {
|
|
7007
|
+
throw new FrameError("control message is not JSON");
|
|
7008
|
+
}
|
|
7009
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new FrameError("control message is not an object");
|
|
7010
|
+
const type = value.type;
|
|
7011
|
+
if (typeof type !== "string" || type === "") throw new FrameError("control message has no type");
|
|
7012
|
+
return value;
|
|
7013
|
+
}
|
|
7014
|
+
|
|
7015
|
+
// ../hostcore/src/link/protocol.ts
|
|
7016
|
+
import { createHash as createHash3, createHmac, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
|
|
7017
|
+
var PROTOCOL_VERSION = 1;
|
|
7018
|
+
var LINK_PATH = "/_gate/node";
|
|
7019
|
+
var DUPLICATE_BACKOFF_MS = 5 * 6e4;
|
|
7020
|
+
var NODE_TOKEN_SHAPE = /^abn_[A-Za-z0-9_-]{43}$/;
|
|
7021
|
+
function tokenDigest(token) {
|
|
7022
|
+
return createHash3("sha256").update(token).digest("hex");
|
|
7023
|
+
}
|
|
7024
|
+
function revokeMac(tokenDigestHex, machineId, nonce) {
|
|
7025
|
+
return createHmac("sha256", tokenDigestHex).update(`agentbox-revoked
|
|
7026
|
+
${machineId}
|
|
7027
|
+
${nonce}`).digest("hex");
|
|
7028
|
+
}
|
|
7029
|
+
function verifyRevoke(token, machineId, nonce, mac) {
|
|
7030
|
+
if (typeof mac !== "string" || !/^[0-9a-f]{64}$/.test(mac)) return false;
|
|
7031
|
+
const want = Buffer.from(revokeMac(tokenDigest(token), machineId, nonce), "hex");
|
|
7032
|
+
return timingSafeEqual2(want, Buffer.from(mac, "hex"));
|
|
7033
|
+
}
|
|
7034
|
+
|
|
7035
|
+
// ../hostcore/src/link/stream.ts
|
|
7036
|
+
import { Duplex as Duplex2 } from "node:stream";
|
|
7037
|
+
var STREAM_WINDOW = 256 * 1024;
|
|
7038
|
+
var DATA_CHUNK = 64 * 1024;
|
|
7039
|
+
var STREAM_BUDGET = 64 * 1024 * 1024;
|
|
7040
|
+
function decodeOpen(payload) {
|
|
7041
|
+
let v = {};
|
|
7042
|
+
try {
|
|
7043
|
+
v = JSON.parse(payload.toString("utf8"));
|
|
7044
|
+
} catch {
|
|
7045
|
+
}
|
|
7046
|
+
const id = (s) => typeof s === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(s) ? s : null;
|
|
7047
|
+
return { kind: v.kind === "upgrade" ? "upgrade" : "http", session: id(v.session), grant: id(v.grant) };
|
|
7048
|
+
}
|
|
7049
|
+
function windowPayload(n) {
|
|
7050
|
+
const b = Buffer.allocUnsafe(4);
|
|
7051
|
+
b.writeUInt32BE(n, 0);
|
|
7052
|
+
return b;
|
|
7053
|
+
}
|
|
7054
|
+
var LinkStream = class extends Duplex2 {
|
|
7055
|
+
constructor(id, sendFrame, window = STREAM_WINDOW) {
|
|
7056
|
+
super({ allowHalfOpen: false });
|
|
7057
|
+
this.id = id;
|
|
7058
|
+
this.sendFrame = sendFrame;
|
|
7059
|
+
this.window = window;
|
|
7060
|
+
this.credit = window;
|
|
7061
|
+
this.allowance = window;
|
|
7062
|
+
}
|
|
7063
|
+
id;
|
|
7064
|
+
sendFrame;
|
|
7065
|
+
window;
|
|
7066
|
+
/** Bytes the peer will still take from us. */
|
|
7067
|
+
credit;
|
|
7068
|
+
/** Bytes the peer may still send us before it must wait for a window frame. */
|
|
7069
|
+
allowance;
|
|
7070
|
+
/** Bytes received and pushed to our reader, not yet credited back. */
|
|
7071
|
+
unacked = 0;
|
|
7072
|
+
pending = null;
|
|
7073
|
+
remoteClosed = false;
|
|
7074
|
+
closeSent = false;
|
|
7075
|
+
// What net.Socket has and Node's HTTP server, its client and `ws` call.
|
|
7076
|
+
setTimeout(_ms, cb) {
|
|
7077
|
+
if (cb) this.once("timeout", cb);
|
|
7078
|
+
return this;
|
|
7079
|
+
}
|
|
7080
|
+
setNoDelay() {
|
|
7081
|
+
return this;
|
|
7082
|
+
}
|
|
7083
|
+
setKeepAlive() {
|
|
7084
|
+
return this;
|
|
7085
|
+
}
|
|
7086
|
+
ref() {
|
|
7087
|
+
return this;
|
|
7088
|
+
}
|
|
7089
|
+
unref() {
|
|
7090
|
+
return this;
|
|
7091
|
+
}
|
|
7092
|
+
/** A frame for this stream, from the link. */
|
|
7093
|
+
receive(frame) {
|
|
7094
|
+
if (this.destroyed) return;
|
|
7095
|
+
switch (frame.type) {
|
|
7096
|
+
case FrameType.data: {
|
|
7097
|
+
if (this.remoteClosed) return;
|
|
7098
|
+
const n = frame.payload.length;
|
|
7099
|
+
if (n === 0) return;
|
|
7100
|
+
this.allowance -= n;
|
|
7101
|
+
if (this.allowance < 0) {
|
|
7102
|
+
this.destroy(new Error("the far end sent past its window"));
|
|
7103
|
+
return;
|
|
7104
|
+
}
|
|
7105
|
+
this.unacked += n;
|
|
7106
|
+
if (this.push(Buffer.from(frame.payload)) && this.unacked >= this.window / 4) this.grant();
|
|
7107
|
+
return;
|
|
7108
|
+
}
|
|
7109
|
+
case FrameType.window: {
|
|
7110
|
+
if (frame.payload.length !== 4) {
|
|
7111
|
+
this.destroy(new Error("bad window frame"));
|
|
7112
|
+
return;
|
|
7113
|
+
}
|
|
7114
|
+
this.credit += frame.payload.readUInt32BE(0);
|
|
7115
|
+
if (this.credit > this.window * 4) {
|
|
7116
|
+
this.destroy(new Error("the far end widened the window past any it was given"));
|
|
7117
|
+
return;
|
|
7118
|
+
}
|
|
7119
|
+
this.flush();
|
|
7120
|
+
return;
|
|
7121
|
+
}
|
|
7122
|
+
case FrameType.close:
|
|
7123
|
+
this.remoteEnd();
|
|
7124
|
+
return;
|
|
7125
|
+
default:
|
|
7126
|
+
return;
|
|
7127
|
+
}
|
|
7128
|
+
}
|
|
7129
|
+
/** The far end is gone (its close, or the link's end). */
|
|
7130
|
+
remoteEnd() {
|
|
7131
|
+
if (this.remoteClosed) return;
|
|
7132
|
+
this.remoteClosed = true;
|
|
7133
|
+
this.closeSent = true;
|
|
7134
|
+
const p = this.pending;
|
|
7135
|
+
this.pending = null;
|
|
7136
|
+
p?.cb();
|
|
7137
|
+
this.push(null);
|
|
7138
|
+
if (this.readableLength === 0) setImmediate(() => this.destroy());
|
|
7139
|
+
else this.once("end", () => this.destroy());
|
|
7140
|
+
}
|
|
7141
|
+
grant() {
|
|
7142
|
+
if (this.unacked <= 0 || this.remoteClosed || this.closeSent) return;
|
|
7143
|
+
const n = this.unacked;
|
|
7144
|
+
this.unacked = 0;
|
|
7145
|
+
this.allowance += n;
|
|
7146
|
+
this.sendFrame(encodeFrame(FrameType.window, this.id, windowPayload(n)));
|
|
7147
|
+
}
|
|
7148
|
+
_read() {
|
|
7149
|
+
this.grant();
|
|
7150
|
+
}
|
|
7151
|
+
flush() {
|
|
7152
|
+
const p = this.pending;
|
|
7153
|
+
if (!p) return;
|
|
7154
|
+
this.pending = null;
|
|
7155
|
+
this.sendChunks(p.chunk, p.cb);
|
|
7156
|
+
}
|
|
7157
|
+
sendChunks(chunk, cb) {
|
|
7158
|
+
let off = 0;
|
|
7159
|
+
while (off < chunk.length) {
|
|
7160
|
+
if (this.remoteClosed) return cb();
|
|
7161
|
+
if (this.credit <= 0) {
|
|
7162
|
+
this.pending = { chunk: chunk.subarray(off), cb };
|
|
7163
|
+
return;
|
|
7164
|
+
}
|
|
7165
|
+
const n = Math.min(chunk.length - off, this.credit, DATA_CHUNK);
|
|
7166
|
+
this.sendFrame(encodeFrame(FrameType.data, this.id, chunk.subarray(off, off + n)));
|
|
7167
|
+
this.credit -= n;
|
|
7168
|
+
off += n;
|
|
7169
|
+
}
|
|
7170
|
+
cb();
|
|
7171
|
+
}
|
|
7172
|
+
_write(chunk, _enc, cb) {
|
|
7173
|
+
if (this.remoteClosed) return cb();
|
|
7174
|
+
this.sendChunks(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), cb);
|
|
7175
|
+
}
|
|
7176
|
+
_final(cb) {
|
|
7177
|
+
this.sendClose();
|
|
7178
|
+
cb();
|
|
7179
|
+
if (!this.remoteClosed) {
|
|
7180
|
+
this.remoteClosed = true;
|
|
7181
|
+
this.push(null);
|
|
7182
|
+
if (this.readableLength === 0) setImmediate(() => this.destroy());
|
|
7183
|
+
else this.once("end", () => this.destroy());
|
|
7184
|
+
}
|
|
7185
|
+
}
|
|
7186
|
+
_destroy(err, cb) {
|
|
7187
|
+
this.sendClose();
|
|
7188
|
+
const p = this.pending;
|
|
7189
|
+
this.pending = null;
|
|
7190
|
+
p?.cb(err ?? new Error("stream closed"));
|
|
7191
|
+
cb(err);
|
|
7192
|
+
}
|
|
7193
|
+
sendClose() {
|
|
7194
|
+
if (this.closeSent) return;
|
|
7195
|
+
this.closeSent = true;
|
|
7196
|
+
try {
|
|
7197
|
+
this.sendFrame(encodeFrame(FrameType.close, this.id));
|
|
7198
|
+
} catch {
|
|
7199
|
+
}
|
|
7200
|
+
}
|
|
7201
|
+
};
|
|
7202
|
+
var StreamTable = class {
|
|
7203
|
+
constructor(sendFrame, window = STREAM_WINDOW) {
|
|
7204
|
+
this.sendFrame = sendFrame;
|
|
7205
|
+
this.window = window;
|
|
7206
|
+
}
|
|
7207
|
+
sendFrame;
|
|
7208
|
+
window;
|
|
7209
|
+
streams = /* @__PURE__ */ new Map();
|
|
7210
|
+
get size() {
|
|
7211
|
+
return this.streams.size;
|
|
7212
|
+
}
|
|
7213
|
+
has(id) {
|
|
7214
|
+
return this.streams.has(id);
|
|
7215
|
+
}
|
|
7216
|
+
open(id) {
|
|
7217
|
+
if (this.streams.has(id)) throw new Error(`stream ${id} is open already`);
|
|
7218
|
+
const s = new LinkStream(id, this.sendFrame, this.window);
|
|
7219
|
+
this.streams.set(id, s);
|
|
7220
|
+
s.once("close", () => {
|
|
7221
|
+
if (this.streams.get(id) === s) this.streams.delete(id);
|
|
7222
|
+
});
|
|
7223
|
+
s.on("error", () => {
|
|
7224
|
+
});
|
|
7225
|
+
return s;
|
|
7226
|
+
}
|
|
7227
|
+
/** Route a data, window or close frame; false when no such stream is open. */
|
|
7228
|
+
receive(frame) {
|
|
7229
|
+
const s = this.streams.get(frame.stream);
|
|
7230
|
+
if (!s) return false;
|
|
7231
|
+
s.receive(frame);
|
|
7232
|
+
return true;
|
|
7233
|
+
}
|
|
7234
|
+
/** The link ended: every stream with it. */
|
|
7235
|
+
endAll(reason) {
|
|
7236
|
+
for (const s of [...this.streams.values()]) {
|
|
7237
|
+
s.remoteEnd();
|
|
7238
|
+
s.destroy(new Error(reason));
|
|
7239
|
+
}
|
|
7240
|
+
this.streams.clear();
|
|
7241
|
+
}
|
|
7242
|
+
};
|
|
7243
|
+
|
|
7244
|
+
// src/node/credentials.ts
|
|
7245
|
+
function readCredentials(file) {
|
|
7246
|
+
let raw;
|
|
7247
|
+
try {
|
|
7248
|
+
raw = fs10.readFileSync(file, "utf8");
|
|
7249
|
+
} catch (err) {
|
|
7250
|
+
if (err.code === "ENOENT") return null;
|
|
7251
|
+
throw err;
|
|
7252
|
+
}
|
|
7253
|
+
let c;
|
|
7254
|
+
try {
|
|
7255
|
+
c = JSON.parse(raw);
|
|
7256
|
+
} catch {
|
|
7257
|
+
throw new CliError(`${file} is not valid JSON; this machine cannot link until it is fixed or re-joined`);
|
|
7258
|
+
}
|
|
7259
|
+
const p = c.presence;
|
|
7260
|
+
if (c.version !== 1 || typeof c.box !== "string" || typeof c.machineId !== "string" || typeof c.name !== "string" || typeof c.token !== "string" || !NODE_TOKEN_SHAPE.test(c.token) || !p || typeof p.salt !== "string" || typeof p.key !== "string" || typeof p.iterations !== "number") {
|
|
7261
|
+
throw new CliError(`${file} is not a node's credentials; re-join this machine`);
|
|
7262
|
+
}
|
|
7263
|
+
return c;
|
|
7264
|
+
}
|
|
7265
|
+
function writeCredentials(file, c) {
|
|
7266
|
+
writePrivate2(file, `${JSON.stringify(c, null, 2)}
|
|
7267
|
+
`);
|
|
7268
|
+
}
|
|
7269
|
+
|
|
7270
|
+
// src/node/claude.ts
|
|
7271
|
+
import fs12 from "node:fs";
|
|
7272
|
+
import path11 from "node:path";
|
|
7273
|
+
|
|
7274
|
+
// ../../images/machine/agents.md
|
|
7275
|
+
var agents_default = "# You are working on {{machine}}, through agentbox\n\n{{machine}} is one of the owner's own computers, joined to their agentbox. You\nrun here as the owner, with their files, their tools and their network. The\nowner watches your terminal from their browser, on agentbox's machines page.\n\n- **To show the owner an app** you started (a dev server, say), tell them the\n port it listens on; they open it themselves.\n- **Never make anything public.** Do not use tunnels (ngrok, cloudflared,\n localtunnel), external hosting, claude.ai Artifacts or similar to show a web\n app unless the owner explicitly asks for that.\n- **Docker and ports are this machine's own.** Containers you start and ports\n you listen on are on {{machine}}, not in an agentbox sandbox; clean up what\n you start.\n";
|
|
7276
|
+
|
|
7277
|
+
// src/node/update.ts
|
|
7278
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
7279
|
+
import crypto2 from "node:crypto";
|
|
7280
|
+
import fs11 from "node:fs";
|
|
7281
|
+
import path10 from "node:path";
|
|
7282
|
+
|
|
7283
|
+
// src/node/release-keys.ts
|
|
7284
|
+
var RELEASE_PUBLIC_KEYS = [
|
|
7285
|
+
// current
|
|
7286
|
+
`-----BEGIN PUBLIC KEY-----
|
|
7287
|
+
MCowBQYDK2VwAyEAQP6eEvWwcx5ptR56iFAJvDISpDYvy2oWOYNTH9KMUzw=
|
|
7288
|
+
-----END PUBLIC KEY-----`,
|
|
7289
|
+
// next (rotation)
|
|
7290
|
+
`-----BEGIN PUBLIC KEY-----
|
|
7291
|
+
MCowBQYDK2VwAyEANhmTV3Z/SVIUjCBjFrK+nZpl3Y2ck7vRex7l5CixSDU=
|
|
7292
|
+
-----END PUBLIC KEY-----`
|
|
7293
|
+
];
|
|
7294
|
+
|
|
7295
|
+
// src/node/update-verify.ts
|
|
7296
|
+
import crypto from "node:crypto";
|
|
7297
|
+
var MACHINE_PLATFORMS = ["linux-x64", "linux-arm64", "win-x64"];
|
|
7298
|
+
var UpdateRefused = class extends Error {
|
|
7299
|
+
constructor(message) {
|
|
7300
|
+
super(message);
|
|
7301
|
+
this.name = "UpdateRefused";
|
|
7302
|
+
}
|
|
7303
|
+
};
|
|
7304
|
+
function machinePlatform(platform = process.platform, arch = process.arch) {
|
|
7305
|
+
const k = `${platform === "win32" ? "win" : platform}-${arch}`;
|
|
7306
|
+
return MACHINE_PLATFORMS.includes(k) ? k : null;
|
|
7307
|
+
}
|
|
7308
|
+
function parseVersion(v) {
|
|
7309
|
+
const m = /^v?(\d{1,9})\.(\d{1,9})\.(\d{1,9})$/.exec(v);
|
|
7310
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
7311
|
+
}
|
|
7312
|
+
function compareVersions(a, b) {
|
|
7313
|
+
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
7314
|
+
}
|
|
7315
|
+
function verifyManifest(bytes, signature, keys) {
|
|
7316
|
+
if (keys.length === 0) {
|
|
7317
|
+
throw new UpdateRefused(
|
|
7318
|
+
"this build of agentbox has no release keys built in, so it cannot check that an update is genuine; it does not update itself (install a release build from https://github.com/jabezpauls/agentbox/releases)"
|
|
7319
|
+
);
|
|
7320
|
+
}
|
|
7321
|
+
const text = (Buffer.isBuffer(signature) ? signature.toString("utf8") : signature ?? "").trim();
|
|
7322
|
+
const sig = /^[A-Za-z0-9+/]{86}==$/.test(text) ? Buffer.from(text, "base64") : null;
|
|
7323
|
+
if (!sig || sig.length !== 64) throw new UpdateRefused("the release manifest is not signed (no valid signature beside it); not updating");
|
|
7324
|
+
const trusted = keys.some((pem) => {
|
|
7325
|
+
try {
|
|
7326
|
+
const key = crypto.createPublicKey(pem);
|
|
7327
|
+
return key.asymmetricKeyType === "ed25519" && crypto.verify(null, bytes, key, sig);
|
|
7328
|
+
} catch {
|
|
7329
|
+
return false;
|
|
7330
|
+
}
|
|
7331
|
+
});
|
|
7332
|
+
if (!trusted) throw new UpdateRefused("the release manifest is not signed by a release key this node trusts; not updating");
|
|
7333
|
+
return parseManifest(bytes);
|
|
7334
|
+
}
|
|
7335
|
+
var HEX64 = /^[0-9a-f]{64}$/;
|
|
7336
|
+
function asset(v, where, versioned) {
|
|
7337
|
+
const a = v;
|
|
7338
|
+
if (!a || typeof a !== "object") throw new UpdateRefused(`the release manifest has no ${where}`);
|
|
7339
|
+
if (typeof a.url !== "string" || !/^https:\/\//.test(a.url)) throw new UpdateRefused(`the release manifest's ${where} has no https URL`);
|
|
7340
|
+
if (typeof a.sha256 !== "string" || !HEX64.test(a.sha256)) throw new UpdateRefused(`the release manifest's ${where} has no SHA-256`);
|
|
7341
|
+
const out = { url: a.url, sha256: a.sha256 };
|
|
7342
|
+
if (typeof a.size === "number" && Number.isSafeInteger(a.size) && a.size > 0) out.size = a.size;
|
|
7343
|
+
if (!versioned) return out;
|
|
7344
|
+
if (typeof a.version !== "string" || !/^[0-9A-Za-z.+-]{1,64}$/.test(a.version)) throw new UpdateRefused(`the release manifest's ${where} has no version`);
|
|
7345
|
+
const entry = { ...out, version: a.version };
|
|
7346
|
+
if (a.files !== void 0) {
|
|
7347
|
+
const f = a.files;
|
|
7348
|
+
if (!f || typeof f !== "object" || !Object.values(f).every((d) => typeof d === "string" && HEX64.test(d))) {
|
|
7349
|
+
throw new UpdateRefused(`the release manifest's ${where} lists files without a SHA-256 each`);
|
|
7350
|
+
}
|
|
7351
|
+
entry.files = f;
|
|
7352
|
+
}
|
|
7353
|
+
return entry;
|
|
7354
|
+
}
|
|
7355
|
+
function parseManifest(bytes) {
|
|
7356
|
+
let raw;
|
|
7357
|
+
try {
|
|
7358
|
+
raw = JSON.parse(bytes.toString("utf8"));
|
|
7359
|
+
} catch {
|
|
7360
|
+
throw new UpdateRefused("the release manifest is not JSON");
|
|
7361
|
+
}
|
|
7362
|
+
if (!raw || typeof raw !== "object" || raw.schema !== 1) throw new UpdateRefused("the release manifest is of a kind this node does not know; update it by hand");
|
|
7363
|
+
if (typeof raw.version !== "string" || !parseVersion(raw.version)) throw new UpdateRefused("the release manifest has no version");
|
|
7364
|
+
const platforms = {};
|
|
7365
|
+
const given = raw.platforms ?? {};
|
|
7366
|
+
for (const p of MACHINE_PLATFORMS) {
|
|
7367
|
+
const e = given[p];
|
|
7368
|
+
if (!e) continue;
|
|
7369
|
+
const entry = {
|
|
7370
|
+
node: asset(e.node, `${p} node`, false),
|
|
7371
|
+
herdr: asset(e.herdr, `${p} herdr`, true),
|
|
7372
|
+
claude: asset(e.claude, `${p} Claude Code`, true)
|
|
7373
|
+
};
|
|
7374
|
+
if (e.nodeGui !== void 0) entry.nodeGui = asset(e.nodeGui, `${p} windowless node`, false);
|
|
7375
|
+
platforms[p] = entry;
|
|
7376
|
+
}
|
|
7377
|
+
return { schema: 1, version: raw.version, platforms };
|
|
7378
|
+
}
|
|
7379
|
+
|
|
7380
|
+
// src/node/update.ts
|
|
7381
|
+
var RELEASES_URL = "https://github.com/jabezpauls/agentbox/releases/latest/download";
|
|
7382
|
+
var MANIFEST_FILE = "machines-manifest.json";
|
|
7383
|
+
var SIGNATURE_FILE = "machines-manifest.json.sig";
|
|
7384
|
+
var MAX_MANIFEST = 256 * 1024;
|
|
7385
|
+
var MAX_SIGNATURE = 4 * 1024;
|
|
7386
|
+
var MAX_ASSET = 1024 * 1024 * 1024;
|
|
7387
|
+
function describeReport(r) {
|
|
7388
|
+
const tools = (t2) => [t2.herdr === "deferred" || t2.claude === "deferred" ? "herdr and Claude Code are updated once no agent is running" : "", t2.herdr === "updated" ? "herdr updated" : "", t2.claude === "updated" ? "Claude Code updated" : ""].filter(Boolean).join("; ");
|
|
7389
|
+
switch (r.state) {
|
|
7390
|
+
case "off":
|
|
7391
|
+
return "updates are off (policy.json)";
|
|
7392
|
+
case "current": {
|
|
7393
|
+
const t2 = tools(r.tools);
|
|
7394
|
+
return `up to date (agentbox ${r.version})${t2 ? `; ${t2}` : ""}`;
|
|
7395
|
+
}
|
|
7396
|
+
case "available":
|
|
7397
|
+
return `agentbox ${r.version} is available (this is ${r.from}); updates are set to notify, so run it by hand or set "updates": "auto"`;
|
|
7398
|
+
case "updated": {
|
|
7399
|
+
const t2 = tools(r.tools);
|
|
7400
|
+
return `updated agentbox ${r.from} \u2192 ${r.version}${r.restart ? ", restarting" : ""}${t2 ? `; ${t2}` : ""}`;
|
|
7401
|
+
}
|
|
7402
|
+
}
|
|
7403
|
+
}
|
|
7404
|
+
async function fetchLimited(fetcher, url, max, what) {
|
|
7405
|
+
const res = await fetcher(url, { redirect: "follow" });
|
|
7406
|
+
if (res.status === 404) return null;
|
|
7407
|
+
if (!res.ok || !res.body) throw new Error(`${what}: HTTP ${res.status} from ${url}`);
|
|
7408
|
+
const chunks = [];
|
|
7409
|
+
let size = 0;
|
|
7410
|
+
for await (const c of res.body) {
|
|
7411
|
+
size += c.length;
|
|
7412
|
+
if (size > max) throw new UpdateRefused(`${what} is larger than any real one; not updating`);
|
|
7413
|
+
chunks.push(Buffer.from(c));
|
|
7414
|
+
}
|
|
7415
|
+
return Buffer.concat(chunks);
|
|
7416
|
+
}
|
|
7417
|
+
async function fetchManifest(o = {}) {
|
|
7418
|
+
const keys = o.keys ?? RELEASE_PUBLIC_KEYS;
|
|
7419
|
+
if (keys.length === 0) verifyManifest(Buffer.alloc(0), null, keys);
|
|
7420
|
+
const fetcher = o.fetch ?? globalThis.fetch;
|
|
7421
|
+
const base = o.releasesUrl ?? RELEASES_URL;
|
|
7422
|
+
const bytes = await fetchLimited(fetcher, `${base}/${MANIFEST_FILE}`, MAX_MANIFEST, "the release manifest");
|
|
7423
|
+
if (!bytes) throw new UpdateRefused("the latest release has no machines manifest");
|
|
7424
|
+
const sig = await fetchLimited(fetcher, `${base}/${SIGNATURE_FILE}`, MAX_SIGNATURE, "the manifest's signature");
|
|
7425
|
+
return verifyManifest(bytes, sig, keys);
|
|
7426
|
+
}
|
|
7427
|
+
async function downloadVerified(fetcher, asset2, dest, what) {
|
|
7428
|
+
const dir = path10.dirname(dest);
|
|
7429
|
+
fs11.mkdirSync(dir, { recursive: true });
|
|
7430
|
+
const tmp = path10.join(dir, `.${path10.basename(dest)}.update-${crypto2.randomBytes(4).toString("hex")}`);
|
|
7431
|
+
const hash = crypto2.createHash("sha256");
|
|
7432
|
+
let fd = null;
|
|
7433
|
+
try {
|
|
7434
|
+
const res = await fetcher(asset2.url, { redirect: "follow" });
|
|
7435
|
+
if (!res.ok || !res.body) throw new Error(`${what}: HTTP ${res.status} from ${asset2.url}`);
|
|
7436
|
+
fd = fs11.openSync(tmp, "wx", 448);
|
|
7437
|
+
let size = 0;
|
|
7438
|
+
for await (const c of res.body) {
|
|
7439
|
+
size += c.length;
|
|
7440
|
+
if (size > (asset2.size ?? MAX_ASSET) || size > MAX_ASSET) throw new UpdateRefused(`${what} is larger than the manifest says; not installing it`);
|
|
7441
|
+
hash.update(c);
|
|
7442
|
+
fs11.writeSync(fd, c);
|
|
7443
|
+
}
|
|
7444
|
+
fs11.closeSync(fd);
|
|
7445
|
+
fd = null;
|
|
7446
|
+
const got = hash.digest("hex");
|
|
7447
|
+
if (got !== asset2.sha256) throw new UpdateRefused(`${what} does not match the signed manifest (SHA-256 ${got}, expected ${asset2.sha256}); not installing it`);
|
|
7448
|
+
fs11.chmodSync(tmp, 493);
|
|
7449
|
+
return tmp;
|
|
7450
|
+
} catch (err) {
|
|
7451
|
+
if (fd !== null) fs11.closeSync(fd);
|
|
7452
|
+
fs11.rmSync(tmp, { force: true });
|
|
7453
|
+
throw err;
|
|
7454
|
+
}
|
|
7455
|
+
}
|
|
7456
|
+
function replaceFile(tmp, dest, platform = process.platform) {
|
|
7457
|
+
try {
|
|
7458
|
+
if (platform === "win32" && fs11.existsSync(dest)) {
|
|
7459
|
+
const old = `${dest}.old`;
|
|
7460
|
+
fs11.rmSync(old, { force: true });
|
|
7461
|
+
fs11.renameSync(dest, old);
|
|
7462
|
+
}
|
|
7463
|
+
fs11.renameSync(tmp, dest);
|
|
7464
|
+
} catch (err) {
|
|
7465
|
+
fs11.rmSync(tmp, { force: true });
|
|
7466
|
+
throw err;
|
|
7467
|
+
}
|
|
7468
|
+
}
|
|
7469
|
+
function defaultCheckRuns(file, version) {
|
|
7470
|
+
const out = spawnSync2(file, ["--version"], { encoding: "utf8", timeout: 3e4, windowsHide: true });
|
|
7471
|
+
const said = (out.stdout ?? "").trim();
|
|
7472
|
+
if (out.status !== 0 || said !== `agentbox ${version}`) {
|
|
7473
|
+
throw new UpdateRefused(`the downloaded agentbox does not run here, or is not ${version} (it said ${JSON.stringify(said.slice(0, 100))}); not installing it`);
|
|
7474
|
+
}
|
|
7475
|
+
}
|
|
7476
|
+
async function updateTool(o, fetcher, platform, name, installed, asset2, idle) {
|
|
7477
|
+
if (!installed) return "none";
|
|
7478
|
+
if (installed.version === asset2.version) return "current";
|
|
7479
|
+
if (asset2.files) return "unsupported";
|
|
7480
|
+
if (!idle) return "deferred";
|
|
7481
|
+
const tmp = await downloadVerified(fetcher, asset2, installed.path, `${name} ${asset2.version} for ${platform}`);
|
|
7482
|
+
replaceFile(tmp, installed.path, o.platform ?? process.platform);
|
|
7483
|
+
if (installed.versionFile) fs11.writeFileSync(installed.versionFile, `${asset2.version}
|
|
7484
|
+
`, { mode: 384 });
|
|
7485
|
+
return "updated";
|
|
7486
|
+
}
|
|
7487
|
+
async function checkForUpdate(o) {
|
|
7488
|
+
if (o.policy === "off") return { state: "off" };
|
|
7489
|
+
const running = parseVersion(o.running);
|
|
7490
|
+
if (!running) throw new UpdateRefused(`this node's version (${o.running}) is not a release's, so it cannot tell what is newer; install a release build`);
|
|
7491
|
+
const platform = machinePlatform(o.platform ?? process.platform, o.arch ?? process.arch);
|
|
7492
|
+
if (!platform) throw new UpdateRefused(`no agentbox release is built for ${o.platform ?? process.platform}-${o.arch ?? process.arch}`);
|
|
7493
|
+
const fetcher = o.fetch ?? globalThis.fetch;
|
|
7494
|
+
const manifest = await fetchManifest(o);
|
|
7495
|
+
const order = compareVersions(parseVersion(manifest.version), running);
|
|
7496
|
+
if (order < 0) throw new UpdateRefused(`the latest release (${manifest.version}) is older than this node (${o.running}); not going back`);
|
|
7497
|
+
const assets = manifest.platforms[platform];
|
|
7498
|
+
if (!assets) throw new UpdateRefused(`the release ${manifest.version} has nothing for ${platform}`);
|
|
7499
|
+
const newer = order > 0;
|
|
7500
|
+
if (newer && o.policy === "notify") return { state: "available", version: manifest.version, from: o.running };
|
|
7501
|
+
const tools = async () => {
|
|
7502
|
+
const needed = [o.herdr && o.herdr.version !== assets.herdr.version, o.claude && o.claude.version !== assets.claude.version].some(Boolean);
|
|
7503
|
+
const idle = needed && o.policy === "auto" && o.idle ? await o.idle() : false;
|
|
7504
|
+
return {
|
|
7505
|
+
herdr: await updateTool(o, fetcher, platform, "herdr", o.herdr, assets.herdr, idle),
|
|
7506
|
+
claude: await updateTool(o, fetcher, platform, "Claude Code", o.claude, assets.claude, idle)
|
|
7507
|
+
};
|
|
7508
|
+
};
|
|
7509
|
+
if (!newer) return { state: "current", version: o.running, tools: o.policy === "auto" ? await tools() : { herdr: "none", claude: "none" } };
|
|
7510
|
+
if (o.nodePath === null) return { state: "available", version: manifest.version, from: o.running };
|
|
7511
|
+
const node = await downloadVerified(fetcher, assets.node, o.nodePath, `agentbox ${manifest.version} for ${platform}`);
|
|
7512
|
+
let gui = null;
|
|
7513
|
+
try {
|
|
7514
|
+
(o.checkRuns ?? defaultCheckRuns)(node, manifest.version);
|
|
7515
|
+
if (o.nodeGuiPath && assets.nodeGui) gui = await downloadVerified(fetcher, assets.nodeGui, o.nodeGuiPath, `agentbox-node.exe ${manifest.version}`);
|
|
7516
|
+
} catch (err) {
|
|
7517
|
+
fs11.rmSync(node, { force: true });
|
|
7518
|
+
throw err;
|
|
7519
|
+
}
|
|
7520
|
+
replaceFile(node, o.nodePath, o.platform ?? process.platform);
|
|
7521
|
+
if (gui && o.nodeGuiPath) replaceFile(gui, o.nodeGuiPath, o.platform ?? process.platform);
|
|
7522
|
+
return { state: "updated", version: manifest.version, from: o.running, restart: true, tools: await tools() };
|
|
7523
|
+
}
|
|
7524
|
+
|
|
7525
|
+
// src/node/claude.ts
|
|
7526
|
+
var CLAUDE_VERSION = "2.1.311";
|
|
7527
|
+
var CLAUDE_PINS = {
|
|
7528
|
+
"linux-x64": { path: "linux-x64/claude", sha256: "86dc1fcc69498159eaf15f446613e847010f9036bfbbdffd4c49350be8489ada", size: 293776752 },
|
|
7529
|
+
"linux-arm64": { path: "linux-arm64/claude", sha256: "2e2496c9a8065df93acb0fca2d341f59a6b8777649b2e025ab8c9bcd5eef61f3", size: 293658808 },
|
|
7530
|
+
"win32-x64": { path: "win32-x64/claude.exe", sha256: "966f4b2ce962298f5dee6a942e8c47c8f97392e69e728e622319a98b259c0680", size: 298870432 }
|
|
7531
|
+
};
|
|
7532
|
+
var CLAUDE_RELEASES = "https://downloads.claude.ai/claude-code-releases";
|
|
7533
|
+
function claudePaths(p, platform = process.platform) {
|
|
7534
|
+
const exe = platform === "win32" ? "claude.exe" : "claude";
|
|
7535
|
+
return {
|
|
7536
|
+
bin: path11.join(p.bin, exe),
|
|
7537
|
+
versionFile: path11.join(p.bin, "claude.version"),
|
|
7538
|
+
config: path11.join(p.dir, "claude"),
|
|
7539
|
+
shims: path11.join(p.dir, "shims"),
|
|
7540
|
+
shim: path11.join(p.dir, "shims", "claude"),
|
|
7541
|
+
agents: path11.join(p.dir, "agents.md"),
|
|
7542
|
+
token: path11.join(p.secrets, "claude-token")
|
|
7543
|
+
};
|
|
7544
|
+
}
|
|
7545
|
+
function writeIfAbsent(file, contents) {
|
|
7546
|
+
if (fs12.existsSync(file)) return false;
|
|
7547
|
+
writePrivate2(file, contents);
|
|
7548
|
+
return true;
|
|
7549
|
+
}
|
|
7550
|
+
function seedClaudeConfig(c, home2) {
|
|
7551
|
+
ensurePrivateDir(c.config);
|
|
7552
|
+
writeIfAbsent(path11.join(c.config, ".claude.json"), `${JSON.stringify({ hasCompletedOnboarding: true, projects: { [home2]: { hasTrustDialogAccepted: true } } }, null, 2)}
|
|
7553
|
+
`);
|
|
7554
|
+
writeIfAbsent(path11.join(c.config, "settings.json"), `${JSON.stringify({ disableDeepLinkRegistration: "disable" }, null, 2)}
|
|
7555
|
+
`);
|
|
7556
|
+
}
|
|
7557
|
+
function agentsText(machine) {
|
|
7558
|
+
return agents_default.replaceAll("{{machine}}", machine.replace(/[^\w.-]/g, ""));
|
|
7559
|
+
}
|
|
7560
|
+
function shQuote(s) {
|
|
7561
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
7562
|
+
}
|
|
7563
|
+
var PLAIN = ["mcp", "config", "doctor", "update", "install", "setup-token", "migrate-installer", "plugin", "-v", "--version", "-h", "--help"];
|
|
7564
|
+
function shimText(c) {
|
|
7565
|
+
return `#!/bin/sh
|
|
7566
|
+
# agentbox: Claude Code on this machine. Written by the node; rewritten at
|
|
7567
|
+
# every start. Reads the shared sign-in (when this machine was given it) at
|
|
7568
|
+
# each launch, for this process only.
|
|
7569
|
+
CLAUDE_CONFIG_DIR=${shQuote(c.config)}
|
|
7570
|
+
DISABLE_UPDATES=1
|
|
7571
|
+
export CLAUDE_CONFIG_DIR DISABLE_UPDATES
|
|
7572
|
+
if [ -f ${shQuote(c.token)} ]; then
|
|
7573
|
+
CLAUDE_CODE_OAUTH_TOKEN="$(cat ${shQuote(c.token)})"
|
|
7574
|
+
export CLAUDE_CODE_OAUTH_TOKEN
|
|
7575
|
+
fi
|
|
7576
|
+
if [ ! -x ${shQuote(c.bin)} ]; then
|
|
7577
|
+
echo "agentbox: Claude Code is not installed on this machine yet (it is installed by \`agentbox node join\`)." >&2
|
|
7578
|
+
exit 127
|
|
7579
|
+
fi
|
|
7580
|
+
case "\${1:-}" in
|
|
7581
|
+
${PLAIN.join("|")}) exec ${shQuote(c.bin)} "$@" ;;
|
|
7582
|
+
esac
|
|
7583
|
+
exec ${shQuote(c.bin)} --append-system-prompt-file ${shQuote(c.agents)} "$@"
|
|
7584
|
+
`;
|
|
7585
|
+
}
|
|
7586
|
+
function prepareClaude(c, o) {
|
|
7587
|
+
seedClaudeConfig(c, o.home);
|
|
7588
|
+
writePrivate2(c.agents, agentsText(o.machine));
|
|
7589
|
+
ensurePrivateDir(c.shims);
|
|
7590
|
+
writePrivate2(c.shim, shimText(c), 448);
|
|
7591
|
+
}
|
|
7592
|
+
function holdToken(c, token) {
|
|
7593
|
+
if (token === null) {
|
|
7594
|
+
fs12.rmSync(c.token, { force: true });
|
|
7595
|
+
return;
|
|
7596
|
+
}
|
|
7597
|
+
if (!/^[A-Za-z0-9_\-.~+/=]{20,512}$/.test(token)) return;
|
|
7598
|
+
writePrivate2(c.token, token);
|
|
7599
|
+
}
|
|
7600
|
+
function installedClaude(c) {
|
|
7601
|
+
if (!fs12.existsSync(c.bin)) return null;
|
|
7602
|
+
try {
|
|
7603
|
+
const v = fs12.readFileSync(c.versionFile, "utf8").trim();
|
|
7604
|
+
return parseVersion(v) ? v : null;
|
|
7605
|
+
} catch {
|
|
7606
|
+
return null;
|
|
7607
|
+
}
|
|
7608
|
+
}
|
|
7609
|
+
async function installClaude(c, deps) {
|
|
7610
|
+
const have = installedClaude(c);
|
|
7611
|
+
const want = parseVersion(CLAUDE_VERSION);
|
|
7612
|
+
if (have && compareVersions(parseVersion(have), want) >= 0) return have;
|
|
7613
|
+
const key = `${deps.platform}-${deps.arch}`;
|
|
7614
|
+
const pin = CLAUDE_PINS[key];
|
|
7615
|
+
if (!pin) throw new CliError(`Claude Code ${CLAUDE_VERSION} has no build for ${deps.platform}/${deps.arch} that this node knows`);
|
|
7616
|
+
deps.log?.(`Installing Claude Code ${CLAUDE_VERSION} (about ${Math.round(pin.size / 2 ** 20)} MiB)\u2026
|
|
7617
|
+
`);
|
|
7618
|
+
const tmp = await downloadVerified(
|
|
7619
|
+
deps.fetch ?? globalThis.fetch,
|
|
7620
|
+
{ url: `${CLAUDE_RELEASES}/${CLAUDE_VERSION}/${pin.path}`, sha256: pin.sha256, size: pin.size },
|
|
7621
|
+
c.bin,
|
|
7622
|
+
`Claude Code ${CLAUDE_VERSION}`
|
|
7623
|
+
);
|
|
7624
|
+
replaceFile(tmp, c.bin);
|
|
7625
|
+
writePrivate2(c.versionFile, `${CLAUDE_VERSION}
|
|
7626
|
+
`);
|
|
7627
|
+
return CLAUDE_VERSION;
|
|
7628
|
+
}
|
|
7629
|
+
|
|
7630
|
+
// src/node/install.ts
|
|
7631
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
7632
|
+
import fs13 from "node:fs";
|
|
7633
|
+
import path12 from "node:path";
|
|
7634
|
+
var HERDR_VERSION = "0.9.3";
|
|
7635
|
+
var HERDR_PINS = {
|
|
7636
|
+
"linux-x64": { asset: "herdr-linux-x86_64", sha256: "18a8dc65f1c2fa485884344356dea1cfd911c6f06cf46fa78e193f4087f4dba7" },
|
|
7637
|
+
"linux-arm64": { asset: "herdr-linux-aarch64", sha256: "4de7aa3e25678812e92960de64f7c2aaa1bca1f0f80a3c5e559837e231e1f5c0" }
|
|
7638
|
+
};
|
|
7639
|
+
function sha256(bytes) {
|
|
7640
|
+
return createHash4("sha256").update(bytes).digest("hex");
|
|
7641
|
+
}
|
|
7642
|
+
async function fetchBytes(url) {
|
|
7643
|
+
let res;
|
|
7644
|
+
try {
|
|
7645
|
+
res = await fetch(url, { redirect: "follow" });
|
|
7646
|
+
} catch (err) {
|
|
7647
|
+
throw new CliError(`could not download ${url}: ${err.message}`);
|
|
7648
|
+
}
|
|
7649
|
+
if (!res.ok) throw new CliError(`could not download ${url}: HTTP ${res.status}`);
|
|
7650
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
7651
|
+
}
|
|
7652
|
+
async function installHerdr(binDir, deps) {
|
|
7653
|
+
const pin = HERDR_PINS[`${deps.platform}-${deps.arch}`];
|
|
7654
|
+
if (!pin) throw new CliError(`herdr ${HERDR_VERSION} has no build for ${deps.platform}/${deps.arch} that this node knows`);
|
|
7655
|
+
const target = path12.join(binDir, "herdr");
|
|
7656
|
+
try {
|
|
7657
|
+
if (sha256(fs13.readFileSync(target)) === pin.sha256) return target;
|
|
7658
|
+
} catch {
|
|
7659
|
+
}
|
|
7660
|
+
const url = `https://github.com/herdrdev/herdr/releases/download/v${HERDR_VERSION}/${pin.asset}`;
|
|
7661
|
+
deps.log?.(`Installing herdr ${HERDR_VERSION}\u2026
|
|
7662
|
+
`);
|
|
7663
|
+
const bytes = deps.from ? new Uint8Array(fs13.readFileSync(deps.from)) : await (deps.download ?? fetchBytes)(url);
|
|
7664
|
+
const got = sha256(bytes);
|
|
7665
|
+
if (got !== pin.sha256) throw new CliError(`herdr ${HERDR_VERSION} failed verification (SHA-256 ${got}, expected ${pin.sha256}); nothing was installed`);
|
|
7666
|
+
writePrivate2(target, bytes, 448);
|
|
7667
|
+
return target;
|
|
7668
|
+
}
|
|
7669
|
+
|
|
7670
|
+
// src/node/join.ts
|
|
7671
|
+
import fs15 from "node:fs";
|
|
7672
|
+
|
|
7673
|
+
// src/node/policy.ts
|
|
7674
|
+
import fs14 from "node:fs";
|
|
7675
|
+
import path13 from "node:path";
|
|
7676
|
+
var DENY_ALL = { terminals: false, files: [], ports: false, share: false, updates: "auto", presenceHours: 12, paused: false };
|
|
7677
|
+
var KEYS = /* @__PURE__ */ new Set(["terminals", "files", "ports", "share", "updates", "presenceHours", "paused"]);
|
|
7678
|
+
function broken(problem) {
|
|
7679
|
+
return { policy: { ...DENY_ALL, files: [] }, problem };
|
|
7680
|
+
}
|
|
7681
|
+
function expand(p, home2) {
|
|
7682
|
+
if (p === "~") return home2;
|
|
7683
|
+
if (p.startsWith("~/")) return path13.join(home2, p.slice(2));
|
|
7684
|
+
return p;
|
|
7685
|
+
}
|
|
7686
|
+
function parsePolicy(text, home2) {
|
|
7687
|
+
let raw;
|
|
7688
|
+
try {
|
|
7689
|
+
raw = JSON.parse(text);
|
|
7690
|
+
} catch {
|
|
7691
|
+
return broken("policy.json is not valid JSON");
|
|
7692
|
+
}
|
|
7693
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return broken("policy.json is not an object");
|
|
7694
|
+
const o = raw;
|
|
7695
|
+
for (const k of Object.keys(o)) if (!KEYS.has(k)) return broken(`policy.json has "${k}", which this node does not know`);
|
|
7696
|
+
const policy = { ...DENY_ALL, files: [] };
|
|
7697
|
+
for (const k of ["terminals", "ports", "share", "paused"]) {
|
|
7698
|
+
if (o[k] === void 0) continue;
|
|
7699
|
+
if (typeof o[k] !== "boolean") return broken(`policy.json: "${k}" must be true or false`);
|
|
7700
|
+
policy[k] = o[k];
|
|
7701
|
+
}
|
|
7702
|
+
if (o.updates !== void 0) {
|
|
7703
|
+
if (o.updates !== "auto" && o.updates !== "notify" && o.updates !== "off") return broken('policy.json: "updates" must be "auto", "notify" or "off"');
|
|
7704
|
+
policy.updates = o.updates;
|
|
7705
|
+
}
|
|
7706
|
+
if (o.presenceHours !== void 0) {
|
|
7707
|
+
const h = o.presenceHours;
|
|
7708
|
+
if (typeof h !== "number" || !Number.isInteger(h) || h < 1 || h > 24) return broken('policy.json: "presenceHours" must be a whole number from 1 to 24');
|
|
7709
|
+
policy.presenceHours = h;
|
|
7710
|
+
}
|
|
7711
|
+
if (o.files !== void 0) {
|
|
7712
|
+
if (!Array.isArray(o.files)) return broken('policy.json: "files" must be a list');
|
|
7713
|
+
for (const f of o.files) {
|
|
7714
|
+
const e = f ?? {};
|
|
7715
|
+
if (typeof e.path !== "string" || e.path === "") return broken('policy.json: every "files" entry needs a "path"');
|
|
7716
|
+
if (e.mode !== "read" && e.mode !== "write") return broken(`policy.json: "${e.path}" needs a "mode" of "read" or "write"`);
|
|
7717
|
+
const full = expand(e.path, home2);
|
|
7718
|
+
if (!path13.isAbsolute(full)) return broken(`policy.json: "${e.path}" is not an absolute folder (or ~/\u2026)`);
|
|
7719
|
+
policy.files.push({ path: path13.normalize(full), mode: e.mode });
|
|
7720
|
+
}
|
|
7721
|
+
}
|
|
7722
|
+
return { policy, problem: null };
|
|
7723
|
+
}
|
|
7724
|
+
function loadPolicy(file, home2) {
|
|
7725
|
+
let text;
|
|
7726
|
+
try {
|
|
7727
|
+
text = fs14.readFileSync(file, "utf8");
|
|
7728
|
+
} catch (err) {
|
|
7729
|
+
if (err.code === "ENOENT") return broken("there is no policy.json");
|
|
7730
|
+
return broken(`policy.json cannot be read (${err.code ?? "error"})`);
|
|
7731
|
+
}
|
|
7732
|
+
return parsePolicy(text, home2);
|
|
7733
|
+
}
|
|
7734
|
+
function summarize(l) {
|
|
7735
|
+
return { ...l.policy, files: l.policy.files.map((f) => ({ ...f })), problem: l.problem };
|
|
7736
|
+
}
|
|
7737
|
+
function watchPolicy(file, home2, onChange, intervalMs = 1e3) {
|
|
7738
|
+
let last = JSON.stringify(loadPolicy(file, home2));
|
|
7739
|
+
const check = () => {
|
|
7740
|
+
const now = loadPolicy(file, home2);
|
|
7741
|
+
const s = JSON.stringify(now);
|
|
7742
|
+
if (s === last) return;
|
|
7743
|
+
last = s;
|
|
7744
|
+
onChange(now);
|
|
7745
|
+
};
|
|
7746
|
+
const timer = setInterval(check, intervalMs);
|
|
7747
|
+
timer.unref();
|
|
7748
|
+
return () => clearInterval(timer);
|
|
7749
|
+
}
|
|
7750
|
+
function writeInitialPolicy(file, folders) {
|
|
7751
|
+
if (fs14.existsSync(file)) return false;
|
|
7752
|
+
const policy = { ...DENY_ALL, terminals: true, files: folders };
|
|
7753
|
+
writePrivate2(file, `${JSON.stringify(policy, null, 2)}
|
|
7754
|
+
`);
|
|
7755
|
+
return true;
|
|
7756
|
+
}
|
|
7757
|
+
function setPaused(file, paused) {
|
|
7758
|
+
let o;
|
|
7759
|
+
try {
|
|
7760
|
+
o = JSON.parse(fs14.readFileSync(file, "utf8"));
|
|
7761
|
+
} catch (err) {
|
|
7762
|
+
if (err.code === "ENOENT") o = { ...DENY_ALL };
|
|
7763
|
+
else throw new CliError(`${file} is not valid JSON; fix it first (everything is denied meanwhile)`);
|
|
7764
|
+
}
|
|
7765
|
+
if (typeof o !== "object" || o === null || Array.isArray(o)) throw new CliError(`${file} is not a policy; fix it first`);
|
|
7766
|
+
o.paused = paused;
|
|
7767
|
+
writePrivate2(file, `${JSON.stringify(o, null, 2)}
|
|
7768
|
+
`);
|
|
7769
|
+
}
|
|
7770
|
+
|
|
7771
|
+
// src/node/join.ts
|
|
7772
|
+
var MIN_FREE_BYTES = 512 * 1024 * 1024;
|
|
7773
|
+
var SUPPORTED_PLATFORMS = /* @__PURE__ */ new Set(["linux"]);
|
|
7774
|
+
async function joinMachine(o, s) {
|
|
7775
|
+
const existing = readCredentials(s.paths.credentials);
|
|
7776
|
+
if (existing && existing.box !== o.box && existing.revokedAt === void 0) {
|
|
7777
|
+
throw new CliError(
|
|
7778
|
+
`this machine is joined to ${existing.box} already; a machine joins one box only (run \`agentbox node leave\` first)`
|
|
7779
|
+
);
|
|
7780
|
+
}
|
|
7781
|
+
const rejoin = existing && existing.box === o.box && existing.revokedAt === void 0 ? existing : null;
|
|
7782
|
+
if (!SUPPORTED_PLATFORMS.has(s.platform)) throw new CliError(`machines on ${s.platform} are not supported yet (Linux only, for now)`);
|
|
7783
|
+
try {
|
|
7784
|
+
ensurePrivateDir(s.paths.dir);
|
|
7785
|
+
fs15.accessSync(s.paths.dir, fs15.constants.W_OK);
|
|
7786
|
+
} catch (err) {
|
|
7787
|
+
throw new CliError(`the node folder ${s.paths.dir} is not writable: ${err.message}`);
|
|
7788
|
+
}
|
|
7789
|
+
const free = s.freeBytes(s.paths.dir);
|
|
7790
|
+
if (free !== null && free < MIN_FREE_BYTES) {
|
|
7791
|
+
throw new CliError(`${s.paths.dir} has ${Math.floor(free / 2 ** 20)} MiB free; the node needs ${MIN_FREE_BYTES / 2 ** 20} MiB`);
|
|
7792
|
+
}
|
|
7793
|
+
await s.install();
|
|
7794
|
+
const presence = await s.enrol(await s.passphrase());
|
|
7795
|
+
let started;
|
|
7796
|
+
try {
|
|
7797
|
+
started = await s.client.json("POST", "/_gate/node/join/start", {
|
|
7798
|
+
auth: false,
|
|
7799
|
+
body: {
|
|
7800
|
+
json: {
|
|
7801
|
+
name: o.name,
|
|
7802
|
+
platform: s.platform,
|
|
7803
|
+
arch: s.arch,
|
|
7804
|
+
hostname: s.hostname,
|
|
7805
|
+
...rejoin ? { rejoin: { machineId: rejoin.machineId, token: rejoin.token } } : {}
|
|
7806
|
+
}
|
|
7807
|
+
},
|
|
7808
|
+
what: "asking the box to join"
|
|
7809
|
+
});
|
|
7810
|
+
} catch (err) {
|
|
7811
|
+
if (err.status === 404) throw new CliError(`${o.box} does not have machines turned on (or is not an agentbox)`, EXIT.NOT_FOUND);
|
|
7812
|
+
throw err;
|
|
7813
|
+
}
|
|
7814
|
+
s.err(`
|
|
7815
|
+
${rejoin ? "Re-joining" : "Joining"} as "${started.name}". On the box's machines Admin page${started.approveAt ? ` (${started.approveAt})` : ""},
|
|
7816
|
+
`);
|
|
7817
|
+
s.err(`check the name, platform and address, and type this code:
|
|
7818
|
+
|
|
7819
|
+
${started.userCode}
|
|
7820
|
+
|
|
7821
|
+
`);
|
|
7822
|
+
s.err(`Waiting for approval (the code lasts ${Math.round(started.expiresIn / 60)} minutes)\u2026
|
|
7823
|
+
`);
|
|
7824
|
+
const deadline = s.now() + started.expiresIn * 1e3;
|
|
7825
|
+
let got = null;
|
|
7826
|
+
while (got === null) {
|
|
7827
|
+
await s.sleep(Math.max(1, started.interval) * 1e3);
|
|
7828
|
+
if (s.now() > deadline) throw new CliError("the code expired before it was approved; run `agentbox node join` again");
|
|
7829
|
+
let p;
|
|
7830
|
+
try {
|
|
7831
|
+
p = await s.client.json("POST", "/_gate/node/join/poll", { auth: false, body: { json: { joinCode: started.joinCode } }, what: "waiting for approval" });
|
|
7832
|
+
} catch (err) {
|
|
7833
|
+
if (err instanceof CliError && (err.exitCode === EXIT.UNREACHABLE || err.status === 429 || (err.status ?? 0) >= 500)) continue;
|
|
7834
|
+
throw err;
|
|
7835
|
+
}
|
|
7836
|
+
if (p.status === "pending") continue;
|
|
7837
|
+
if (p.status === "denied") throw new CliError("the join was denied on the box");
|
|
7838
|
+
if (p.status === "expired") throw new CliError("the code expired, or was already used; run `agentbox node join` again");
|
|
7839
|
+
got = p;
|
|
7840
|
+
}
|
|
7841
|
+
const credentials = {
|
|
7842
|
+
version: 1,
|
|
7843
|
+
box: o.box,
|
|
7844
|
+
machineId: got.machineId,
|
|
7845
|
+
name: got.name,
|
|
7846
|
+
token: got.token,
|
|
7847
|
+
presence,
|
|
7848
|
+
joinedAt: s.now()
|
|
7849
|
+
};
|
|
7850
|
+
writeCredentials(s.paths.credentials, credentials);
|
|
7851
|
+
s.err(`Joined as "${got.name}" (machine ${got.machineId}).
|
|
7852
|
+
`);
|
|
7853
|
+
if (fs15.existsSync(s.paths.policy)) {
|
|
7854
|
+
s.err(`Keeping the existing ${s.paths.policy}:
|
|
7855
|
+
`);
|
|
7856
|
+
} else {
|
|
7857
|
+
const folders = o.folders ?? await s.askFolders();
|
|
7858
|
+
writeInitialPolicy(s.paths.policy, folders);
|
|
7859
|
+
s.err(`Wrote ${s.paths.policy}:
|
|
7860
|
+
`);
|
|
7861
|
+
}
|
|
7862
|
+
s.printPolicy();
|
|
7863
|
+
if (s.service) {
|
|
7864
|
+
await s.service();
|
|
7865
|
+
s.err("Waiting for the machine to show online\u2026\n");
|
|
7866
|
+
if (await s.waitOnline()) s.err("Online.\n");
|
|
7867
|
+
else s.err("It has not come online yet; see `agentbox node status` and `agentbox node logs`.\n");
|
|
7868
|
+
} else {
|
|
7869
|
+
s.err("Start it with `agentbox node run` (no service was registered).\n");
|
|
7870
|
+
}
|
|
7871
|
+
return credentials;
|
|
7872
|
+
}
|
|
7873
|
+
|
|
7874
|
+
// src/node/link.ts
|
|
7875
|
+
import { randomBytes as randomBytes5 } from "node:crypto";
|
|
7876
|
+
import { EventEmitter } from "node:events";
|
|
7877
|
+
function backoffMs(n, min, max, random = Math.random) {
|
|
7878
|
+
const base = Math.min(max, min * 2 ** Math.max(0, n - 1));
|
|
7879
|
+
return Math.round(base / 2 + base / 2 * random());
|
|
7880
|
+
}
|
|
7881
|
+
var NodeLink = class extends EventEmitter {
|
|
7882
|
+
ws = null;
|
|
7883
|
+
stateNow = "stopped";
|
|
7884
|
+
running = false;
|
|
7885
|
+
paused = false;
|
|
7886
|
+
failures = 0;
|
|
7887
|
+
failingSince = null;
|
|
7888
|
+
retryTimer = null;
|
|
7889
|
+
wakeTimer = null;
|
|
7890
|
+
lastTick = 0;
|
|
7891
|
+
nonce = "";
|
|
7892
|
+
nextDelay = null;
|
|
7893
|
+
o;
|
|
7894
|
+
constructor(opts) {
|
|
7895
|
+
super();
|
|
7896
|
+
this.o = {
|
|
7897
|
+
now: Date.now,
|
|
7898
|
+
random: Math.random,
|
|
7899
|
+
pingMs: PING_MS,
|
|
7900
|
+
minBackoffMs: 1e3,
|
|
7901
|
+
maxBackoffMs: 6e4,
|
|
7902
|
+
slowAfterMs: 60 * 6e4,
|
|
7903
|
+
slowRetryMs: 60 * 6e4,
|
|
7904
|
+
duplicateBackoffMs: DUPLICATE_BACKOFF_MS,
|
|
7905
|
+
wakeCheckMs: 5e3,
|
|
7906
|
+
wakeJumpMs: 15e3,
|
|
7907
|
+
...opts
|
|
7908
|
+
};
|
|
7909
|
+
}
|
|
7910
|
+
get state() {
|
|
7911
|
+
return this.stateNow;
|
|
7912
|
+
}
|
|
7913
|
+
set(state) {
|
|
7914
|
+
if (state === this.stateNow) return;
|
|
7915
|
+
this.stateNow = state;
|
|
7916
|
+
this.emit("state", state);
|
|
7917
|
+
}
|
|
7918
|
+
log(line) {
|
|
7919
|
+
this.emit("log", line);
|
|
7920
|
+
}
|
|
7921
|
+
start() {
|
|
7922
|
+
if (this.running) return;
|
|
7923
|
+
this.running = true;
|
|
7924
|
+
this.lastTick = this.o.now();
|
|
7925
|
+
this.wakeTimer = setInterval(() => this.tick(), this.o.wakeCheckMs);
|
|
7926
|
+
this.wakeTimer.unref();
|
|
7927
|
+
if (!this.paused) this.connect();
|
|
7928
|
+
else this.set("paused");
|
|
7929
|
+
}
|
|
7930
|
+
stop(state = "stopped") {
|
|
7931
|
+
this.running = false;
|
|
7932
|
+
if (this.wakeTimer) clearInterval(this.wakeTimer);
|
|
7933
|
+
this.wakeTimer = null;
|
|
7934
|
+
this.clearRetry();
|
|
7935
|
+
const ws = this.ws;
|
|
7936
|
+
this.ws = null;
|
|
7937
|
+
ws?.close(1e3, "stopping");
|
|
7938
|
+
this.set(state);
|
|
7939
|
+
}
|
|
7940
|
+
/** `agentbox node pause`: say so, close, and stay closed until resume. */
|
|
7941
|
+
pause() {
|
|
7942
|
+
this.paused = true;
|
|
7943
|
+
this.clearRetry();
|
|
7944
|
+
const ws = this.ws;
|
|
7945
|
+
this.ws = null;
|
|
7946
|
+
if (ws && ws.readyState === import_websocket.default.OPEN) {
|
|
7947
|
+
ws.send(encodeControl({ type: "pause" }));
|
|
7948
|
+
ws.close(1e3, "paused");
|
|
7949
|
+
} else {
|
|
7950
|
+
ws?.terminate();
|
|
7951
|
+
}
|
|
7952
|
+
if (this.running) this.set("paused");
|
|
7953
|
+
}
|
|
7954
|
+
resume() {
|
|
7955
|
+
if (!this.paused) return;
|
|
7956
|
+
this.paused = false;
|
|
7957
|
+
this.failures = 0;
|
|
7958
|
+
this.failingSince = null;
|
|
7959
|
+
if (this.running && !this.ws) this.connect();
|
|
7960
|
+
}
|
|
7961
|
+
get isPaused() {
|
|
7962
|
+
return this.paused;
|
|
7963
|
+
}
|
|
7964
|
+
sendPolicy(policy) {
|
|
7965
|
+
if (this.stateNow === "online" && this.ws?.readyState === import_websocket.default.OPEN) this.ws.send(encodeControl({ type: "policy", policy }));
|
|
7966
|
+
}
|
|
7967
|
+
/** Tell the box this machine is leaving; resolves whether it was told. */
|
|
7968
|
+
leave(timeoutMs = 5e3) {
|
|
7969
|
+
const ws = this.ws;
|
|
7970
|
+
if (!ws || this.stateNow !== "online" || ws.readyState !== import_websocket.default.OPEN) return Promise.resolve(false);
|
|
7971
|
+
return new Promise((resolve) => {
|
|
7972
|
+
const timer = setTimeout(() => resolve(false), timeoutMs);
|
|
7973
|
+
ws.once("close", (code2) => {
|
|
7974
|
+
clearTimeout(timer);
|
|
7975
|
+
resolve(code2 === 4005);
|
|
7976
|
+
});
|
|
7977
|
+
ws.send(encodeControl({ type: "leave" }));
|
|
7978
|
+
});
|
|
7979
|
+
}
|
|
7980
|
+
clearRetry() {
|
|
7981
|
+
if (this.retryTimer) clearTimeout(this.retryTimer);
|
|
7982
|
+
this.retryTimer = null;
|
|
7983
|
+
}
|
|
7984
|
+
/** The wall clock jumped: the machine slept. Whatever was waiting, go now. */
|
|
7985
|
+
tick() {
|
|
7986
|
+
const t2 = this.o.now();
|
|
7987
|
+
const late = t2 - this.lastTick - this.o.wakeCheckMs;
|
|
7988
|
+
this.lastTick = t2;
|
|
7989
|
+
if (late < this.o.wakeJumpMs || !this.running || this.paused) return;
|
|
7990
|
+
if (this.stateNow === "revoked" || this.stateNow === "left") return;
|
|
7991
|
+
this.log(`woke after ${Math.round(late / 1e3)} s; reconnecting now`);
|
|
7992
|
+
this.failures = 0;
|
|
7993
|
+
if (this.ws) {
|
|
7994
|
+
const ws = this.ws;
|
|
7995
|
+
this.ws = null;
|
|
7996
|
+
ws.terminate();
|
|
7997
|
+
}
|
|
7998
|
+
this.clearRetry();
|
|
7999
|
+
this.connect();
|
|
8000
|
+
}
|
|
8001
|
+
retry() {
|
|
8002
|
+
if (!this.running || this.paused) return;
|
|
8003
|
+
const t2 = this.o.now();
|
|
8004
|
+
let delay;
|
|
8005
|
+
if (this.nextDelay !== null) {
|
|
8006
|
+
delay = this.nextDelay;
|
|
8007
|
+
this.nextDelay = null;
|
|
8008
|
+
} else {
|
|
8009
|
+
this.failures += 1;
|
|
8010
|
+
this.failingSince ??= t2;
|
|
8011
|
+
delay = t2 - this.failingSince >= this.o.slowAfterMs ? this.o.slowRetryMs : backoffMs(this.failures, this.o.minBackoffMs, this.o.maxBackoffMs, this.o.random);
|
|
8012
|
+
}
|
|
8013
|
+
this.clearRetry();
|
|
8014
|
+
this.retryTimer = setTimeout(() => {
|
|
8015
|
+
this.retryTimer = null;
|
|
8016
|
+
this.connect();
|
|
8017
|
+
}, delay);
|
|
8018
|
+
this.retryTimer.unref();
|
|
8019
|
+
}
|
|
8020
|
+
connect() {
|
|
8021
|
+
if (!this.running || this.paused || this.ws) return;
|
|
8022
|
+
this.set("connecting");
|
|
8023
|
+
const url = `${this.o.box.replace(/^http/, "ws")}${LINK_PATH}`;
|
|
8024
|
+
const ws = new import_websocket.default(url, { headers: { authorization: `Bearer ${this.o.token}` }, perMessageDeflate: false, handshakeTimeout: 2e4, followRedirects: false });
|
|
8025
|
+
this.ws = ws;
|
|
8026
|
+
let welcomed = false;
|
|
8027
|
+
let stopKeepAlive = () => {
|
|
8028
|
+
};
|
|
8029
|
+
const streams = new StreamTable((f) => {
|
|
8030
|
+
if (ws.readyState === import_websocket.default.OPEN) ws.send(f);
|
|
8031
|
+
}, STREAM_WINDOW);
|
|
8032
|
+
ws.on("unexpected-response", (_req, res) => {
|
|
8033
|
+
this.log(`the box answered the link with HTTP ${res.statusCode}; retrying`);
|
|
8034
|
+
res.resume();
|
|
8035
|
+
ws.terminate();
|
|
8036
|
+
});
|
|
8037
|
+
ws.on("error", (err) => this.log(`link error: ${err.message}`));
|
|
8038
|
+
ws.on("open", () => {
|
|
8039
|
+
stopKeepAlive = keepAlive(
|
|
8040
|
+
ws,
|
|
8041
|
+
() => {
|
|
8042
|
+
this.log("the box stopped answering pings; reconnecting");
|
|
8043
|
+
ws.terminate();
|
|
8044
|
+
},
|
|
8045
|
+
this.o.pingMs
|
|
8046
|
+
);
|
|
8047
|
+
this.nonce = randomBytes5(32).toString("base64url");
|
|
8048
|
+
void this.o.describe().then(
|
|
8049
|
+
(d) => {
|
|
8050
|
+
if (ws.readyState !== import_websocket.default.OPEN) return;
|
|
8051
|
+
const hello = { type: "hello", protocol: PROTOCOL_VERSION, version: this.o.version, nonce: this.nonce, ...d };
|
|
8052
|
+
ws.send(encodeControl(hello));
|
|
8053
|
+
},
|
|
8054
|
+
(err) => {
|
|
8055
|
+
this.log(`could not describe this machine: ${err.message}`);
|
|
8056
|
+
ws.terminate();
|
|
8057
|
+
}
|
|
8058
|
+
);
|
|
8059
|
+
});
|
|
8060
|
+
ws.on("message", (data, isBinary) => {
|
|
8061
|
+
if (!isBinary) return;
|
|
8062
|
+
let frame;
|
|
8063
|
+
try {
|
|
8064
|
+
frame = decodeFrame(data);
|
|
8065
|
+
} catch {
|
|
8066
|
+
this.log("the box sent a malformed frame; reconnecting");
|
|
8067
|
+
ws.terminate();
|
|
8068
|
+
return;
|
|
8069
|
+
}
|
|
8070
|
+
if (frame.type === FrameType.control) {
|
|
8071
|
+
let m;
|
|
8072
|
+
try {
|
|
8073
|
+
m = decodeControl(frame.payload);
|
|
8074
|
+
} catch {
|
|
8075
|
+
return;
|
|
8076
|
+
}
|
|
8077
|
+
if (m.type === "ok") {
|
|
8078
|
+
welcomed = true;
|
|
8079
|
+
this.failures = 0;
|
|
8080
|
+
this.failingSince = null;
|
|
8081
|
+
this.set("online");
|
|
8082
|
+
this.emit("welcome", { machineId: m.machineId, name: m.name });
|
|
8083
|
+
} else if (m.type === "revoked") {
|
|
8084
|
+
if (verifyRevoke(this.o.token, this.o.machineId, this.nonce, m.mac)) {
|
|
8085
|
+
this.log("the box revoked this machine");
|
|
8086
|
+
this.emit("revoked");
|
|
8087
|
+
this.stop("revoked");
|
|
8088
|
+
} else {
|
|
8089
|
+
this.log("ignored a revoked message that was not signed for this machine and link");
|
|
8090
|
+
ws.terminate();
|
|
8091
|
+
}
|
|
8092
|
+
} else if (m.type === "duplicate") {
|
|
8093
|
+
this.log("the box refused this link: this machine is already linked from elsewhere; waiting 5 minutes");
|
|
8094
|
+
this.set("duplicate");
|
|
8095
|
+
this.nextDelay = this.o.duplicateBackoffMs;
|
|
8096
|
+
} else if (m.type === "upgrade") {
|
|
8097
|
+
this.log(`the box needs a newer node (protocol ${String(m.minimum)}); waiting an hour`);
|
|
8098
|
+
this.set("upgrade");
|
|
8099
|
+
this.nextDelay = this.o.slowRetryMs;
|
|
8100
|
+
this.emit("upgrade", { minimum: m.minimum });
|
|
8101
|
+
} else if (welcomed) {
|
|
8102
|
+
this.emit("control", m);
|
|
8103
|
+
}
|
|
8104
|
+
return;
|
|
8105
|
+
}
|
|
8106
|
+
if (frame.type === FrameType.open) {
|
|
8107
|
+
if (!welcomed || this.listenerCount("stream") === 0 || streams.has(frame.stream)) {
|
|
8108
|
+
ws.send(encodeFrame(FrameType.close, frame.stream));
|
|
8109
|
+
return;
|
|
8110
|
+
}
|
|
8111
|
+
this.emit("stream", streams.open(frame.stream), decodeOpen(frame.payload));
|
|
8112
|
+
return;
|
|
8113
|
+
}
|
|
8114
|
+
if (!streams.receive(frame) && frame.type !== FrameType.close) ws.send(encodeFrame(FrameType.close, frame.stream));
|
|
8115
|
+
});
|
|
8116
|
+
ws.on("close", (code2) => {
|
|
8117
|
+
stopKeepAlive();
|
|
8118
|
+
streams.endAll("the link closed");
|
|
8119
|
+
if (this.ws !== ws) return;
|
|
8120
|
+
this.ws = null;
|
|
8121
|
+
if (!this.running || this.stateNow === "revoked") return;
|
|
8122
|
+
if (code2 === 4005) {
|
|
8123
|
+
this.stop("left");
|
|
8124
|
+
return;
|
|
8125
|
+
}
|
|
8126
|
+
if (welcomed) {
|
|
8127
|
+
this.failures = 0;
|
|
8128
|
+
this.failingSince = null;
|
|
8129
|
+
}
|
|
8130
|
+
if (this.stateNow !== "duplicate" && this.stateNow !== "upgrade") this.set("offline");
|
|
8131
|
+
this.retry();
|
|
8132
|
+
});
|
|
8133
|
+
}
|
|
8134
|
+
};
|
|
8135
|
+
|
|
8136
|
+
// src/node/presence.ts
|
|
8137
|
+
import { createHmac as createHmac2, pbkdf2, randomBytes as randomBytes6, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
|
|
8138
|
+
var PRESENCE_ITERATIONS = 6e5;
|
|
8139
|
+
var MIN_PASSPHRASE_LENGTH = 12;
|
|
8140
|
+
function derive(passphrase2, salt, iterations = PRESENCE_ITERATIONS) {
|
|
8141
|
+
return new Promise((resolve, reject) => pbkdf2(passphrase2, salt, iterations, 32, "sha256", (err, key) => err ? reject(err) : resolve(key)));
|
|
8142
|
+
}
|
|
8143
|
+
async function enrol(passphrase2, now, iterations = PRESENCE_ITERATIONS) {
|
|
8144
|
+
const salt = randomBytes6(16);
|
|
8145
|
+
const key = await derive(passphrase2, salt, iterations);
|
|
8146
|
+
return { algorithm: "PBKDF2-SHA256", iterations, salt: salt.toString("base64url"), key: key.toString("base64url"), createdAt: now };
|
|
8147
|
+
}
|
|
8148
|
+
async function askPassphrase(prompt, err) {
|
|
8149
|
+
err("The machines passphrase (the one set on the box's host with `agentbox machines enable`).\n");
|
|
8150
|
+
err("This machine keeps only a key derived from it; the box never sees it from here.\n");
|
|
8151
|
+
const pass = await prompt.secret("Machines passphrase: ");
|
|
8152
|
+
if (pass.length < MIN_PASSPHRASE_LENGTH) throw new CliError(`the machines passphrase has at least ${MIN_PASSPHRASE_LENGTH} characters`);
|
|
8153
|
+
const again = await prompt.secret("Again: ");
|
|
8154
|
+
if (again !== pass) throw new CliError("the passphrases did not match");
|
|
8155
|
+
return pass;
|
|
8156
|
+
}
|
|
8157
|
+
var CHALLENGE_MS = 6e4;
|
|
8158
|
+
var GRANT_IDLE_MS = 30 * 6e4;
|
|
8159
|
+
var LOCK_FAILURES = 5;
|
|
8160
|
+
var LOCK_WINDOW_MS = 10 * 6e4;
|
|
8161
|
+
var LOCK_MS = 15 * 6e4;
|
|
8162
|
+
var MAX_CHALLENGES = 64;
|
|
8163
|
+
var MAX_GRANTS = 32;
|
|
8164
|
+
function presenceMessage(challenge, sessionId, machineId) {
|
|
8165
|
+
return Buffer.concat([challenge, Buffer.from(sessionId, "utf8"), Buffer.from(machineId, "utf8")]);
|
|
8166
|
+
}
|
|
8167
|
+
function presenceAnswer(key, challenge, sessionId, machineId) {
|
|
8168
|
+
return createHmac2("sha256", key).update(presenceMessage(challenge, sessionId, machineId)).digest("base64url");
|
|
8169
|
+
}
|
|
8170
|
+
var PresenceGate = class {
|
|
8171
|
+
constructor(o) {
|
|
8172
|
+
this.o = o;
|
|
8173
|
+
this.key = Buffer.from(o.presence.key, "base64url");
|
|
8174
|
+
this.now = o.now ?? Date.now;
|
|
8175
|
+
}
|
|
8176
|
+
o;
|
|
8177
|
+
key;
|
|
8178
|
+
challenges = /* @__PURE__ */ new Map();
|
|
8179
|
+
grants = /* @__PURE__ */ new Map();
|
|
8180
|
+
failures = [];
|
|
8181
|
+
lockedUntil = 0;
|
|
8182
|
+
now;
|
|
8183
|
+
/** Locked until this time, or `null`. */
|
|
8184
|
+
locked() {
|
|
8185
|
+
return this.lockedUntil > this.now() ? this.lockedUntil : null;
|
|
8186
|
+
}
|
|
8187
|
+
challenge(session) {
|
|
8188
|
+
if (session === null) return null;
|
|
8189
|
+
const until = this.locked();
|
|
8190
|
+
if (until !== null) return { locked: true, until };
|
|
8191
|
+
const t2 = this.now();
|
|
8192
|
+
for (const [c, v] of this.challenges) if (v.expiresAt <= t2) this.challenges.delete(c);
|
|
8193
|
+
while (this.challenges.size >= MAX_CHALLENGES) this.challenges.delete(this.challenges.keys().next().value);
|
|
8194
|
+
const challenge = randomBytes6(32).toString("base64url");
|
|
8195
|
+
const expiresAt = t2 + CHALLENGE_MS;
|
|
8196
|
+
this.challenges.set(challenge, { session, expiresAt });
|
|
8197
|
+
return { challenge, salt: this.o.presence.salt, iterations: this.o.presence.iterations, expiresAt };
|
|
8198
|
+
}
|
|
8199
|
+
answer(session, challenge, answer) {
|
|
8200
|
+
if (session === null) return { ok: false, error: "no_session" };
|
|
8201
|
+
const until = this.locked();
|
|
8202
|
+
if (until !== null) return { ok: false, error: "locked", until };
|
|
8203
|
+
const t2 = this.now();
|
|
8204
|
+
const c = this.challenges.get(challenge);
|
|
8205
|
+
this.challenges.delete(challenge);
|
|
8206
|
+
if (!c || c.expiresAt <= t2 || c.session !== session) return { ok: false, error: "expired" };
|
|
8207
|
+
const want = Buffer.from(presenceAnswer(this.key, Buffer.from(challenge, "base64url"), session, this.o.machineId), "base64url");
|
|
8208
|
+
const got = Buffer.from(answer, "base64url");
|
|
8209
|
+
if (got.length !== want.length || !timingSafeEqual3(got, want)) {
|
|
8210
|
+
this.failures = this.failures.filter((f) => f > t2 - LOCK_WINDOW_MS);
|
|
8211
|
+
this.failures.push(t2);
|
|
8212
|
+
if (this.failures.length >= LOCK_FAILURES) {
|
|
8213
|
+
this.lockedUntil = t2 + LOCK_MS;
|
|
8214
|
+
this.failures = [];
|
|
8215
|
+
this.challenges.clear();
|
|
8216
|
+
this.o.log?.(`presence: ${LOCK_FAILURES} wrong machines passphrases in ${LOCK_WINDOW_MS / 6e4} minutes; locked for ${LOCK_MS / 6e4} minutes`);
|
|
8217
|
+
return { ok: false, error: "locked", until: this.lockedUntil };
|
|
8218
|
+
}
|
|
8219
|
+
return { ok: false, error: "wrong", failures: this.failures.length };
|
|
8220
|
+
}
|
|
8221
|
+
this.failures = [];
|
|
8222
|
+
while (this.grants.size >= MAX_GRANTS) this.grants.delete(this.grants.keys().next().value);
|
|
8223
|
+
const grant = `pg_${randomBytes6(32).toString("base64url")}`;
|
|
8224
|
+
const g = { session, issuedAt: t2, lastActive: t2, open: 0 };
|
|
8225
|
+
this.grants.set(grant, g);
|
|
8226
|
+
return { ok: true, grant, expiresAt: this.expiry(g) };
|
|
8227
|
+
}
|
|
8228
|
+
expiry(g) {
|
|
8229
|
+
return g.issuedAt + this.o.hours() * 60 * 6e4;
|
|
8230
|
+
}
|
|
8231
|
+
/** Whether `grant` is live for `session` now. */
|
|
8232
|
+
check(grant, session) {
|
|
8233
|
+
if (grant === null || session === null) return false;
|
|
8234
|
+
const g = this.grants.get(grant);
|
|
8235
|
+
if (!g || g.session !== session) return false;
|
|
8236
|
+
const t2 = this.now();
|
|
8237
|
+
if (t2 >= this.expiry(g) || g.open === 0 && t2 - g.lastActive >= GRANT_IDLE_MS) {
|
|
8238
|
+
this.grants.delete(grant);
|
|
8239
|
+
return false;
|
|
8240
|
+
}
|
|
8241
|
+
return true;
|
|
8242
|
+
}
|
|
8243
|
+
/** A channel opened on `grant`: it does not idle out while open. Returns its release. */
|
|
8244
|
+
hold(grant) {
|
|
8245
|
+
const g = this.grants.get(grant);
|
|
8246
|
+
if (!g) return () => {
|
|
8247
|
+
};
|
|
8248
|
+
g.open += 1;
|
|
8249
|
+
g.lastActive = this.now();
|
|
8250
|
+
let done = false;
|
|
8251
|
+
return () => {
|
|
8252
|
+
if (done) return;
|
|
8253
|
+
done = true;
|
|
8254
|
+
g.open -= 1;
|
|
8255
|
+
g.lastActive = this.now();
|
|
8256
|
+
};
|
|
8257
|
+
}
|
|
8258
|
+
};
|
|
8259
|
+
|
|
8260
|
+
// src/node/prompt.ts
|
|
8261
|
+
var Prompter = class {
|
|
8262
|
+
constructor(stdin, out) {
|
|
8263
|
+
this.stdin = stdin;
|
|
8264
|
+
this.out = out;
|
|
8265
|
+
}
|
|
8266
|
+
stdin;
|
|
8267
|
+
out;
|
|
8268
|
+
buffer = "";
|
|
8269
|
+
ended = false;
|
|
8270
|
+
waiting = null;
|
|
8271
|
+
listening = false;
|
|
8272
|
+
listen() {
|
|
8273
|
+
if (this.listening) return;
|
|
8274
|
+
this.listening = true;
|
|
8275
|
+
this.stdin.on("data", (c) => {
|
|
8276
|
+
this.buffer += c.toString();
|
|
8277
|
+
this.waiting?.();
|
|
8278
|
+
});
|
|
8279
|
+
this.stdin.on("end", () => {
|
|
8280
|
+
this.ended = true;
|
|
8281
|
+
this.waiting?.();
|
|
8282
|
+
});
|
|
8283
|
+
this.stdin.resume();
|
|
8284
|
+
}
|
|
8285
|
+
async nextLine() {
|
|
8286
|
+
this.listen();
|
|
8287
|
+
for (; ; ) {
|
|
8288
|
+
const i = this.buffer.search(/\r?\n|\r/);
|
|
8289
|
+
if (i >= 0) {
|
|
8290
|
+
const line = this.buffer.slice(0, i);
|
|
8291
|
+
const nl = /^\r?\n|^\r/.exec(this.buffer.slice(i))?.[0].length ?? 1;
|
|
8292
|
+
this.buffer = this.buffer.slice(i + nl);
|
|
8293
|
+
return line;
|
|
8294
|
+
}
|
|
8295
|
+
if (this.ended) {
|
|
8296
|
+
if (this.buffer === "") throw new CliError("no answer on stdin", EXIT.USAGE);
|
|
8297
|
+
const line = this.buffer;
|
|
8298
|
+
this.buffer = "";
|
|
8299
|
+
return line;
|
|
8300
|
+
}
|
|
8301
|
+
await new Promise((r) => this.waiting = r);
|
|
8302
|
+
this.waiting = null;
|
|
8303
|
+
}
|
|
8304
|
+
}
|
|
8305
|
+
async line(question) {
|
|
8306
|
+
this.out.write(question);
|
|
8307
|
+
return (await this.nextLine()).trim();
|
|
8308
|
+
}
|
|
8309
|
+
/** A secret: on a terminal, read in raw mode and never echoed. */
|
|
8310
|
+
async secret(question) {
|
|
8311
|
+
this.out.write(question);
|
|
8312
|
+
if (!this.stdin.isTTY || !this.stdin.setRawMode) return this.nextLine();
|
|
8313
|
+
this.listen();
|
|
8314
|
+
this.stdin.setRawMode(true);
|
|
8315
|
+
try {
|
|
8316
|
+
let value = "";
|
|
8317
|
+
for (; ; ) {
|
|
8318
|
+
while (this.buffer.length > 0) {
|
|
8319
|
+
const ch = this.buffer[0];
|
|
8320
|
+
this.buffer = this.buffer.slice(1);
|
|
8321
|
+
if (ch === "\r" || ch === "\n") {
|
|
8322
|
+
this.out.write("\n");
|
|
8323
|
+
return value;
|
|
8324
|
+
}
|
|
8325
|
+
if (ch === "") throw new CliError("interrupted", EXIT.INTERRUPTED);
|
|
8326
|
+
if (ch === "\x7F" || ch === "\b") value = value.slice(0, -1);
|
|
8327
|
+
else if (ch >= " ") value += ch;
|
|
8328
|
+
}
|
|
8329
|
+
if (this.ended) throw new CliError("no answer", EXIT.USAGE);
|
|
8330
|
+
await new Promise((r) => this.waiting = r);
|
|
8331
|
+
this.waiting = null;
|
|
8332
|
+
}
|
|
8333
|
+
} finally {
|
|
8334
|
+
this.stdin.setRawMode(false);
|
|
8335
|
+
}
|
|
8336
|
+
}
|
|
8337
|
+
close() {
|
|
8338
|
+
this.stdin.pause();
|
|
8339
|
+
}
|
|
8340
|
+
};
|
|
8341
|
+
|
|
8342
|
+
// src/node/run.ts
|
|
8343
|
+
import fs19 from "node:fs";
|
|
8344
|
+
import path17 from "node:path";
|
|
8345
|
+
|
|
8346
|
+
// ../hostcore/src/herdr/socket.ts
|
|
8347
|
+
import net4 from "node:net";
|
|
8348
|
+
import readline from "node:readline";
|
|
8349
|
+
var HerdrError = class extends Error {
|
|
8350
|
+
code;
|
|
8351
|
+
constructor(code2, message) {
|
|
8352
|
+
super(message);
|
|
8353
|
+
this.code = code2;
|
|
8354
|
+
this.name = "HerdrError";
|
|
8355
|
+
}
|
|
8356
|
+
};
|
|
8357
|
+
var seq = 0;
|
|
8358
|
+
function request(socketPath, method, params = {}, opts = {}) {
|
|
8359
|
+
return new Promise((resolve, reject) => {
|
|
8360
|
+
const id = `wb_${++seq}`;
|
|
8361
|
+
const sock = net4.connect(socketPath);
|
|
8362
|
+
const rl = readline.createInterface({ input: sock });
|
|
8363
|
+
rl.on("error", () => {
|
|
8364
|
+
});
|
|
8365
|
+
const timer = setTimeout(() => {
|
|
8366
|
+
rl.close();
|
|
8367
|
+
sock.destroy();
|
|
8368
|
+
reject(new HerdrError("timeout", `${method} timed out`));
|
|
8369
|
+
}, opts.timeoutMs ?? 1e4);
|
|
8370
|
+
sock.once("error", (err) => {
|
|
8371
|
+
clearTimeout(timer);
|
|
8372
|
+
rl.close();
|
|
8373
|
+
reject(err);
|
|
8374
|
+
});
|
|
8375
|
+
sock.once("connect", () => {
|
|
8376
|
+
sock.write(JSON.stringify({ id, method, params }) + "\n");
|
|
8377
|
+
});
|
|
8378
|
+
rl.once("line", (line) => {
|
|
8379
|
+
clearTimeout(timer);
|
|
8380
|
+
rl.close();
|
|
8381
|
+
sock.end();
|
|
8382
|
+
let msg;
|
|
8383
|
+
try {
|
|
8384
|
+
msg = JSON.parse(line);
|
|
8385
|
+
} catch {
|
|
8386
|
+
reject(new HerdrError("bad_json", line.slice(0, 200)));
|
|
8387
|
+
return;
|
|
8388
|
+
}
|
|
8389
|
+
if (msg.error) {
|
|
8390
|
+
reject(new HerdrError(msg.error.code ?? "error", msg.error.message ?? "herdr error"));
|
|
8391
|
+
return;
|
|
8392
|
+
}
|
|
8393
|
+
resolve(msg.result);
|
|
8394
|
+
});
|
|
8395
|
+
});
|
|
8396
|
+
}
|
|
8397
|
+
function subscribe(socketPath, subscriptions, onEvent, onClose) {
|
|
8398
|
+
return new Promise((resolve, reject) => {
|
|
8399
|
+
const sock = net4.connect(socketPath);
|
|
8400
|
+
let started = false;
|
|
8401
|
+
let closing = false;
|
|
8402
|
+
const rl = readline.createInterface({ input: sock });
|
|
8403
|
+
rl.on("error", () => {
|
|
8404
|
+
});
|
|
8405
|
+
sock.once("connect", () => {
|
|
8406
|
+
sock.write(JSON.stringify({ id: "sub", method: "events.subscribe", params: { subscriptions } }) + "\n");
|
|
8407
|
+
});
|
|
8408
|
+
sock.on("error", (err) => {
|
|
8409
|
+
if (!started) {
|
|
8410
|
+
reject(err);
|
|
8411
|
+
} else if (!closing) {
|
|
8412
|
+
onClose(err);
|
|
8413
|
+
}
|
|
8414
|
+
});
|
|
8415
|
+
sock.on("close", () => {
|
|
8416
|
+
if (started && !closing) onClose();
|
|
8417
|
+
});
|
|
8418
|
+
rl.on("line", (line) => {
|
|
8419
|
+
let msg;
|
|
8420
|
+
try {
|
|
8421
|
+
msg = JSON.parse(line);
|
|
8422
|
+
} catch {
|
|
8423
|
+
return;
|
|
8424
|
+
}
|
|
8425
|
+
if (!started) {
|
|
8426
|
+
if (msg.error) {
|
|
8427
|
+
sock.destroy();
|
|
8428
|
+
reject(new HerdrError(msg.error.code ?? "error", msg.error.message ?? "herdr error"));
|
|
8429
|
+
return;
|
|
8430
|
+
}
|
|
8431
|
+
started = true;
|
|
8432
|
+
resolve({
|
|
8433
|
+
close: () => {
|
|
8434
|
+
closing = true;
|
|
8435
|
+
sock.destroy();
|
|
8436
|
+
}
|
|
8437
|
+
});
|
|
8438
|
+
return;
|
|
8439
|
+
}
|
|
8440
|
+
if (msg.event) onEvent(msg);
|
|
8441
|
+
});
|
|
8442
|
+
});
|
|
8443
|
+
}
|
|
8444
|
+
|
|
8445
|
+
// ../hostcore/src/herdr/session.ts
|
|
8446
|
+
var LIFECYCLE_TYPES = [
|
|
8447
|
+
"workspace.created",
|
|
8448
|
+
"workspace.updated",
|
|
8449
|
+
"workspace.metadata_updated",
|
|
8450
|
+
"workspace.renamed",
|
|
8451
|
+
"workspace.moved",
|
|
8452
|
+
"workspace.reordered",
|
|
8453
|
+
"workspace.closed",
|
|
8454
|
+
"workspace.focused",
|
|
8455
|
+
"worktree.created",
|
|
8456
|
+
"worktree.opened",
|
|
8457
|
+
"worktree.removed",
|
|
8458
|
+
"tab.created",
|
|
8459
|
+
"tab.closed",
|
|
8460
|
+
"tab.focused",
|
|
8461
|
+
"tab.renamed",
|
|
8462
|
+
"tab.moved",
|
|
8463
|
+
"pane.created",
|
|
8464
|
+
"pane.closed",
|
|
8465
|
+
"pane.updated",
|
|
8466
|
+
"pane.focused",
|
|
8467
|
+
"pane.moved",
|
|
8468
|
+
"pane.exited",
|
|
8469
|
+
"pane.agent_detected",
|
|
8470
|
+
"layout.updated"
|
|
8471
|
+
];
|
|
8472
|
+
var SessionHub = class {
|
|
8473
|
+
socketPath;
|
|
8474
|
+
lifecycleSub = null;
|
|
8475
|
+
agentSub = null;
|
|
8476
|
+
panes = /* @__PURE__ */ new Set();
|
|
8477
|
+
listeners = /* @__PURE__ */ new Set();
|
|
8478
|
+
_connected = false;
|
|
8479
|
+
_version = null;
|
|
8480
|
+
_protocol = null;
|
|
8481
|
+
stopped = false;
|
|
8482
|
+
reconnecting = false;
|
|
8483
|
+
reconnectDelay = 250;
|
|
8484
|
+
reconnectTimer = null;
|
|
8485
|
+
agentDebounce = null;
|
|
8486
|
+
constructor(socketPath) {
|
|
8487
|
+
this.socketPath = socketPath;
|
|
8488
|
+
}
|
|
8489
|
+
get connected() {
|
|
8490
|
+
return this._connected;
|
|
8491
|
+
}
|
|
8492
|
+
get version() {
|
|
8493
|
+
return this._version;
|
|
8494
|
+
}
|
|
8495
|
+
get protocol() {
|
|
8496
|
+
return this._protocol;
|
|
8497
|
+
}
|
|
8498
|
+
paneIds() {
|
|
8499
|
+
return [...this.panes];
|
|
8500
|
+
}
|
|
8501
|
+
on(listener) {
|
|
8502
|
+
this.listeners.add(listener);
|
|
8503
|
+
return () => {
|
|
8504
|
+
this.listeners.delete(listener);
|
|
8505
|
+
};
|
|
8506
|
+
}
|
|
8507
|
+
emit(m) {
|
|
8508
|
+
for (const listener of this.listeners) listener(m);
|
|
8509
|
+
}
|
|
8510
|
+
async start() {
|
|
8511
|
+
await this.connect();
|
|
8512
|
+
}
|
|
8513
|
+
async snapshot() {
|
|
8514
|
+
const res = await request(this.socketPath, "session.snapshot");
|
|
8515
|
+
return res.snapshot;
|
|
8516
|
+
}
|
|
8517
|
+
stop() {
|
|
8518
|
+
this.stopped = true;
|
|
8519
|
+
if (this.reconnectTimer) {
|
|
8520
|
+
clearTimeout(this.reconnectTimer);
|
|
8521
|
+
this.reconnectTimer = null;
|
|
8522
|
+
}
|
|
8523
|
+
if (this.agentDebounce) {
|
|
8524
|
+
clearTimeout(this.agentDebounce);
|
|
8525
|
+
this.agentDebounce = null;
|
|
8526
|
+
}
|
|
8527
|
+
this.closeSubs();
|
|
8528
|
+
this._connected = false;
|
|
8529
|
+
this.listeners.clear();
|
|
8530
|
+
}
|
|
8531
|
+
closeSubs() {
|
|
8532
|
+
if (this.lifecycleSub) {
|
|
8533
|
+
this.lifecycleSub.close();
|
|
8534
|
+
this.lifecycleSub = null;
|
|
8535
|
+
}
|
|
8536
|
+
if (this.agentSub) {
|
|
8537
|
+
this.agentSub.close();
|
|
8538
|
+
this.agentSub = null;
|
|
8539
|
+
}
|
|
8540
|
+
}
|
|
8541
|
+
/** Open lifecycle subscription, load the snapshot, then open agent-status. */
|
|
8542
|
+
async connect() {
|
|
8543
|
+
if (this.agentDebounce) {
|
|
8544
|
+
clearTimeout(this.agentDebounce);
|
|
8545
|
+
this.agentDebounce = null;
|
|
8546
|
+
}
|
|
8547
|
+
this.closeSubs();
|
|
8548
|
+
this.lifecycleSub = await subscribe(
|
|
8549
|
+
this.socketPath,
|
|
8550
|
+
LIFECYCLE_TYPES.map((type) => ({ type })),
|
|
8551
|
+
(e) => this.onLifecycle(e),
|
|
8552
|
+
() => this.onDrop()
|
|
8553
|
+
);
|
|
8554
|
+
const snap = await this.snapshot();
|
|
8555
|
+
this._version = snap.version;
|
|
8556
|
+
this._protocol = snap.protocol;
|
|
8557
|
+
this.panes.clear();
|
|
8558
|
+
for (const p of snap.panes) this.panes.add(p.pane_id);
|
|
8559
|
+
await this.openAgentSub();
|
|
8560
|
+
this._connected = true;
|
|
8561
|
+
}
|
|
8562
|
+
async openAgentSub() {
|
|
8563
|
+
const subs = this.paneIds().map((pane_id) => ({
|
|
8564
|
+
type: "pane.agent_status_changed",
|
|
8565
|
+
pane_id
|
|
8566
|
+
}));
|
|
8567
|
+
const next = await subscribe(
|
|
8568
|
+
this.socketPath,
|
|
8569
|
+
subs,
|
|
8570
|
+
(e) => this.onAgentEvent(e),
|
|
8571
|
+
() => this.onDrop()
|
|
8572
|
+
);
|
|
8573
|
+
const previous = this.agentSub;
|
|
8574
|
+
this.agentSub = next;
|
|
8575
|
+
if (previous) previous.close();
|
|
8576
|
+
}
|
|
8577
|
+
onLifecycle(e) {
|
|
8578
|
+
this.emit({ kind: "event", event: e.event, data: e.data });
|
|
8579
|
+
let changed = false;
|
|
8580
|
+
if (e.event === "pane_created" || e.event === "pane_moved") {
|
|
8581
|
+
const paneId = e.data.pane?.pane_id;
|
|
8582
|
+
if (paneId && !this.panes.has(paneId)) {
|
|
8583
|
+
this.panes.add(paneId);
|
|
8584
|
+
changed = true;
|
|
8585
|
+
}
|
|
8586
|
+
} else if (e.event === "pane_closed") {
|
|
8587
|
+
const paneId = e.data.pane_id;
|
|
8588
|
+
if (paneId && this.panes.delete(paneId)) changed = true;
|
|
8589
|
+
}
|
|
8590
|
+
if (changed) this.scheduleAgentReopen();
|
|
8591
|
+
}
|
|
8592
|
+
onAgentEvent(e) {
|
|
8593
|
+
this.emit({ kind: "event", event: "pane_agent_status_changed", data: e.data });
|
|
8594
|
+
}
|
|
8595
|
+
scheduleAgentReopen() {
|
|
8596
|
+
if (this.agentDebounce) clearTimeout(this.agentDebounce);
|
|
8597
|
+
this.agentDebounce = setTimeout(() => {
|
|
8598
|
+
this.agentDebounce = null;
|
|
8599
|
+
this.openAgentSub().catch(() => this.onDrop());
|
|
8600
|
+
}, 50);
|
|
8601
|
+
}
|
|
8602
|
+
onDrop() {
|
|
8603
|
+
if (this.stopped || this.reconnecting) return;
|
|
8604
|
+
this.reconnecting = true;
|
|
8605
|
+
this._connected = false;
|
|
8606
|
+
this.scheduleReconnect();
|
|
8607
|
+
}
|
|
8608
|
+
scheduleReconnect() {
|
|
8609
|
+
this.reconnectTimer = setTimeout(() => {
|
|
8610
|
+
this.reconnectTimer = null;
|
|
8611
|
+
if (this.stopped) return;
|
|
8612
|
+
this.connect().then(
|
|
8613
|
+
() => {
|
|
8614
|
+
this.reconnectDelay = 250;
|
|
8615
|
+
this.reconnecting = false;
|
|
8616
|
+
this.emit({ kind: "reset", reason: "herdr reconnected" });
|
|
8617
|
+
},
|
|
8618
|
+
() => {
|
|
8619
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 5e3);
|
|
8620
|
+
this.scheduleReconnect();
|
|
8621
|
+
}
|
|
8622
|
+
);
|
|
8623
|
+
}, this.reconnectDelay);
|
|
8624
|
+
}
|
|
8625
|
+
};
|
|
8626
|
+
|
|
8627
|
+
// ../hostcore/src/herdr/terminal.ts
|
|
8628
|
+
import { spawn as spawn5 } from "node:child_process";
|
|
8629
|
+
import readline2 from "node:readline";
|
|
8630
|
+
var MAX_REPLAY_BYTES = 1e6;
|
|
8631
|
+
var RELEASE_GRACE_MS = 1e3;
|
|
8632
|
+
var SIGKILL_GRACE_MS = 2e3;
|
|
8633
|
+
function killWithFallback(child, exited, graceMs = SIGKILL_GRACE_MS) {
|
|
8634
|
+
if (exited()) return null;
|
|
8635
|
+
if (!child.killed) child.kill("SIGTERM");
|
|
8636
|
+
const timer = setTimeout(() => {
|
|
8637
|
+
if (!exited()) child.kill("SIGKILL");
|
|
8638
|
+
}, graceMs);
|
|
8639
|
+
timer.unref?.();
|
|
8640
|
+
return timer;
|
|
8641
|
+
}
|
|
8642
|
+
var PaneStream = class {
|
|
8643
|
+
constructor(paneId, env, cols, rows, onDead) {
|
|
8644
|
+
this.paneId = paneId;
|
|
8645
|
+
this.env = env;
|
|
8646
|
+
this.onDead = onDead;
|
|
8647
|
+
this.cols = cols;
|
|
8648
|
+
this.rows = rows;
|
|
8649
|
+
this.watchScroll();
|
|
8650
|
+
this.child = this.spawn();
|
|
8651
|
+
}
|
|
8652
|
+
paneId;
|
|
8653
|
+
env;
|
|
8654
|
+
onDead;
|
|
8655
|
+
child;
|
|
8656
|
+
viewers = /* @__PURE__ */ new Set();
|
|
8657
|
+
/** Last full frame; the baseline replayed to late joiners. */
|
|
8658
|
+
full = null;
|
|
8659
|
+
/** Diffs received since the last full frame, replayed after it. */
|
|
8660
|
+
diffs = [];
|
|
8661
|
+
diffBytes = 0;
|
|
8662
|
+
cols;
|
|
8663
|
+
rows;
|
|
8664
|
+
/** Last size each viewer asked for, so focus can restore that viewer's size. */
|
|
8665
|
+
viewerSize = /* @__PURE__ */ new WeakMap();
|
|
8666
|
+
lastFullSeq = 0;
|
|
8667
|
+
released = false;
|
|
8668
|
+
dead = false;
|
|
8669
|
+
/** True once the child process has actually exited (not merely signalled). */
|
|
8670
|
+
childExited = false;
|
|
8671
|
+
killTimer = null;
|
|
8672
|
+
/** Where herdr's scrollback stands, from its pane.scroll_changed events. */
|
|
8673
|
+
scrolled = null;
|
|
8674
|
+
scrollSub = null;
|
|
8675
|
+
/**
|
|
8676
|
+
* Follow herdr's scrollback position for this pane and pass it to every
|
|
8677
|
+
* viewer, for the scrollbar and the way back to live. herdr reports it on a
|
|
8678
|
+
* scroll, on output that grows the history, and when typing snaps the pane
|
|
8679
|
+
* back to live. Without the socket the pane still scrolls, just unmarked.
|
|
8680
|
+
*/
|
|
8681
|
+
watchScroll() {
|
|
8682
|
+
const socketPath = this.env.HERDR_SOCKET_PATH;
|
|
8683
|
+
if (!socketPath) return;
|
|
8684
|
+
subscribe(
|
|
8685
|
+
socketPath,
|
|
8686
|
+
[{ type: "pane.scroll_changed", pane_id: this.paneId }],
|
|
8687
|
+
(e) => {
|
|
8688
|
+
const info = e.data?.scroll;
|
|
8689
|
+
if (!info) return;
|
|
8690
|
+
const m = { type: "scrolled", offset: info.offset_from_bottom, max: info.max_offset_from_bottom, rows: info.viewport_rows };
|
|
8691
|
+
this.scrolled = m;
|
|
8692
|
+
for (const v of this.viewers) v.sendJson(m);
|
|
8693
|
+
},
|
|
8694
|
+
() => {
|
|
8695
|
+
}
|
|
8696
|
+
).then(
|
|
8697
|
+
(sub) => {
|
|
8698
|
+
if (this.dead || this.released) sub.close();
|
|
8699
|
+
else this.scrollSub = sub;
|
|
8700
|
+
},
|
|
8701
|
+
() => {
|
|
8702
|
+
}
|
|
8703
|
+
);
|
|
8704
|
+
}
|
|
8705
|
+
unwatchScroll() {
|
|
8706
|
+
this.scrollSub?.close();
|
|
8707
|
+
this.scrollSub = null;
|
|
8708
|
+
}
|
|
8709
|
+
/** True once the child has exited/errored or herdr reported the pane closed. */
|
|
8710
|
+
get isDead() {
|
|
8711
|
+
return this.dead;
|
|
8712
|
+
}
|
|
8713
|
+
spawn() {
|
|
8714
|
+
const child = spawn5(
|
|
8715
|
+
"herdr",
|
|
8716
|
+
[
|
|
8717
|
+
"terminal",
|
|
8718
|
+
"session",
|
|
8719
|
+
"control",
|
|
8720
|
+
this.paneId,
|
|
8721
|
+
"--takeover",
|
|
8722
|
+
"--cols",
|
|
8723
|
+
String(this.cols),
|
|
8724
|
+
"--rows",
|
|
8725
|
+
String(this.rows)
|
|
8726
|
+
],
|
|
8727
|
+
{ env: this.env, stdio: ["pipe", "pipe", "pipe"] }
|
|
8728
|
+
);
|
|
8729
|
+
if (child.stdout) {
|
|
8730
|
+
readline2.createInterface({ input: child.stdout }).on("line", (line) => this.onLine(line));
|
|
8731
|
+
}
|
|
8732
|
+
child.stderr?.resume();
|
|
8733
|
+
child.on("error", () => this.markDead("stream error"));
|
|
8734
|
+
child.on("exit", () => {
|
|
8735
|
+
this.childExited = true;
|
|
8736
|
+
if (this.killTimer) {
|
|
8737
|
+
clearTimeout(this.killTimer);
|
|
8738
|
+
this.killTimer = null;
|
|
8739
|
+
}
|
|
8740
|
+
this.markDead("stream ended");
|
|
8741
|
+
});
|
|
8742
|
+
return child;
|
|
8743
|
+
}
|
|
8744
|
+
/**
|
|
8745
|
+
* Send SIGTERM and, if the child is still alive after a bounded grace, follow
|
|
8746
|
+
* it with SIGKILL. A herdr controller that ignores SIGTERM would otherwise
|
|
8747
|
+
* keep the process — and this stream — from ever tearing down.
|
|
8748
|
+
*/
|
|
8749
|
+
killWithFallback() {
|
|
8750
|
+
if (this.childExited || this.killTimer) return;
|
|
8751
|
+
this.killTimer = killWithFallback(this.child, () => this.childExited);
|
|
8752
|
+
}
|
|
8753
|
+
/**
|
|
8754
|
+
* Mark the stream dead exactly once (dropping it from the registry) and close
|
|
8755
|
+
* any remaining viewers. Called on child exit/error and on terminal.closed.
|
|
8756
|
+
*/
|
|
8757
|
+
markDead(reason) {
|
|
8758
|
+
this.unwatchScroll();
|
|
8759
|
+
if (!this.dead) {
|
|
8760
|
+
this.dead = true;
|
|
8761
|
+
this.onDead(this.paneId, this);
|
|
8762
|
+
}
|
|
8763
|
+
this.shutdownViewers(reason);
|
|
8764
|
+
}
|
|
8765
|
+
shutdownViewers(reason) {
|
|
8766
|
+
for (const v of this.viewers) v.close(reason);
|
|
8767
|
+
this.viewers.clear();
|
|
8768
|
+
}
|
|
8769
|
+
onLine(line) {
|
|
8770
|
+
let msg;
|
|
8771
|
+
try {
|
|
8772
|
+
msg = JSON.parse(line);
|
|
8773
|
+
} catch {
|
|
8774
|
+
return;
|
|
8775
|
+
}
|
|
8776
|
+
if (msg.type === "terminal.frame") {
|
|
8777
|
+
const buf = Buffer.from(msg.bytes, "base64");
|
|
8778
|
+
if (msg.full) {
|
|
8779
|
+
this.full = buf;
|
|
8780
|
+
this.diffs = [];
|
|
8781
|
+
this.diffBytes = 0;
|
|
8782
|
+
this.lastFullSeq = msg.seq;
|
|
8783
|
+
} else {
|
|
8784
|
+
this.diffs.push(buf);
|
|
8785
|
+
this.diffBytes += buf.length;
|
|
8786
|
+
if (this.diffBytes > MAX_REPLAY_BYTES) {
|
|
8787
|
+
this.full = null;
|
|
8788
|
+
this.diffs = [];
|
|
8789
|
+
this.diffBytes = 0;
|
|
8790
|
+
}
|
|
8791
|
+
}
|
|
8792
|
+
for (const v of this.viewers) v.send(buf);
|
|
8793
|
+
} else if (msg.type === "terminal.closed") {
|
|
8794
|
+
this.markDead(msg.reason ?? "closed");
|
|
8795
|
+
}
|
|
8796
|
+
}
|
|
8797
|
+
write(o) {
|
|
8798
|
+
this.child.stdin?.write(JSON.stringify(o) + "\n");
|
|
8799
|
+
}
|
|
8800
|
+
addViewer(v, cols, rows) {
|
|
8801
|
+
this.viewers.add(v);
|
|
8802
|
+
this.viewerSize.set(v, { cols, rows });
|
|
8803
|
+
if (this.scrolled) v.sendJson(this.scrolled);
|
|
8804
|
+
if (this.full && this.diffBytes < MAX_REPLAY_BYTES && cols === this.cols && rows === this.rows) {
|
|
8805
|
+
v.send(this.full);
|
|
8806
|
+
for (const d of this.diffs) v.send(d);
|
|
8807
|
+
} else {
|
|
8808
|
+
this.resize(cols, rows);
|
|
8809
|
+
}
|
|
8810
|
+
}
|
|
8811
|
+
removeViewer(v) {
|
|
8812
|
+
if (!this.viewers.delete(v)) return;
|
|
8813
|
+
if (this.viewers.size === 0) this.release();
|
|
8814
|
+
}
|
|
8815
|
+
release() {
|
|
8816
|
+
if (this.released) return;
|
|
8817
|
+
this.released = true;
|
|
8818
|
+
this.unwatchScroll();
|
|
8819
|
+
this.write({ type: "terminal.release" });
|
|
8820
|
+
this.child.stdin?.end();
|
|
8821
|
+
setTimeout(() => this.killWithFallback(), RELEASE_GRACE_MS).unref();
|
|
8822
|
+
}
|
|
8823
|
+
/** Kill the child immediately; used on shutdown, not on normal release. */
|
|
8824
|
+
destroy() {
|
|
8825
|
+
this.released = true;
|
|
8826
|
+
this.unwatchScroll();
|
|
8827
|
+
this.child.stdin?.end();
|
|
8828
|
+
this.killWithFallback();
|
|
8829
|
+
}
|
|
8830
|
+
resize(cols, rows) {
|
|
8831
|
+
this.cols = cols;
|
|
8832
|
+
this.rows = rows;
|
|
8833
|
+
this.write({ type: "terminal.resize", cols, rows });
|
|
8834
|
+
for (const v of this.viewers) v.sendJson({ type: "size", cols, rows });
|
|
8835
|
+
}
|
|
8836
|
+
/** Record this viewer's size and make it the current pane size. */
|
|
8837
|
+
resizeFor(v, cols, rows) {
|
|
8838
|
+
this.viewerSize.set(v, { cols, rows });
|
|
8839
|
+
this.resize(cols, rows);
|
|
8840
|
+
}
|
|
8841
|
+
/** The focusing viewer becomes the size owner: restore its last known size. */
|
|
8842
|
+
focus(v) {
|
|
8843
|
+
const size = this.viewerSize.get(v) ?? { cols: this.cols, rows: this.rows };
|
|
8844
|
+
this.resize(size.cols, size.rows);
|
|
8845
|
+
}
|
|
8846
|
+
input(text, bytes) {
|
|
8847
|
+
this.write({ type: "terminal.input", ...bytes ? { bytes } : { text: text ?? "" } });
|
|
8848
|
+
}
|
|
8849
|
+
scroll(direction, lines) {
|
|
8850
|
+
this.write({ type: "terminal.scroll", direction, lines: Math.max(1, lines | 0), source: "wheel" });
|
|
8851
|
+
}
|
|
8852
|
+
/** Put herdr's view of the pane `offset` lines above live; 0 is back to live. */
|
|
8853
|
+
scrollTo(offset) {
|
|
8854
|
+
const socketPath = this.env.HERDR_SOCKET_PATH;
|
|
8855
|
+
if (!socketPath || !Number.isFinite(offset)) return;
|
|
8856
|
+
const offset_from_bottom = Math.max(0, Math.floor(offset));
|
|
8857
|
+
request(socketPath, "pane.scroll", { pane_id: this.paneId, offset_from_bottom }).catch(() => {
|
|
8858
|
+
});
|
|
8859
|
+
}
|
|
8860
|
+
size() {
|
|
8861
|
+
return { cols: this.cols, rows: this.rows };
|
|
8862
|
+
}
|
|
8863
|
+
get viewerCount() {
|
|
8864
|
+
return this.viewers.size;
|
|
8865
|
+
}
|
|
8866
|
+
};
|
|
8867
|
+
var TerminalStreams = class {
|
|
8868
|
+
constructor(env) {
|
|
8869
|
+
this.env = env;
|
|
8870
|
+
}
|
|
8871
|
+
env;
|
|
8872
|
+
streams = /* @__PURE__ */ new Map();
|
|
8873
|
+
/** Drop a stream from the registry, but only if it is still the live one. */
|
|
8874
|
+
forget(paneId, stream) {
|
|
8875
|
+
if (this.streams.get(paneId) === stream) this.streams.delete(paneId);
|
|
8876
|
+
}
|
|
8877
|
+
attach(paneId, viewer, cols = 80, rows = 24) {
|
|
8878
|
+
let stream = this.streams.get(paneId);
|
|
8879
|
+
if (!stream || stream.isDead) {
|
|
8880
|
+
stream = new PaneStream(paneId, this.env, cols, rows, (p, s2) => this.forget(p, s2));
|
|
8881
|
+
this.streams.set(paneId, stream);
|
|
8882
|
+
}
|
|
8883
|
+
stream.addViewer(viewer, cols, rows);
|
|
8884
|
+
const s = stream;
|
|
8885
|
+
let detached = false;
|
|
8886
|
+
return {
|
|
8887
|
+
input: (text, bytes) => s.input(text, bytes),
|
|
8888
|
+
resize: (c, r) => s.resizeFor(viewer, c, r),
|
|
8889
|
+
scroll: (direction, lines) => s.scroll(direction, lines),
|
|
8890
|
+
scrollTo: (offset) => s.scrollTo(offset),
|
|
8891
|
+
focus: () => s.focus(viewer),
|
|
8892
|
+
detach: () => {
|
|
8893
|
+
if (detached) return;
|
|
8894
|
+
detached = true;
|
|
8895
|
+
s.removeViewer(viewer);
|
|
8896
|
+
if (s.viewerCount === 0) this.forget(paneId, s);
|
|
8897
|
+
}
|
|
8898
|
+
};
|
|
8899
|
+
}
|
|
8900
|
+
active(paneId) {
|
|
8901
|
+
return this.streams.has(paneId);
|
|
8902
|
+
}
|
|
8903
|
+
size(paneId) {
|
|
8904
|
+
return this.streams.get(paneId)?.size() ?? null;
|
|
8905
|
+
}
|
|
8906
|
+
lastFullSeq(paneId) {
|
|
8907
|
+
return this.streams.get(paneId)?.lastFullSeq ?? 0;
|
|
8908
|
+
}
|
|
8909
|
+
/** Test-only: number of live pane streams in the registry. */
|
|
8910
|
+
streamCount() {
|
|
8911
|
+
return this.streams.size;
|
|
8912
|
+
}
|
|
8913
|
+
/** Tear down every live stream; call on server shutdown. */
|
|
8914
|
+
stop() {
|
|
8915
|
+
for (const s of this.streams.values()) s.destroy();
|
|
8916
|
+
this.streams.clear();
|
|
8917
|
+
}
|
|
8918
|
+
};
|
|
8919
|
+
|
|
8920
|
+
// ../hostcore/src/ws-heartbeat.ts
|
|
8921
|
+
var HEARTBEAT_MS = 3e4;
|
|
8922
|
+
function heartbeat(socket2, intervalMs = HEARTBEAT_MS) {
|
|
8923
|
+
let alive = true;
|
|
8924
|
+
socket2.on("pong", () => {
|
|
8925
|
+
alive = true;
|
|
8926
|
+
});
|
|
8927
|
+
const timer = setInterval(() => {
|
|
8928
|
+
if (socket2.readyState !== socket2.OPEN) return;
|
|
8929
|
+
if (!alive) {
|
|
8930
|
+
socket2.terminate();
|
|
8931
|
+
return;
|
|
8932
|
+
}
|
|
8933
|
+
alive = false;
|
|
8934
|
+
try {
|
|
8935
|
+
socket2.ping();
|
|
8936
|
+
} catch {
|
|
8937
|
+
}
|
|
8938
|
+
}, intervalMs);
|
|
8939
|
+
timer.unref?.();
|
|
8940
|
+
const stop = () => clearInterval(timer);
|
|
8941
|
+
socket2.on("close", stop);
|
|
8942
|
+
return stop;
|
|
8943
|
+
}
|
|
8944
|
+
|
|
8945
|
+
// ../hostcore/src/herdr/terminal-socket.ts
|
|
8946
|
+
var PANE_RE = /^w[0-9A-Za-z]{1,12}:p[0-9A-Za-z]{1,12}$/;
|
|
8947
|
+
var MAX_BUFFERED_BYTES = 8 * 1024 * 1024;
|
|
8948
|
+
var MAX_CHANNELS = 64;
|
|
8949
|
+
function isBackpressured(bufferedAmount, limit = MAX_BUFFERED_BYTES) {
|
|
8950
|
+
return bufferedAmount > limit;
|
|
8951
|
+
}
|
|
8952
|
+
function clamp(value, min, max, fallback) {
|
|
8953
|
+
return Number.isFinite(value) ? Math.min(max, Math.max(min, Math.trunc(value))) : fallback;
|
|
8954
|
+
}
|
|
8955
|
+
function muxFrame(ch, data) {
|
|
8956
|
+
const out = Buffer.allocUnsafe(2 + data.length);
|
|
8957
|
+
out.writeUInt16BE(ch, 0);
|
|
8958
|
+
data.copy(out, 2);
|
|
8959
|
+
return out;
|
|
8960
|
+
}
|
|
8961
|
+
function sendOrShed(socket2, data) {
|
|
8962
|
+
if (socket2.readyState !== socket2.OPEN) return;
|
|
8963
|
+
if (isBackpressured(socket2.bufferedAmount)) {
|
|
8964
|
+
try {
|
|
8965
|
+
socket2.close(1013, "viewer too slow");
|
|
8966
|
+
} catch {
|
|
8967
|
+
}
|
|
8968
|
+
return;
|
|
8969
|
+
}
|
|
8970
|
+
socket2.send(data, { binary: true });
|
|
8971
|
+
}
|
|
8972
|
+
function sendJson(socket2, m) {
|
|
8973
|
+
if (socket2.readyState === socket2.OPEN) socket2.send(JSON.stringify(m));
|
|
8974
|
+
}
|
|
8975
|
+
function control(a, msg, size) {
|
|
8976
|
+
switch (msg.type) {
|
|
8977
|
+
case "input":
|
|
8978
|
+
a.input(msg.text, msg.bytes);
|
|
8979
|
+
break;
|
|
8980
|
+
case "resize":
|
|
8981
|
+
a.resize(clamp(Number(msg.cols), 20, 500, size.cols), clamp(Number(msg.rows), 5, 200, size.rows));
|
|
8982
|
+
break;
|
|
8983
|
+
case "scroll":
|
|
8984
|
+
if (msg.direction === "up" || msg.direction === "down") a.scroll(msg.direction, Number(msg.lines));
|
|
8985
|
+
break;
|
|
8986
|
+
case "scrollTo":
|
|
8987
|
+
a.scrollTo(Number(msg.offset));
|
|
8988
|
+
break;
|
|
8989
|
+
case "focus":
|
|
8990
|
+
a.focus();
|
|
8991
|
+
break;
|
|
8992
|
+
default:
|
|
8993
|
+
break;
|
|
8994
|
+
}
|
|
8995
|
+
}
|
|
8996
|
+
function parse(raw) {
|
|
8997
|
+
try {
|
|
8998
|
+
return JSON.parse(raw.toString());
|
|
8999
|
+
} catch {
|
|
9000
|
+
return null;
|
|
9001
|
+
}
|
|
9002
|
+
}
|
|
9003
|
+
function single(socket2, streams, pane, cols, rows) {
|
|
9004
|
+
const viewer = {
|
|
9005
|
+
send: (data) => sendOrShed(socket2, data),
|
|
9006
|
+
sendJson: (m) => sendJson(socket2, m),
|
|
9007
|
+
close: (reason) => {
|
|
9008
|
+
try {
|
|
9009
|
+
if (socket2.readyState === socket2.OPEN) {
|
|
9010
|
+
socket2.send(JSON.stringify({ type: "closed", reason }));
|
|
9011
|
+
socket2.close(1e3);
|
|
9012
|
+
}
|
|
9013
|
+
} catch {
|
|
9014
|
+
}
|
|
9015
|
+
}
|
|
9016
|
+
};
|
|
9017
|
+
const attachment = streams.attach(pane, viewer, cols, rows);
|
|
9018
|
+
socket2.on("message", (raw) => {
|
|
9019
|
+
const msg = parse(raw);
|
|
9020
|
+
if (msg) control(attachment, msg, { cols, rows });
|
|
9021
|
+
});
|
|
9022
|
+
socket2.on("close", () => attachment.detach());
|
|
9023
|
+
}
|
|
9024
|
+
function shared(socket2, streams) {
|
|
9025
|
+
const channels = /* @__PURE__ */ new Map();
|
|
9026
|
+
const notice = (ch, m) => sendJson(socket2, { ...m, ch });
|
|
9027
|
+
const attach2 = (ch, pane, c, r) => {
|
|
9028
|
+
if (channels.has(ch)) return;
|
|
9029
|
+
if (typeof pane !== "string" || !PANE_RE.test(pane)) return notice(ch, { type: "closed", reason: "invalid pane" });
|
|
9030
|
+
if (channels.size >= MAX_CHANNELS) return notice(ch, { type: "closed", reason: "too many panes on one socket" });
|
|
9031
|
+
const size = { cols: clamp(Number(c), 20, 500, 80), rows: clamp(Number(r), 5, 200, 24) };
|
|
9032
|
+
let live = true;
|
|
9033
|
+
const viewer = {
|
|
9034
|
+
send: (data) => {
|
|
9035
|
+
if (live) sendOrShed(socket2, muxFrame(ch, data));
|
|
9036
|
+
},
|
|
9037
|
+
sendJson: (m) => {
|
|
9038
|
+
if (live) notice(ch, m);
|
|
9039
|
+
},
|
|
9040
|
+
close: (reason) => {
|
|
9041
|
+
if (!live) return;
|
|
9042
|
+
live = false;
|
|
9043
|
+
channels.delete(ch);
|
|
9044
|
+
notice(ch, { type: "closed", reason });
|
|
9045
|
+
}
|
|
9046
|
+
};
|
|
9047
|
+
const attachment = streams.attach(pane, viewer, size.cols, size.rows);
|
|
9048
|
+
if (live) channels.set(ch, { attachment, size });
|
|
9049
|
+
};
|
|
9050
|
+
socket2.on("message", (raw) => {
|
|
9051
|
+
const msg = parse(raw);
|
|
9052
|
+
if (!msg) return;
|
|
9053
|
+
const ch = Number(msg.ch);
|
|
9054
|
+
if (!Number.isInteger(ch) || ch < 0 || ch > 65535) return;
|
|
9055
|
+
if (msg.type === "ping") return sendJson(socket2, { type: "pong", ch, t: Number(msg.t) || 0 });
|
|
9056
|
+
if (msg.type === "attach") return attach2(ch, msg.pane, msg.cols, msg.rows);
|
|
9057
|
+
const c = channels.get(ch);
|
|
9058
|
+
if (!c) return;
|
|
9059
|
+
if (msg.type === "detach") {
|
|
9060
|
+
channels.delete(ch);
|
|
9061
|
+
c.attachment.detach();
|
|
9062
|
+
return;
|
|
9063
|
+
}
|
|
9064
|
+
if (msg.type === "resize") c.size = { cols: clamp(Number(msg.cols), 20, 500, c.size.cols), rows: clamp(Number(msg.rows), 5, 200, c.size.rows) };
|
|
9065
|
+
control(c.attachment, msg, c.size);
|
|
9066
|
+
});
|
|
9067
|
+
socket2.on("close", () => {
|
|
9068
|
+
for (const c of channels.values()) c.attachment.detach();
|
|
9069
|
+
channels.clear();
|
|
9070
|
+
});
|
|
9071
|
+
}
|
|
9072
|
+
function serveTerminalSocket(socket2, streams, query) {
|
|
9073
|
+
const pane = query.pane;
|
|
9074
|
+
if (pane === void 0) {
|
|
9075
|
+
heartbeat(socket2);
|
|
9076
|
+
shared(socket2, streams);
|
|
9077
|
+
return;
|
|
9078
|
+
}
|
|
9079
|
+
if (typeof pane !== "string" || !PANE_RE.test(pane)) {
|
|
9080
|
+
socket2.close(1008, "invalid pane");
|
|
9081
|
+
return;
|
|
9082
|
+
}
|
|
9083
|
+
heartbeat(socket2);
|
|
9084
|
+
single(socket2, streams, pane, clamp(Number(query.cols), 20, 500, 80), clamp(Number(query.rows), 5, 200, 24));
|
|
9085
|
+
}
|
|
9086
|
+
|
|
9087
|
+
// ../hostcore/src/rpc-allowlist.ts
|
|
9088
|
+
var EXACT = /* @__PURE__ */ new Set(["ping", "session.snapshot", "notification.show"]);
|
|
9089
|
+
var PREFIXES = ["workspace.", "worktree.", "tab.", "pane.", "agent.", "layout."];
|
|
9090
|
+
function isGraphics(method) {
|
|
9091
|
+
return method === "pane.graphics" || method.startsWith("pane.graphics.");
|
|
9092
|
+
}
|
|
9093
|
+
function isAllowed(method) {
|
|
9094
|
+
if (EXACT.has(method)) return true;
|
|
9095
|
+
if (isGraphics(method)) return false;
|
|
9096
|
+
return PREFIXES.some(
|
|
9097
|
+
(p) => method.startsWith(p) && method.length > p.length && /^[a-z_.]+$/.test(method)
|
|
9098
|
+
);
|
|
9099
|
+
}
|
|
9100
|
+
|
|
9101
|
+
// src/node/herdr.ts
|
|
9102
|
+
import { spawn as spawn6 } from "node:child_process";
|
|
9103
|
+
import { randomBytes as randomBytes7 } from "node:crypto";
|
|
9104
|
+
import { EventEmitter as EventEmitter2 } from "node:events";
|
|
9105
|
+
import fs16 from "node:fs";
|
|
9106
|
+
import os8 from "node:os";
|
|
9107
|
+
import path14 from "node:path";
|
|
9108
|
+
var HERDR_SESSION = "agentbox-node";
|
|
9109
|
+
var CHECK_MS = 3e4;
|
|
9110
|
+
var FAILED_CHECKS = 2;
|
|
9111
|
+
var READY_MS = 15e3;
|
|
9112
|
+
var MAX_SOCKET_PATH = 96;
|
|
9113
|
+
var HerdrSupervisor = class extends EventEmitter2 {
|
|
9114
|
+
constructor(o) {
|
|
9115
|
+
super();
|
|
9116
|
+
this.o = o;
|
|
9117
|
+
this.stateFile = path14.join(o.dir, "state", "herdr.json");
|
|
9118
|
+
}
|
|
9119
|
+
o;
|
|
9120
|
+
child = null;
|
|
9121
|
+
socket = null;
|
|
9122
|
+
pid = null;
|
|
9123
|
+
stopped = false;
|
|
9124
|
+
failures = 0;
|
|
9125
|
+
checker = null;
|
|
9126
|
+
restarting = null;
|
|
9127
|
+
backoff = 1e3;
|
|
9128
|
+
stateFile;
|
|
9129
|
+
/** The control socket now, or `null` while herdr is starting. */
|
|
9130
|
+
get socketPath() {
|
|
9131
|
+
return this.socket;
|
|
9132
|
+
}
|
|
9133
|
+
log(line) {
|
|
9134
|
+
this.emit("log", line);
|
|
9135
|
+
}
|
|
9136
|
+
async answers(socket2) {
|
|
9137
|
+
try {
|
|
9138
|
+
const pong = await request(socket2, "ping", {}, { timeoutMs: 5e3 });
|
|
9139
|
+
return pong.version === (this.o.version ?? HERDR_VERSION);
|
|
9140
|
+
} catch {
|
|
9141
|
+
return false;
|
|
9142
|
+
}
|
|
9143
|
+
}
|
|
9144
|
+
/** A fresh socket path: in the node folder when it fits, else a private folder of its own. */
|
|
9145
|
+
freshSocket() {
|
|
9146
|
+
const name = `herdr-${randomBytes7(4).toString("hex")}.sock`;
|
|
9147
|
+
const inNode = path14.join(this.o.dir, name);
|
|
9148
|
+
if (Buffer.byteLength(inNode) <= MAX_SOCKET_PATH) return inNode;
|
|
9149
|
+
const dir = fs16.mkdtempSync(path14.join(os8.tmpdir(), "agentbox-node-"));
|
|
9150
|
+
fs16.chmodSync(dir, 448);
|
|
9151
|
+
return path14.join(dir, name);
|
|
9152
|
+
}
|
|
9153
|
+
async start() {
|
|
9154
|
+
this.stopped = false;
|
|
9155
|
+
const saved = this.readSaved();
|
|
9156
|
+
if (saved && await this.answers(saved.socket)) {
|
|
9157
|
+
this.socket = saved.socket;
|
|
9158
|
+
this.pid = saved.pid;
|
|
9159
|
+
this.log(`herdr: kept the running server (pid ${saved.pid})`);
|
|
9160
|
+
this.emit("ready", saved.socket, { restored: false });
|
|
9161
|
+
} else {
|
|
9162
|
+
if (saved) this.kill(saved.pid);
|
|
9163
|
+
await this.spawnServer();
|
|
9164
|
+
}
|
|
9165
|
+
this.watch();
|
|
9166
|
+
}
|
|
9167
|
+
readSaved() {
|
|
9168
|
+
try {
|
|
9169
|
+
const v = JSON.parse(fs16.readFileSync(this.stateFile, "utf8"));
|
|
9170
|
+
return typeof v.socket === "string" && typeof v.pid === "number" ? { socket: v.socket, pid: v.pid } : null;
|
|
9171
|
+
} catch {
|
|
9172
|
+
return null;
|
|
9173
|
+
}
|
|
9174
|
+
}
|
|
9175
|
+
/** Only a herdr of ours: on Linux its command line says so; elsewhere, the pid we recorded. */
|
|
9176
|
+
kill(pid) {
|
|
9177
|
+
try {
|
|
9178
|
+
if (process.platform === "linux") {
|
|
9179
|
+
const cmd = fs16.readFileSync(`/proc/${pid}/cmdline`, "utf8");
|
|
9180
|
+
if (!cmd.includes("herdr")) return;
|
|
9181
|
+
}
|
|
9182
|
+
process.kill(pid, "SIGTERM");
|
|
9183
|
+
} catch {
|
|
9184
|
+
}
|
|
9185
|
+
}
|
|
9186
|
+
removeSocket(socket2) {
|
|
9187
|
+
if (!socket2) return;
|
|
9188
|
+
for (const f of [socket2, socket2.replace(/\.sock$/, "-client.sock")]) fs16.rmSync(f, { force: true });
|
|
9189
|
+
}
|
|
9190
|
+
async spawnServer() {
|
|
9191
|
+
const old = this.socket;
|
|
9192
|
+
this.socket = null;
|
|
9193
|
+
this.removeSocket(old);
|
|
9194
|
+
const socket2 = this.freshSocket();
|
|
9195
|
+
ensurePrivateDir(path14.dirname(this.stateFile));
|
|
9196
|
+
const child = spawn6(this.o.bin, ["server"], {
|
|
9197
|
+
env: { ...this.o.env, HERDR_SOCKET_PATH: socket2, HERDR_SESSION },
|
|
9198
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
9199
|
+
// Its own session: herdr counts only such a server as a daemon, and it
|
|
9200
|
+
// outlives the node.
|
|
9201
|
+
detached: true,
|
|
9202
|
+
windowsHide: true
|
|
9203
|
+
});
|
|
9204
|
+
this.child = child;
|
|
9205
|
+
child.stderr?.on("data", (c) => this.log(`herdr: ${c.toString("utf8").trim().slice(0, 500)}`));
|
|
9206
|
+
child.once("error", (err) => this.log(`herdr: could not start: ${err.message}`));
|
|
9207
|
+
child.once("exit", (code2, signal) => {
|
|
9208
|
+
if (this.child === child) this.child = null;
|
|
9209
|
+
if (this.stopped || this.socket !== socket2) return;
|
|
9210
|
+
this.log(`herdr exited (code ${code2 ?? "none"}, signal ${signal ?? "none"}); restarting`);
|
|
9211
|
+
this.socket = null;
|
|
9212
|
+
this.emit("down");
|
|
9213
|
+
void this.restart("it exited");
|
|
9214
|
+
});
|
|
9215
|
+
child.unref();
|
|
9216
|
+
const end = Date.now() + READY_MS;
|
|
9217
|
+
while (!await this.answers(socket2)) {
|
|
9218
|
+
if (this.stopped || this.child !== child) return;
|
|
9219
|
+
if (Date.now() > end) {
|
|
9220
|
+
child.kill("SIGTERM");
|
|
9221
|
+
throw new Error(`herdr did not answer on ${socket2} within ${READY_MS / 1e3} s`);
|
|
9222
|
+
}
|
|
9223
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
9224
|
+
}
|
|
9225
|
+
this.socket = socket2;
|
|
9226
|
+
this.pid = child.pid ?? null;
|
|
9227
|
+
if (this.pid !== null) writePrivate2(this.stateFile, `${JSON.stringify({ socket: socket2, pid: this.pid })}
|
|
9228
|
+
`);
|
|
9229
|
+
this.failures = 0;
|
|
9230
|
+
this.backoff = 1e3;
|
|
9231
|
+
this.log(`herdr ${this.o.version ?? HERDR_VERSION} started (pid ${this.pid ?? "?"})`);
|
|
9232
|
+
this.emit("ready", socket2, { restored: true });
|
|
9233
|
+
}
|
|
9234
|
+
watch() {
|
|
9235
|
+
if (this.checker) clearInterval(this.checker);
|
|
9236
|
+
this.checker = setInterval(() => void this.check(), this.o.checkMs ?? CHECK_MS);
|
|
9237
|
+
this.checker.unref();
|
|
9238
|
+
}
|
|
9239
|
+
/** One health check: two failures in a row and herdr is restarted. */
|
|
9240
|
+
async check() {
|
|
9241
|
+
if (this.stopped || this.restarting || !this.socket) return;
|
|
9242
|
+
if (await this.answers(this.socket)) {
|
|
9243
|
+
this.failures = 0;
|
|
9244
|
+
return;
|
|
9245
|
+
}
|
|
9246
|
+
this.failures += 1;
|
|
9247
|
+
this.log(`herdr: its control socket did not answer (${this.failures} of ${FAILED_CHECKS})`);
|
|
9248
|
+
if (this.failures >= FAILED_CHECKS) await this.restart("its control socket stopped answering");
|
|
9249
|
+
}
|
|
9250
|
+
/** Stop the server and start a fresh one (a hang, an exit, an update while idle). */
|
|
9251
|
+
restart(reason) {
|
|
9252
|
+
if (this.restarting) return this.restarting;
|
|
9253
|
+
this.restarting = (async () => {
|
|
9254
|
+
this.log(`herdr: restarting (${reason})`);
|
|
9255
|
+
this.failures = 0;
|
|
9256
|
+
const child = this.child;
|
|
9257
|
+
this.child = null;
|
|
9258
|
+
if (child) child.kill("SIGTERM");
|
|
9259
|
+
else if (this.pid !== null) this.kill(this.pid);
|
|
9260
|
+
this.socket = null;
|
|
9261
|
+
this.emit("down");
|
|
9262
|
+
for (; ; ) {
|
|
9263
|
+
if (this.stopped) return;
|
|
9264
|
+
try {
|
|
9265
|
+
await this.spawnServer();
|
|
9266
|
+
return;
|
|
9267
|
+
} catch (err) {
|
|
9268
|
+
this.log(`herdr: ${err.message}; trying again in ${this.backoff / 1e3} s`);
|
|
9269
|
+
await new Promise((r) => setTimeout(r, this.backoff));
|
|
9270
|
+
this.backoff = Math.min(this.backoff * 2, 3e4);
|
|
9271
|
+
}
|
|
9272
|
+
}
|
|
9273
|
+
})().finally(() => {
|
|
9274
|
+
this.restarting = null;
|
|
9275
|
+
});
|
|
9276
|
+
return this.restarting;
|
|
9277
|
+
}
|
|
9278
|
+
/** No agent is running (the updater replaces herdr and Claude Code only then). */
|
|
9279
|
+
async idle() {
|
|
9280
|
+
if (!this.socket) return true;
|
|
9281
|
+
try {
|
|
9282
|
+
const r = await request(this.socket, "session.snapshot", {}, { timeoutMs: 5e3 });
|
|
9283
|
+
return (r.snapshot.agents ?? []).length === 0;
|
|
9284
|
+
} catch {
|
|
9285
|
+
return false;
|
|
9286
|
+
}
|
|
9287
|
+
}
|
|
9288
|
+
/** Stop supervising. herdr keeps running (its agents with it) unless `kill`. */
|
|
9289
|
+
stop(kill = false) {
|
|
9290
|
+
this.stopped = true;
|
|
9291
|
+
if (this.checker) clearInterval(this.checker);
|
|
9292
|
+
this.checker = null;
|
|
9293
|
+
if (kill) {
|
|
9294
|
+
if (this.child) this.child.kill("SIGTERM");
|
|
9295
|
+
else if (this.pid !== null) this.kill(this.pid);
|
|
9296
|
+
}
|
|
9297
|
+
}
|
|
9298
|
+
};
|
|
9299
|
+
|
|
9300
|
+
// src/node/server.ts
|
|
9301
|
+
import http from "node:http";
|
|
9302
|
+
var MAX_BODY = 64 * 1024;
|
|
9303
|
+
function json(res, status3, body) {
|
|
9304
|
+
const text = JSON.stringify(body);
|
|
9305
|
+
res.writeHead(status3, { "content-type": "application/json", "content-length": Buffer.byteLength(text), "cache-control": "no-store" });
|
|
9306
|
+
res.end(text);
|
|
9307
|
+
}
|
|
9308
|
+
function readJson(req) {
|
|
9309
|
+
return new Promise((resolve) => {
|
|
9310
|
+
const chunks = [];
|
|
9311
|
+
let size = 0;
|
|
9312
|
+
req.on("data", (c) => {
|
|
9313
|
+
size += c.length;
|
|
9314
|
+
if (size > MAX_BODY) {
|
|
9315
|
+
resolve(null);
|
|
9316
|
+
req.destroy();
|
|
9317
|
+
return;
|
|
9318
|
+
}
|
|
9319
|
+
chunks.push(c);
|
|
9320
|
+
});
|
|
9321
|
+
req.on("end", () => {
|
|
9322
|
+
try {
|
|
9323
|
+
const v = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
9324
|
+
resolve(typeof v === "object" && v !== null && !Array.isArray(v) ? v : null);
|
|
9325
|
+
} catch {
|
|
9326
|
+
resolve(null);
|
|
9327
|
+
}
|
|
9328
|
+
});
|
|
9329
|
+
req.on("error", () => resolve(null));
|
|
9330
|
+
});
|
|
9331
|
+
}
|
|
9332
|
+
function refuseSocket(socket2, status3, body) {
|
|
9333
|
+
const text = JSON.stringify(body);
|
|
9334
|
+
socket2.end(
|
|
9335
|
+
`HTTP/1.1 ${status3} ${http.STATUS_CODES[status3] ?? ""}\r
|
|
9336
|
+
Connection: close\r
|
|
9337
|
+
Content-Type: application/json\r
|
|
9338
|
+
Content-Length: ${Buffer.byteLength(text)}\r
|
|
9339
|
+
\r
|
|
9340
|
+
${text}`
|
|
9341
|
+
);
|
|
9342
|
+
}
|
|
9343
|
+
var NodeServer = class {
|
|
9344
|
+
constructor(o) {
|
|
9345
|
+
this.o = o;
|
|
9346
|
+
this.server = http.createServer({ requestTimeout: 0, headersTimeout: 3e4, keepAliveTimeout: 6e4 }, (req, res) => {
|
|
9347
|
+
void this.handle(req, res).catch((err) => {
|
|
9348
|
+
o.log?.(`server: ${err.message}`);
|
|
9349
|
+
if (!res.headersSent) json(res, 500, { error: "internal" });
|
|
9350
|
+
else res.destroy();
|
|
9351
|
+
});
|
|
9352
|
+
});
|
|
9353
|
+
this.server.on("upgrade", (req, socket2, head) => this.upgrade(req, socket2, head));
|
|
9354
|
+
this.server.on("clientError", (_err, socket2) => socket2.destroy());
|
|
9355
|
+
}
|
|
9356
|
+
o;
|
|
9357
|
+
server;
|
|
9358
|
+
wss = new import_websocket_server.default({ noServer: true, maxPayload: 1024 * 1024 });
|
|
9359
|
+
opens = /* @__PURE__ */ new WeakMap();
|
|
9360
|
+
channels = /* @__PURE__ */ new Set();
|
|
9361
|
+
/** A stream from the link: a connection to this server. */
|
|
9362
|
+
accept(stream, open3) {
|
|
9363
|
+
this.opens.set(stream, open3);
|
|
9364
|
+
this.server.emit("connection", stream);
|
|
9365
|
+
}
|
|
9366
|
+
openOf(req) {
|
|
9367
|
+
return this.opens.get(req.socket) ?? { kind: "http", session: null, grant: null };
|
|
9368
|
+
}
|
|
9369
|
+
/** Why `need` is not allowed for this stream now, or `null`. */
|
|
9370
|
+
refusal(need, open3) {
|
|
9371
|
+
const l = this.o.policy();
|
|
9372
|
+
if (need === "terminals" && !l.policy.terminals) {
|
|
9373
|
+
return {
|
|
9374
|
+
status: 403,
|
|
9375
|
+
body: { error: "terminals_off", message: l.problem ? `this machine's policy denies everything: ${l.problem}` : "terminals are off in this machine's policy.json" }
|
|
9376
|
+
};
|
|
9377
|
+
}
|
|
9378
|
+
if (!this.o.presence.check(open3.grant, open3.session)) {
|
|
9379
|
+
return { status: 401, body: { error: "presence", message: "this machine wants proof that you are there: the machines passphrase" } };
|
|
9380
|
+
}
|
|
9381
|
+
return null;
|
|
9382
|
+
}
|
|
9383
|
+
/** Close every channel the policy or presence no longer allows. Returns how many. */
|
|
9384
|
+
enforce() {
|
|
9385
|
+
let n = 0;
|
|
9386
|
+
for (const ch of [...this.channels]) {
|
|
9387
|
+
if (this.refusal(ch.need, ch.open) === null) continue;
|
|
9388
|
+
this.channels.delete(ch);
|
|
9389
|
+
ch.close();
|
|
9390
|
+
n += 1;
|
|
9391
|
+
}
|
|
9392
|
+
return n;
|
|
9393
|
+
}
|
|
9394
|
+
/** Track a channel for as long as `until` lasts; holds its grant from idling out. */
|
|
9395
|
+
channel(need, open3, close, onEnd) {
|
|
9396
|
+
const ch = { need, open: open3, close };
|
|
9397
|
+
this.channels.add(ch);
|
|
9398
|
+
const release = open3.grant ? this.o.presence.hold(open3.grant) : () => {
|
|
9399
|
+
};
|
|
9400
|
+
onEnd(() => {
|
|
9401
|
+
this.channels.delete(ch);
|
|
9402
|
+
release();
|
|
9403
|
+
});
|
|
9404
|
+
}
|
|
9405
|
+
get openChannels() {
|
|
9406
|
+
return this.channels.size;
|
|
9407
|
+
}
|
|
9408
|
+
async handle(req, res) {
|
|
9409
|
+
const url = new URL(req.url ?? "/", "http://machine");
|
|
9410
|
+
const open3 = this.openOf(req);
|
|
9411
|
+
const route = `${req.method} ${url.pathname}`;
|
|
9412
|
+
switch (route) {
|
|
9413
|
+
case "GET /api/health": {
|
|
9414
|
+
const hub = this.o.hub();
|
|
9415
|
+
const l = this.o.policy();
|
|
9416
|
+
return json(res, 200, {
|
|
9417
|
+
ok: true,
|
|
9418
|
+
herdr: { connected: hub?.connected ?? false, version: hub?.version ?? null, protocol: hub?.protocol ?? null },
|
|
9419
|
+
workspaceRoot: this.o.home,
|
|
9420
|
+
homeRoot: this.o.home,
|
|
9421
|
+
sharing: false,
|
|
9422
|
+
publicUrl: null,
|
|
9423
|
+
machine: { terminals: l.policy.terminals, problem: l.problem, presenceLocked: this.o.presence.locked() }
|
|
9424
|
+
});
|
|
9425
|
+
}
|
|
9426
|
+
case "POST /presence/challenge": {
|
|
9427
|
+
await readJson(req);
|
|
9428
|
+
const c = this.o.presence.challenge(open3.session);
|
|
9429
|
+
if (c === null) return json(res, 400, { error: "no_session", message: "no machines session" });
|
|
9430
|
+
if ("locked" in c) return json(res, 429, { error: "locked", until: c.until, message: lockedMessage(c.until) });
|
|
9431
|
+
return json(res, 200, c);
|
|
9432
|
+
}
|
|
9433
|
+
case "POST /presence/answer": {
|
|
9434
|
+
const b = await readJson(req);
|
|
9435
|
+
const r = this.o.presence.answer(open3.session, typeof b?.challenge === "string" ? b.challenge : "", typeof b?.answer === "string" ? b.answer : "");
|
|
9436
|
+
if (r.ok) return json(res, 200, { grant: r.grant, expiresAt: r.expiresAt });
|
|
9437
|
+
if (r.error === "locked") return json(res, 429, { error: "locked", until: r.until, message: lockedMessage(r.until) });
|
|
9438
|
+
if (r.error === "wrong") return json(res, 401, { error: "wrong", message: "That is not this machine's passphrase.", failures: r.failures });
|
|
9439
|
+
return json(res, 400, { error: r.error, message: "That challenge has expired; try again." });
|
|
9440
|
+
}
|
|
9441
|
+
case "GET /api/session":
|
|
9442
|
+
case "POST /api/rpc": {
|
|
9443
|
+
const no = this.refusal("terminals", open3);
|
|
9444
|
+
if (no) return json(res, no.status, no.body);
|
|
9445
|
+
const socket2 = this.o.herdrSocket();
|
|
9446
|
+
if (!socket2) return json(res, 503, { error: "herdr", message: "herdr is starting on this machine" });
|
|
9447
|
+
this.channel(
|
|
9448
|
+
"terminals",
|
|
9449
|
+
open3,
|
|
9450
|
+
() => res.destroy(),
|
|
9451
|
+
(cb) => res.once("close", cb)
|
|
9452
|
+
);
|
|
9453
|
+
if (route === "GET /api/session") {
|
|
9454
|
+
const hub = this.o.hub();
|
|
9455
|
+
if (!hub) return json(res, 503, { error: "herdr", message: "herdr is starting on this machine" });
|
|
9456
|
+
try {
|
|
9457
|
+
return json(res, 200, await hub.snapshot());
|
|
9458
|
+
} catch (err) {
|
|
9459
|
+
return json(res, 502, { error: "herdr", message: err.message });
|
|
9460
|
+
}
|
|
9461
|
+
}
|
|
9462
|
+
const b = await readJson(req);
|
|
9463
|
+
const method = b?.method;
|
|
9464
|
+
if (typeof method !== "string") return json(res, 400, { error: "method required" });
|
|
9465
|
+
if (!isAllowed(method)) return json(res, 403, { error: "method not allowed" });
|
|
9466
|
+
const params = typeof b?.params === "object" && b.params !== null ? b.params : {};
|
|
9467
|
+
try {
|
|
9468
|
+
return json(res, 200, { result: await request(socket2, method, params) });
|
|
9469
|
+
} catch (err) {
|
|
9470
|
+
if (err instanceof HerdrError) return json(res, 502, { error: { code: err.code, message: err.message } });
|
|
9471
|
+
return json(res, 502, { error: { code: "unreachable", message: err.message } });
|
|
9472
|
+
}
|
|
9473
|
+
}
|
|
9474
|
+
default:
|
|
9475
|
+
return json(res, 404, { error: "not found" });
|
|
9476
|
+
}
|
|
9477
|
+
}
|
|
9478
|
+
upgrade(req, socket2, head) {
|
|
9479
|
+
socket2.on("error", () => socket2.destroy());
|
|
9480
|
+
const url = new URL(req.url ?? "/", "http://machine");
|
|
9481
|
+
const open3 = this.openOf(req);
|
|
9482
|
+
if (url.pathname !== "/ws/terminal" && url.pathname !== "/ws/events") return refuseSocket(socket2, 404, { error: "not found" });
|
|
9483
|
+
const no = this.refusal("terminals", open3);
|
|
9484
|
+
if (no) return refuseSocket(socket2, no.status, no.body);
|
|
9485
|
+
const streams = this.o.streams();
|
|
9486
|
+
const hub = this.o.hub();
|
|
9487
|
+
if (!streams || !hub) return refuseSocket(socket2, 503, { error: "herdr", message: "herdr is starting on this machine" });
|
|
9488
|
+
this.wss.handleUpgrade(req, socket2, head, (ws) => {
|
|
9489
|
+
this.channel(
|
|
9490
|
+
"terminals",
|
|
9491
|
+
open3,
|
|
9492
|
+
() => ws.terminate(),
|
|
9493
|
+
(cb) => ws.once("close", cb)
|
|
9494
|
+
);
|
|
9495
|
+
if (url.pathname === "/ws/terminal") {
|
|
9496
|
+
serveTerminalSocket(ws, streams, { pane: url.searchParams.get("pane") ?? void 0, cols: url.searchParams.get("cols") ?? void 0, rows: url.searchParams.get("rows") ?? void 0 });
|
|
9497
|
+
return;
|
|
9498
|
+
}
|
|
9499
|
+
this.events(ws, hub);
|
|
9500
|
+
});
|
|
9501
|
+
}
|
|
9502
|
+
/** `/ws/events`: a fresh snapshot, then every hub message as it comes. */
|
|
9503
|
+
events(ws, hub) {
|
|
9504
|
+
heartbeat(ws);
|
|
9505
|
+
let off = null;
|
|
9506
|
+
const send = (m) => {
|
|
9507
|
+
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(m));
|
|
9508
|
+
};
|
|
9509
|
+
hub.snapshot().then(
|
|
9510
|
+
(snapshot) => {
|
|
9511
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
9512
|
+
send({ kind: "snapshot", snapshot });
|
|
9513
|
+
off = hub.on(send);
|
|
9514
|
+
},
|
|
9515
|
+
() => ws.close()
|
|
9516
|
+
);
|
|
9517
|
+
ws.on("close", () => off?.());
|
|
9518
|
+
}
|
|
9519
|
+
close() {
|
|
9520
|
+
for (const ch of [...this.channels]) ch.close();
|
|
9521
|
+
this.channels.clear();
|
|
9522
|
+
this.wss.close();
|
|
9523
|
+
this.server.close();
|
|
9524
|
+
}
|
|
9525
|
+
};
|
|
9526
|
+
function lockedMessage(until) {
|
|
9527
|
+
const mins = Math.max(1, Math.ceil((until - Date.now()) / 6e4));
|
|
9528
|
+
return `Too many wrong passphrases: this machine has locked presence for ${mins} more minute${mins === 1 ? "" : "s"}.`;
|
|
9529
|
+
}
|
|
9530
|
+
|
|
9531
|
+
// src/node/service-linux.ts
|
|
9532
|
+
import { execFile } from "node:child_process";
|
|
9533
|
+
import fs17 from "node:fs";
|
|
9534
|
+
import os9 from "node:os";
|
|
9535
|
+
import path15 from "node:path";
|
|
9536
|
+
var UNIT_NAME = "agentbox-node.service";
|
|
9537
|
+
var runCommand = (cmd, args) => new Promise((resolve) => {
|
|
9538
|
+
execFile(cmd, args, { timeout: 3e4 }, (err, stdout, stderr) => {
|
|
9539
|
+
const code2 = err ? typeof err.code === "number" ? err.code : 127 : 0;
|
|
9540
|
+
resolve({ code: code2, stdout: String(stdout), stderr: String(stderr) });
|
|
9541
|
+
});
|
|
9542
|
+
});
|
|
9543
|
+
function unitDir(env = process.env, home2 = os9.homedir()) {
|
|
9544
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
9545
|
+
return path15.join(xdg && path15.isAbsolute(xdg) ? xdg : path15.join(home2, ".config"), "systemd", "user");
|
|
9546
|
+
}
|
|
9547
|
+
function execStart(execPath = process.execPath, script = process.argv[1]) {
|
|
9548
|
+
const isSea = script === void 0 || path15.resolve(script) === path15.resolve(execPath);
|
|
9549
|
+
return isSea ? [execPath, "node", "run"] : [execPath, path15.resolve(script), "node", "run"];
|
|
9550
|
+
}
|
|
9551
|
+
function quote(arg) {
|
|
9552
|
+
return `"${arg.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
|
|
9553
|
+
}
|
|
9554
|
+
function unitText(command2) {
|
|
9555
|
+
return [
|
|
9556
|
+
"[Unit]",
|
|
9557
|
+
"Description=agentbox node: this machine's link to its box",
|
|
9558
|
+
"After=network-online.target",
|
|
9559
|
+
"Wants=network-online.target",
|
|
9560
|
+
"",
|
|
9561
|
+
"[Service]",
|
|
9562
|
+
"Type=simple",
|
|
9563
|
+
`ExecStart=${command2.map(quote).join(" ")}`,
|
|
9564
|
+
"Restart=on-failure",
|
|
9565
|
+
"RestartSec=5",
|
|
9566
|
+
// herdr, and the agents in it, outlive a restart of the node (an
|
|
9567
|
+
// update, a crash); the next node adopts it.
|
|
9568
|
+
"KillMode=process",
|
|
9569
|
+
"",
|
|
9570
|
+
"[Install]",
|
|
9571
|
+
"WantedBy=default.target",
|
|
9572
|
+
""
|
|
9573
|
+
].join("\n");
|
|
9574
|
+
}
|
|
9575
|
+
async function installService(opts) {
|
|
9576
|
+
const run4 = opts.run ?? runCommand;
|
|
9577
|
+
const dir = unitDir(opts.env);
|
|
9578
|
+
fs17.mkdirSync(dir, { recursive: true });
|
|
9579
|
+
const unitFile = path15.join(dir, UNIT_NAME);
|
|
9580
|
+
fs17.writeFileSync(unitFile, unitText(opts.command), { mode: 420 });
|
|
9581
|
+
for (const args of [["--user", "daemon-reload"], ["--user", "enable", UNIT_NAME], ["--user", "restart", UNIT_NAME]]) {
|
|
9582
|
+
const r = await run4("systemctl", args);
|
|
9583
|
+
if (r.code !== 0) {
|
|
9584
|
+
throw new Error(`systemctl ${args.join(" ")} failed: ${(r.stderr || r.stdout).trim() || `exit ${r.code}`}`);
|
|
9585
|
+
}
|
|
9586
|
+
}
|
|
9587
|
+
const user = opts.user ?? os9.userInfo().username;
|
|
9588
|
+
const linger = await run4("loginctl", ["enable-linger", user]);
|
|
9589
|
+
if (linger.code === 0) return { unitFile, lingering: true, note: null };
|
|
9590
|
+
const cron = `@reboot ${opts.command.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ")}`;
|
|
9591
|
+
return {
|
|
9592
|
+
unitFile,
|
|
9593
|
+
lingering: false,
|
|
9594
|
+
note: `This machine does not let the node keep running after you log out (loginctl enable-linger was refused), so it runs while you are logged in. To start it at boot instead, add this line with \`crontab -e\`:
|
|
9595
|
+
${cron}`
|
|
9596
|
+
};
|
|
9597
|
+
}
|
|
9598
|
+
async function removeService(opts = {}) {
|
|
9599
|
+
const run4 = opts.run ?? runCommand;
|
|
9600
|
+
const unitFile = path15.join(unitDir(opts.env), UNIT_NAME);
|
|
9601
|
+
if (!fs17.existsSync(unitFile)) return false;
|
|
9602
|
+
await run4("systemctl", ["--user", "disable", "--now", UNIT_NAME]);
|
|
9603
|
+
fs17.rmSync(unitFile, { force: true });
|
|
9604
|
+
await run4("systemctl", ["--user", "daemon-reload"]);
|
|
9605
|
+
return true;
|
|
9606
|
+
}
|
|
9607
|
+
function serviceInstalled(env = process.env) {
|
|
9608
|
+
return fs17.existsSync(path15.join(unitDir(env), UNIT_NAME));
|
|
9609
|
+
}
|
|
9610
|
+
|
|
9611
|
+
// src/node/specs.ts
|
|
9612
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
9613
|
+
import fs18 from "node:fs";
|
|
9614
|
+
import os10 from "node:os";
|
|
9615
|
+
import path16 from "node:path";
|
|
9616
|
+
function run(cmd, args, timeoutMs = 3e3) {
|
|
9617
|
+
return new Promise((resolve) => {
|
|
9618
|
+
execFile2(cmd, args, { timeout: timeoutMs, windowsHide: true, maxBuffer: 256 * 1024 }, (err, stdout) => resolve(err ? null : stdout));
|
|
9619
|
+
});
|
|
9620
|
+
}
|
|
9621
|
+
function onPath(cmd, env) {
|
|
9622
|
+
const exts = process.platform === "win32" ? [".exe", ".cmd", ""] : [""];
|
|
9623
|
+
for (const dir of (env.PATH ?? "").split(path16.delimiter)) {
|
|
9624
|
+
for (const ext of exts) {
|
|
9625
|
+
try {
|
|
9626
|
+
fs18.accessSync(path16.join(dir, cmd + ext), fs18.constants.X_OK);
|
|
9627
|
+
return true;
|
|
9628
|
+
} catch {
|
|
9629
|
+
}
|
|
9630
|
+
}
|
|
9631
|
+
}
|
|
9632
|
+
return false;
|
|
9633
|
+
}
|
|
9634
|
+
async function gpus(env) {
|
|
9635
|
+
if (onPath("nvidia-smi", env)) {
|
|
9636
|
+
const out = await run("nvidia-smi", ["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"]);
|
|
9637
|
+
if (out) {
|
|
9638
|
+
return out.split("\n").map((l) => l.split(",").map((s) => s.trim())).filter((p) => p[0]).map(([name, mem]) => ({ name, memoryMiB: Number(mem) || null }));
|
|
9639
|
+
}
|
|
9640
|
+
}
|
|
9641
|
+
if (onPath("rocm-smi", env)) {
|
|
9642
|
+
const out = await run("rocm-smi", ["--showproductname", "--csv"]);
|
|
9643
|
+
if (out) {
|
|
9644
|
+
return out.split("\n").slice(1).map((l) => l.split(",")[2]?.trim() ?? "").filter(Boolean).map((name) => ({ name, memoryMiB: null }));
|
|
9645
|
+
}
|
|
9646
|
+
}
|
|
9647
|
+
return [];
|
|
9648
|
+
}
|
|
9649
|
+
function freeBytes(dir) {
|
|
9650
|
+
try {
|
|
9651
|
+
const s = fs18.statfsSync(dir);
|
|
9652
|
+
return s.bavail * s.bsize;
|
|
9653
|
+
} catch {
|
|
9654
|
+
return null;
|
|
9655
|
+
}
|
|
9656
|
+
}
|
|
9657
|
+
async function claudeVersion(binDir, env) {
|
|
9658
|
+
const own = path16.join(binDir, process.platform === "win32" ? "claude.exe" : "claude");
|
|
9659
|
+
const cmd = fs18.existsSync(own) ? own : onPath("claude", env) ? "claude" : null;
|
|
9660
|
+
if (!cmd) return null;
|
|
9661
|
+
const out = await run(cmd, ["--version"], 5e3);
|
|
9662
|
+
return out ? /\d+\.\d+\.\d+/.exec(out)?.[0] ?? null : null;
|
|
9663
|
+
}
|
|
9664
|
+
async function gatherSpecs(opts) {
|
|
9665
|
+
const env = opts.env ?? process.env;
|
|
9666
|
+
const cpus = os10.cpus();
|
|
9667
|
+
const disks = [];
|
|
9668
|
+
for (const f of opts.policy.files) {
|
|
9669
|
+
const free = freeBytes(f.path);
|
|
9670
|
+
if (free !== null) disks.push({ path: f.path, freeBytes: free });
|
|
9671
|
+
}
|
|
9672
|
+
return {
|
|
9673
|
+
platform: process.platform,
|
|
9674
|
+
arch: process.arch,
|
|
9675
|
+
hostname: os10.hostname(),
|
|
9676
|
+
cpu: { model: cpus[0]?.model ?? "", cores: cpus.length },
|
|
9677
|
+
ramBytes: os10.totalmem(),
|
|
9678
|
+
disks,
|
|
9679
|
+
gpus: await gpus(env),
|
|
9680
|
+
docker: onPath("docker", env),
|
|
9681
|
+
claude: await claudeVersion(opts.binDir, env)
|
|
9682
|
+
};
|
|
9683
|
+
}
|
|
9684
|
+
|
|
9685
|
+
// src/node/run.ts
|
|
9686
|
+
var RESTART_EXIT = 75;
|
|
9687
|
+
var HEADLESS = { cols: 200, rows: 50, ms: 3e4 };
|
|
9688
|
+
async function runNode(o) {
|
|
9689
|
+
const now = o.now ?? Date.now;
|
|
9690
|
+
const creds = readCredentials(o.paths.credentials);
|
|
9691
|
+
if (!creds) throw new CliError("this machine has not joined a box; run `agentbox node join <box>`", EXIT.NOT_FOUND);
|
|
9692
|
+
if (creds.revokedAt !== void 0) {
|
|
9693
|
+
o.log(`this machine was revoked by ${creds.box} on ${new Date(creds.revokedAt).toISOString()}; re-join it to use it again`);
|
|
9694
|
+
return EXIT.OK;
|
|
9695
|
+
}
|
|
9696
|
+
if (await callControl(o.paths.control, o.paths.controlSecret, "status", 2e3) !== null) {
|
|
9697
|
+
throw new CliError("a node is already running on this machine (see `agentbox node status`)");
|
|
9698
|
+
}
|
|
9699
|
+
let policy = loadPolicy(o.paths.policy, o.home);
|
|
9700
|
+
const claude = claudePaths(o.paths);
|
|
9701
|
+
prepareClaude(claude, { home: o.home, machine: creds.name });
|
|
9702
|
+
const herdrEnv = { ...process.env, PATH: [claude.shims, o.paths.bin, process.env.PATH ?? ""].join(path17.delimiter) };
|
|
9703
|
+
delete herdrEnv.CLAUDE_CODE_OAUTH_TOKEN;
|
|
9704
|
+
const presence = new PresenceGate({ presence: creds.presence, machineId: creds.machineId, hours: () => policy.policy.presenceHours, now, log: o.log });
|
|
9705
|
+
let hub = null;
|
|
9706
|
+
let streams = null;
|
|
9707
|
+
const herdrBin = path17.join(o.paths.bin, process.platform === "win32" ? "herdr.exe" : "herdr");
|
|
9708
|
+
const herdr = fs19.existsSync(herdrBin) ? new HerdrSupervisor({ bin: herdrBin, dir: o.paths.dir, env: herdrEnv }) : null;
|
|
9709
|
+
if (!herdr) o.log(`herdr is not installed (${herdrBin}); terminals and agents are unavailable until \`agentbox node join\` installs it`);
|
|
9710
|
+
const server = new NodeServer({
|
|
9711
|
+
herdrSocket: () => herdr?.socketPath ?? null,
|
|
9712
|
+
hub: () => hub,
|
|
9713
|
+
streams: () => streams,
|
|
9714
|
+
policy: () => policy,
|
|
9715
|
+
presence,
|
|
9716
|
+
home: o.home,
|
|
9717
|
+
log: o.log
|
|
9718
|
+
});
|
|
9719
|
+
herdr?.on("log", (line) => o.log(line));
|
|
9720
|
+
herdr?.on("down", () => {
|
|
9721
|
+
hub?.stop();
|
|
9722
|
+
hub = null;
|
|
9723
|
+
streams?.stop();
|
|
9724
|
+
streams = null;
|
|
9725
|
+
});
|
|
9726
|
+
herdr?.on("ready", (socket2, info) => {
|
|
9727
|
+
hub?.stop();
|
|
9728
|
+
streams?.stop();
|
|
9729
|
+
const h = new SessionHub(socket2);
|
|
9730
|
+
hub = h;
|
|
9731
|
+
streams = new TerminalStreams({ ...herdrEnv, HERDR_SOCKET_PATH: socket2, HERDR_SESSION });
|
|
9732
|
+
const own = streams;
|
|
9733
|
+
const connect = (tries) => {
|
|
9734
|
+
h.start().then(
|
|
9735
|
+
async () => {
|
|
9736
|
+
if (!info.restored || hub !== h) return;
|
|
9737
|
+
const snap = await h.snapshot().catch(() => null);
|
|
9738
|
+
const quiet = { send: () => {
|
|
9739
|
+
}, sendJson: () => {
|
|
9740
|
+
}, close: () => {
|
|
9741
|
+
} };
|
|
9742
|
+
const held = (snap?.panes ?? []).map((p) => own.attach(p.pane_id, quiet, HEADLESS.cols, HEADLESS.rows));
|
|
9743
|
+
if (held.length) o.log(`herdr: ${held.length} restored pane(s) attached headless at ${HEADLESS.cols}\xD7${HEADLESS.rows}`);
|
|
9744
|
+
setTimeout(() => held.forEach((a) => a.detach()), HEADLESS.ms).unref();
|
|
9745
|
+
},
|
|
9746
|
+
() => {
|
|
9747
|
+
if (hub === h && tries > 0) setTimeout(() => connect(tries - 1), 1e3).unref();
|
|
9748
|
+
}
|
|
9749
|
+
);
|
|
9750
|
+
};
|
|
9751
|
+
connect(10);
|
|
9752
|
+
});
|
|
9753
|
+
const sayPolicy = (l) => {
|
|
9754
|
+
if (l.problem) o.log(`policy: ${l.problem}; everything is denied until it is fixed`);
|
|
9755
|
+
else o.log(`policy: terminals ${l.policy.terminals ? "on" : "off"}, ${l.policy.files.length} folder(s), ports ${l.policy.ports ? "on" : "off"}${l.policy.paused ? ", paused" : ""}`);
|
|
9756
|
+
};
|
|
9757
|
+
sayPolicy(policy);
|
|
9758
|
+
const link = new NodeLink({
|
|
9759
|
+
box: creds.box,
|
|
9760
|
+
token: creds.token,
|
|
9761
|
+
machineId: creds.machineId,
|
|
9762
|
+
version: o.version,
|
|
9763
|
+
describe: async () => {
|
|
9764
|
+
const summary = summarize(policy);
|
|
9765
|
+
return { ...await gatherSpecs({ binDir: o.paths.bin, policy: summary }), policy: summary };
|
|
9766
|
+
},
|
|
9767
|
+
...o.link ?? {}
|
|
9768
|
+
});
|
|
9769
|
+
link.on("log", (line) => o.log(line));
|
|
9770
|
+
link.on("stream", (stream, open3) => server.accept(stream, open3));
|
|
9771
|
+
link.on("control", (m) => {
|
|
9772
|
+
if (m.type !== "claude-token") return;
|
|
9773
|
+
const token = typeof m.token === "string" ? m.token : null;
|
|
9774
|
+
holdToken(claude, token);
|
|
9775
|
+
o.log(token ? "the box sent the shared Claude sign-in; new Claude Code sessions here use it" : "the box withdrew the shared Claude sign-in from this machine");
|
|
9776
|
+
});
|
|
9777
|
+
link.on("state", (state) => o.log(`link: ${state}`));
|
|
9778
|
+
if (policy.policy.paused) link.pause();
|
|
9779
|
+
const stopWatching = watchPolicy(o.paths.policy, o.home, (l) => {
|
|
9780
|
+
policy = l;
|
|
9781
|
+
sayPolicy(l);
|
|
9782
|
+
const closed = server.enforce();
|
|
9783
|
+
if (closed) o.log(`policy: closed ${closed} channel(s) it no longer allows`);
|
|
9784
|
+
if (l.policy.paused && !link.isPaused) link.pause();
|
|
9785
|
+
else if (!l.policy.paused && link.isPaused) link.resume();
|
|
9786
|
+
else link.sendPolicy(summarize(l));
|
|
9787
|
+
});
|
|
9788
|
+
let finish = () => {
|
|
9789
|
+
};
|
|
9790
|
+
const done = new Promise((r) => finish = r);
|
|
9791
|
+
const control2 = await startControl(o.paths.control, o.paths.controlSecret, async (command2) => {
|
|
9792
|
+
switch (command2) {
|
|
9793
|
+
case "status":
|
|
9794
|
+
return {
|
|
9795
|
+
state: link.state,
|
|
9796
|
+
box: creds.box,
|
|
9797
|
+
machineId: creds.machineId,
|
|
9798
|
+
name: creds.name,
|
|
9799
|
+
paused: link.isPaused,
|
|
9800
|
+
policyProblem: policy.problem,
|
|
9801
|
+
pid: process.pid,
|
|
9802
|
+
herdrSocket: herdr?.socketPath ?? null,
|
|
9803
|
+
presenceLocked: presence.locked(),
|
|
9804
|
+
channels: server.openChannels
|
|
9805
|
+
};
|
|
9806
|
+
case "pause":
|
|
9807
|
+
setPaused(o.paths.policy, true);
|
|
9808
|
+
link.pause();
|
|
9809
|
+
return { state: link.state };
|
|
9810
|
+
case "resume":
|
|
9811
|
+
setPaused(o.paths.policy, false);
|
|
9812
|
+
link.resume();
|
|
9813
|
+
return { state: link.state };
|
|
9814
|
+
case "leave": {
|
|
9815
|
+
const told = await link.leave();
|
|
9816
|
+
setImmediate(() => finish(EXIT.OK));
|
|
9817
|
+
return { told };
|
|
9818
|
+
}
|
|
9819
|
+
}
|
|
9820
|
+
});
|
|
9821
|
+
link.on("revoked", () => {
|
|
9822
|
+
fs19.rmSync(o.paths.secrets, { recursive: true, force: true });
|
|
9823
|
+
writeCredentials(o.paths.credentials, { ...creds, revokedAt: now() });
|
|
9824
|
+
o.log(`${creds.box} revoked this machine. Its shared secrets are wiped; everything else is kept. Re-join it to use it again.`);
|
|
9825
|
+
finish(EXIT.OK);
|
|
9826
|
+
});
|
|
9827
|
+
link.on("state", (state) => {
|
|
9828
|
+
if (state === "left") finish(EXIT.OK);
|
|
9829
|
+
});
|
|
9830
|
+
o.signal.addEventListener("abort", () => finish(EXIT.OK), { once: true });
|
|
9831
|
+
let checking = false;
|
|
9832
|
+
const checkUpdates = async () => {
|
|
9833
|
+
if (checking || o.checkUpdates === false) return;
|
|
9834
|
+
checking = true;
|
|
9835
|
+
try {
|
|
9836
|
+
const herdrPath = herdrBin;
|
|
9837
|
+
const report = await checkForUpdate({
|
|
9838
|
+
policy: policy.policy.updates,
|
|
9839
|
+
running: o.version,
|
|
9840
|
+
// Only the standalone executable replaces itself; agentbox.mjs under Node does not.
|
|
9841
|
+
nodePath: execStart().length === 3 ? process.execPath : null,
|
|
9842
|
+
...fs19.existsSync(herdrPath) ? { herdr: { path: herdrPath, version: HERDR_VERSION } } : {},
|
|
9843
|
+
...installedClaude(claude) ? { claude: { path: claude.bin, version: installedClaude(claude), versionFile: claude.versionFile } } : {},
|
|
9844
|
+
// Agents live in herdr: herdr and Claude Code are replaced only while none runs.
|
|
9845
|
+
idle: () => herdr ? herdr.idle() : true
|
|
9846
|
+
});
|
|
9847
|
+
o.log(`update: ${describeReport(report)}`);
|
|
9848
|
+
if ((report.state === "current" || report.state === "updated") && report.tools.herdr === "updated") void herdr?.restart("updated");
|
|
9849
|
+
if (report.state === "updated" && report.restart) finish(RESTART_EXIT);
|
|
9850
|
+
} catch (err) {
|
|
9851
|
+
o.log(`update check failed: ${err.message}`);
|
|
9852
|
+
} finally {
|
|
9853
|
+
checking = false;
|
|
9854
|
+
}
|
|
9855
|
+
};
|
|
9856
|
+
link.on("upgrade", () => void checkUpdates());
|
|
9857
|
+
const firstCheck = setTimeout(() => void checkUpdates(), 10 * 6e4);
|
|
9858
|
+
firstCheck.unref();
|
|
9859
|
+
const daily = setInterval(() => void checkUpdates(), 24 * 60 * 6e4);
|
|
9860
|
+
daily.unref();
|
|
9861
|
+
const enforcer = setInterval(() => server.enforce(), o.enforceMs ?? 5e3);
|
|
9862
|
+
enforcer.unref();
|
|
9863
|
+
if (herdr) {
|
|
9864
|
+
herdr.start().catch((err) => {
|
|
9865
|
+
o.log(`herdr did not start: ${err.message}; trying again`);
|
|
9866
|
+
void herdr.restart("it did not start");
|
|
9867
|
+
});
|
|
9868
|
+
}
|
|
9869
|
+
link.start();
|
|
9870
|
+
const code2 = await done;
|
|
9871
|
+
clearInterval(enforcer);
|
|
9872
|
+
server.close();
|
|
9873
|
+
herdr?.stop();
|
|
9874
|
+
hub?.stop();
|
|
9875
|
+
streams?.stop();
|
|
9876
|
+
clearTimeout(firstCheck);
|
|
9877
|
+
clearInterval(daily);
|
|
9878
|
+
link.stop(link.state === "revoked" ? "revoked" : "stopped");
|
|
9879
|
+
stopWatching();
|
|
9880
|
+
control2.close();
|
|
9881
|
+
return code2;
|
|
9882
|
+
}
|
|
9883
|
+
|
|
9884
|
+
// src/commands/node.ts
|
|
9885
|
+
function paths(ctx) {
|
|
9886
|
+
return nodePaths(nodeDir(ctx.env, ctx.env.HOME || os11.homedir(), ctx.platform));
|
|
9887
|
+
}
|
|
9888
|
+
function home(ctx) {
|
|
9889
|
+
return ctx.env.HOME || os11.homedir();
|
|
9890
|
+
}
|
|
9891
|
+
function parseFolders(raw, homeDir2) {
|
|
9892
|
+
const out = [];
|
|
9893
|
+
for (const part of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
9894
|
+
const m = /^(.*?)(?::(read|write))?$/.exec(part);
|
|
9895
|
+
let p = m[1];
|
|
9896
|
+
if (p === "~") p = homeDir2;
|
|
9897
|
+
else if (p.startsWith("~/")) p = path18.join(homeDir2, p.slice(2));
|
|
9898
|
+
p = path18.resolve(p);
|
|
9899
|
+
if (p === path18.resolve(homeDir2)) throw new UsageError("not the whole home folder: name the project folders the box may use");
|
|
9900
|
+
out.push({ path: p, mode: m[2] ?? "write" });
|
|
9901
|
+
}
|
|
9902
|
+
return out;
|
|
9903
|
+
}
|
|
9904
|
+
function printPolicy(ctx, p) {
|
|
9905
|
+
try {
|
|
9906
|
+
ctx.err(`${fs20.readFileSync(p.policy, "utf8")}`);
|
|
9907
|
+
} catch {
|
|
9908
|
+
ctx.err("(no policy.json)\n");
|
|
9909
|
+
}
|
|
9910
|
+
}
|
|
9911
|
+
var join = command({
|
|
9912
|
+
path: ["node", "join"],
|
|
9913
|
+
summary: "join this computer to a box as a machine",
|
|
9914
|
+
usage: "<box-url>",
|
|
9915
|
+
operands: { min: 1, max: 1 },
|
|
9916
|
+
details: "Installs what the node runs (verified), asks for the machines passphrase here, shows a code to type\non the box's machines Admin page, writes policy.json if there is none, and starts the node as a\nsystemd user service.",
|
|
9917
|
+
options: [
|
|
9918
|
+
{ name: "name", type: "string", value: "name", description: "what the box calls this machine (default: the hostname)" },
|
|
9919
|
+
{ name: "folder", type: "string", value: "dirs", description: "folders the box may use, comma-separated, each dir or dir:read (asked when absent)" },
|
|
9920
|
+
{ name: "no-service", type: "boolean", description: "do not register a service; run `agentbox node run` yourself" },
|
|
9921
|
+
{ name: "no-claude", type: "boolean", description: "do not install Claude Code (about 280 MiB); agents can be started once it is there" }
|
|
9922
|
+
],
|
|
9923
|
+
async run(ctx, p) {
|
|
9924
|
+
const box = normalizeBoxUrl(p.operands[0]);
|
|
9925
|
+
const np = paths(ctx);
|
|
9926
|
+
const prompt = new Prompter(ctx.io.stdin, ctx.io.stderr);
|
|
9927
|
+
const folderArg = str(p.options, "folder");
|
|
9928
|
+
const noService = bool(p.options, "no-service") || ctx.platform !== "linux";
|
|
9929
|
+
try {
|
|
9930
|
+
await joinMachine(
|
|
9931
|
+
{ box, name: str(p.options, "name") ?? os11.hostname(), folders: folderArg !== void 0 ? parseFolders(folderArg, home(ctx)) : null },
|
|
9932
|
+
{
|
|
9933
|
+
paths: np,
|
|
9934
|
+
platform: ctx.platform,
|
|
9935
|
+
arch: process.arch,
|
|
9936
|
+
hostname: os11.hostname(),
|
|
9937
|
+
now: ctx.now,
|
|
9938
|
+
err: (s) => ctx.err(s),
|
|
9939
|
+
client: ctx.client(box, null),
|
|
9940
|
+
freeBytes,
|
|
9941
|
+
install: async () => {
|
|
9942
|
+
await installHerdr(np.bin, { platform: ctx.platform, arch: process.arch, from: ctx.env.AGENTBOX_NODE_HERDR_FROM || void 0, log: (s) => ctx.err(s) });
|
|
9943
|
+
if (!bool(p.options, "no-claude")) await installClaude(claudePaths(np), { platform: ctx.platform, arch: process.arch, log: (s) => ctx.err(s) });
|
|
9944
|
+
},
|
|
9945
|
+
passphrase: () => askPassphrase(prompt, (s) => ctx.err(s)),
|
|
9946
|
+
enrol: (pass) => enrol(pass, ctx.now()),
|
|
9947
|
+
sleep: (ms) => sleep(ms, ctx.abort.signal),
|
|
9948
|
+
askFolders: async () => {
|
|
9949
|
+
ctx.err("Which project folders may the box use? (comma-separated; add :read for read-only; never your whole home)\n");
|
|
9950
|
+
const answer = await prompt.line("Folders: ");
|
|
9951
|
+
return answer ? parseFolders(answer, home(ctx)) : [];
|
|
9952
|
+
},
|
|
9953
|
+
service: noService ? null : async () => {
|
|
9954
|
+
try {
|
|
9955
|
+
const r = await installService({ command: execStart(), env: ctx.env });
|
|
9956
|
+
ctx.err(`Registered ${UNIT_NAME} (${r.unitFile}).
|
|
9957
|
+
`);
|
|
9958
|
+
if (r.note) ctx.err(`${r.note}
|
|
9959
|
+
`);
|
|
9960
|
+
} catch (err) {
|
|
9961
|
+
throw new CliError(`${err.message}
|
|
9962
|
+
The machine is joined; start it with \`agentbox node run\`.`);
|
|
9963
|
+
}
|
|
9964
|
+
},
|
|
9965
|
+
waitOnline: async () => {
|
|
9966
|
+
const deadline = Date.now() + 6e4;
|
|
9967
|
+
while (Date.now() < deadline) {
|
|
9968
|
+
const s = await callControl(np.control, np.controlSecret, "status", 2e3);
|
|
9969
|
+
if (s?.state === "online") return true;
|
|
9970
|
+
await sleep(1e3, ctx.abort.signal);
|
|
9971
|
+
}
|
|
9972
|
+
return false;
|
|
9973
|
+
},
|
|
9974
|
+
printPolicy: () => printPolicy(ctx, np)
|
|
9975
|
+
}
|
|
9976
|
+
);
|
|
9977
|
+
} finally {
|
|
9978
|
+
prompt.close();
|
|
9979
|
+
}
|
|
9980
|
+
}
|
|
9981
|
+
});
|
|
9982
|
+
var run2 = command({
|
|
9983
|
+
path: ["node", "run"],
|
|
9984
|
+
summary: "run the node in the foreground (the service runs this)",
|
|
9985
|
+
usage: "",
|
|
9986
|
+
ownsInterrupt: true,
|
|
9987
|
+
async run(ctx) {
|
|
9988
|
+
const np = paths(ctx);
|
|
9989
|
+
const log = (line) => {
|
|
9990
|
+
ctx.err(`${new Date(ctx.now()).toISOString()} ${line}
|
|
9991
|
+
`);
|
|
9992
|
+
};
|
|
9993
|
+
return runNode({ paths: np, home: home(ctx), version: VERSION, log, signal: ctx.abort.signal });
|
|
9994
|
+
}
|
|
9995
|
+
});
|
|
9996
|
+
var status2 = command({
|
|
9997
|
+
path: ["node", "status"],
|
|
9998
|
+
summary: "whether this machine is joined, linked and paused",
|
|
9999
|
+
usage: "",
|
|
10000
|
+
json: true,
|
|
10001
|
+
async run(ctx) {
|
|
10002
|
+
const np = paths(ctx);
|
|
10003
|
+
const creds = readCredentials(np.credentials);
|
|
10004
|
+
const live = await callControl(np.control, np.controlSecret, "status", 3e3);
|
|
10005
|
+
const policy = loadPolicy(np.policy, home(ctx));
|
|
10006
|
+
const out = {
|
|
10007
|
+
joined: creds !== null,
|
|
10008
|
+
box: creds?.box ?? null,
|
|
10009
|
+
machineId: creds?.machineId ?? null,
|
|
10010
|
+
name: creds?.name ?? null,
|
|
10011
|
+
revoked: creds?.revokedAt !== void 0 && creds !== null,
|
|
10012
|
+
running: live !== null,
|
|
10013
|
+
state: live ? live.state : creds ? creds.revokedAt !== void 0 ? "revoked" : "not running" : "not joined",
|
|
10014
|
+
paused: policy.policy.paused,
|
|
10015
|
+
policyProblem: policy.problem,
|
|
10016
|
+
folder: np.dir,
|
|
10017
|
+
service: ctx.platform === "linux" ? serviceInstalled(ctx.env) : false,
|
|
10018
|
+
herdrSocket: typeof live?.herdrSocket === "string" ? live.herdrSocket : null,
|
|
10019
|
+
presenceLocked: typeof live?.presenceLocked === "number" ? live.presenceLocked : null
|
|
10020
|
+
};
|
|
10021
|
+
if (ctx.json) return ctx.printJson(out);
|
|
10022
|
+
if (!creds) {
|
|
10023
|
+
ctx.out(`Not joined to a box. Run \`agentbox node join <box>\`.
|
|
10024
|
+
`);
|
|
10025
|
+
return EXIT.NOT_FOUND;
|
|
10026
|
+
}
|
|
10027
|
+
ctx.out(`${safeText(out.name ?? "")} (machine ${safeText(out.machineId ?? "")}) at ${safeText(out.box ?? "")}
|
|
10028
|
+
`);
|
|
10029
|
+
ctx.out(`state: ${safeText(String(out.state))}${out.paused ? " (paused: `agentbox node resume`)" : ""}
|
|
10030
|
+
`);
|
|
10031
|
+
if (out.policyProblem) ctx.out(`policy: ${safeText(out.policyProblem)} \u2014 everything is denied
|
|
10032
|
+
`);
|
|
10033
|
+
ctx.out(`folder: ${np.dir}
|
|
10034
|
+
service: ${out.service ? UNIT_NAME : "none"}
|
|
10035
|
+
`);
|
|
10036
|
+
if (out.herdrSocket) ctx.out(`herdr: HERDR_SOCKET_PATH=${out.herdrSocket} herdr (the agents the box sees)
|
|
10037
|
+
`);
|
|
10038
|
+
if (out.presenceLocked) ctx.out(`presence: locked until ${new Date(out.presenceLocked).toISOString()} after wrong passphrases
|
|
10039
|
+
`);
|
|
10040
|
+
}
|
|
10041
|
+
});
|
|
10042
|
+
async function setPause(ctx, paused) {
|
|
10043
|
+
const np = paths(ctx);
|
|
10044
|
+
if (!readCredentials(np.credentials)) throw new CliError("this machine has not joined a box", EXIT.NOT_FOUND);
|
|
10045
|
+
const live = await callControl(np.control, np.controlSecret, paused ? "pause" : "resume");
|
|
10046
|
+
if (!live) setPaused(np.policy, paused);
|
|
10047
|
+
else if (live.ok !== true) throw new CliError(String(live.error ?? "the node refused"));
|
|
10048
|
+
ctx.err(paused ? "Paused: the link is down until `agentbox node resume`.\n" : "Resumed.\n");
|
|
10049
|
+
}
|
|
10050
|
+
var pause = command({ path: ["node", "pause"], summary: "take the link down until resume", usage: "", run: (ctx) => setPause(ctx, true) });
|
|
10051
|
+
var resume = command({ path: ["node", "resume"], summary: "bring the link back up", usage: "", run: (ctx) => setPause(ctx, false) });
|
|
10052
|
+
var leave = command({
|
|
10053
|
+
path: ["node", "leave"],
|
|
10054
|
+
summary: "leave the box: tell it if it can be reached, and forget this machine's token",
|
|
10055
|
+
usage: "",
|
|
10056
|
+
async run(ctx) {
|
|
10057
|
+
const np = paths(ctx);
|
|
10058
|
+
const creds = readCredentials(np.credentials);
|
|
10059
|
+
if (!creds) throw new CliError("this machine has not joined a box", EXIT.NOT_FOUND);
|
|
10060
|
+
let told = false;
|
|
10061
|
+
const live = await callControl(np.control, np.controlSecret, "leave", 1e4);
|
|
10062
|
+
if (live) told = live.told === true;
|
|
10063
|
+
else if (creds.revokedAt === void 0) {
|
|
10064
|
+
const link = new NodeLink({
|
|
10065
|
+
box: creds.box,
|
|
10066
|
+
token: creds.token,
|
|
10067
|
+
machineId: creds.machineId,
|
|
10068
|
+
version: VERSION,
|
|
10069
|
+
describe: async () => {
|
|
10070
|
+
const policy = summarize(loadPolicy(np.policy, home(ctx)));
|
|
10071
|
+
return { ...await gatherSpecs({ binDir: np.bin, policy }), policy };
|
|
10072
|
+
}
|
|
10073
|
+
});
|
|
10074
|
+
link.start();
|
|
10075
|
+
const deadline = Date.now() + 1e4;
|
|
10076
|
+
while (link.state !== "online" && Date.now() < deadline) await sleep(200);
|
|
10077
|
+
told = await link.leave();
|
|
10078
|
+
link.stop();
|
|
10079
|
+
}
|
|
10080
|
+
if (ctx.platform === "linux") await removeService({ env: ctx.env });
|
|
10081
|
+
fs20.rmSync(np.secrets, { recursive: true, force: true });
|
|
10082
|
+
fs20.rmSync(np.credentials, { force: true });
|
|
10083
|
+
fs20.rmSync(np.controlSecret, { force: true });
|
|
10084
|
+
ctx.err(told ? `Left ${creds.box}.
|
|
10085
|
+
` : `Left here; ${creds.box} could not be told, so it lists this machine until the owner removes it.
|
|
10086
|
+
`);
|
|
10087
|
+
ctx.err(`Kept ${np.policy} and ${np.bin}.
|
|
10088
|
+
`);
|
|
10089
|
+
}
|
|
10090
|
+
});
|
|
10091
|
+
var logs = command({
|
|
10092
|
+
path: ["node", "logs"],
|
|
10093
|
+
summary: "the node's log (from the journal)",
|
|
10094
|
+
usage: "",
|
|
10095
|
+
ownsInterrupt: true,
|
|
10096
|
+
options: [{ name: "follow", short: "f", type: "boolean", description: "keep printing new lines" }],
|
|
10097
|
+
async run(ctx, p) {
|
|
10098
|
+
const args = ["--user", "-u", UNIT_NAME, "-n", "200", "--no-pager", ...bool(p.options, "follow") ? ["-f"] : []];
|
|
10099
|
+
return new Promise((resolve) => {
|
|
10100
|
+
const child = spawn7("journalctl", args, { stdio: "inherit" });
|
|
10101
|
+
child.on("error", () => {
|
|
10102
|
+
ctx.err("journalctl is not available here; run `agentbox node run` in a terminal to see the log.\n");
|
|
10103
|
+
resolve(EXIT.FAILURE);
|
|
10104
|
+
});
|
|
10105
|
+
child.on("exit", (code2) => resolve(code2 ?? EXIT.OK));
|
|
10106
|
+
ctx.abort.signal.addEventListener("abort", () => child.kill("SIGINT"), { once: true });
|
|
10107
|
+
});
|
|
10108
|
+
}
|
|
10109
|
+
});
|
|
10110
|
+
var passphrase = command({
|
|
10111
|
+
path: ["node", "passphrase"],
|
|
10112
|
+
summary: "set this machine's copy of the machines passphrase (after it changed on the box)",
|
|
10113
|
+
usage: "",
|
|
10114
|
+
async run(ctx) {
|
|
10115
|
+
const np = paths(ctx);
|
|
10116
|
+
const creds = readCredentials(np.credentials);
|
|
10117
|
+
if (!creds) throw new CliError("this machine has not joined a box", EXIT.NOT_FOUND);
|
|
10118
|
+
const prompt = new Prompter(ctx.io.stdin, ctx.io.stderr);
|
|
10119
|
+
try {
|
|
10120
|
+
const presence = await enrol(await askPassphrase(prompt, (s) => ctx.err(s)), ctx.now());
|
|
10121
|
+
writeCredentials(np.credentials, { ...creds, presence });
|
|
10122
|
+
} finally {
|
|
10123
|
+
prompt.close();
|
|
10124
|
+
}
|
|
10125
|
+
ctx.err("This machine now proves presence with the new passphrase.\n");
|
|
10126
|
+
}
|
|
10127
|
+
});
|
|
10128
|
+
var NODE_COMMANDS = [join, run2, status2, pause, resume, leave, logs, passphrase];
|
|
10129
|
+
|
|
6809
10130
|
// src/commands/mount.ts
|
|
6810
|
-
import
|
|
6811
|
-
import
|
|
6812
|
-
import
|
|
10131
|
+
import fs21 from "node:fs";
|
|
10132
|
+
import os12 from "node:os";
|
|
10133
|
+
import path19 from "node:path";
|
|
6813
10134
|
|
|
6814
10135
|
// src/dav.ts
|
|
6815
|
-
import { randomBytes as
|
|
6816
|
-
import
|
|
10136
|
+
import { randomBytes as randomBytes8, timingSafeEqual as timingSafeEqual4 } from "node:crypto";
|
|
10137
|
+
import http2 from "node:http";
|
|
6817
10138
|
var REMOTE_PREFIX = "/api/dav";
|
|
6818
10139
|
var BadPath = class extends Error {
|
|
6819
10140
|
};
|
|
@@ -6859,7 +10180,7 @@ function underPrefix(p, prefix) {
|
|
|
6859
10180
|
if (p.length < prefix.length) return false;
|
|
6860
10181
|
const head = Buffer.from(p.slice(0, prefix.length));
|
|
6861
10182
|
const want = Buffer.from(prefix);
|
|
6862
|
-
if (head.length !== want.length || !
|
|
10183
|
+
if (head.length !== want.length || !timingSafeEqual4(head, want)) return false;
|
|
6863
10184
|
return p.length === prefix.length || p[prefix.length] === "/";
|
|
6864
10185
|
}
|
|
6865
10186
|
var LOOPBACK_HOSTS = ["127.0.0.1", "localhost", "[::1]"];
|
|
@@ -6921,20 +10242,20 @@ var DROP_REQUEST = /* @__PURE__ */ new Set(["host", "authorization", "proxy-auth
|
|
|
6921
10242
|
var DROP_RESPONSE = /* @__PURE__ */ new Set(["set-cookie", "www-authenticate", "strict-transport-security", "alt-svc"]);
|
|
6922
10243
|
var MAX_REWRITE = 64 * 1024 * 1024;
|
|
6923
10244
|
function newCredentials() {
|
|
6924
|
-
return { user: "agentbox", password:
|
|
10245
|
+
return { user: "agentbox", password: randomBytes8(24).toString("base64url") };
|
|
6925
10246
|
}
|
|
6926
10247
|
function basicMatches(header, want) {
|
|
6927
10248
|
const m = /^Basic\s+([A-Za-z0-9+/=]+)\s*$/i.exec(header ?? "");
|
|
6928
10249
|
if (!m) return false;
|
|
6929
10250
|
const got = Buffer.from(m[1], "base64");
|
|
6930
10251
|
const expected = Buffer.from(`${want.user}:${want.password}`, "utf8");
|
|
6931
|
-
return got.length === expected.length &&
|
|
10252
|
+
return got.length === expected.length && timingSafeEqual4(got, expected);
|
|
6932
10253
|
}
|
|
6933
10254
|
var DavFront = class {
|
|
6934
10255
|
constructor(client, opts = {}) {
|
|
6935
10256
|
this.client = client;
|
|
6936
10257
|
this.opts = opts;
|
|
6937
|
-
this.secret = opts.secret ??
|
|
10258
|
+
this.secret = opts.secret ?? randomBytes8(16).toString("base64url");
|
|
6938
10259
|
this.credentials = opts.credentials === void 0 ? newCredentials() : opts.credentials;
|
|
6939
10260
|
}
|
|
6940
10261
|
client;
|
|
@@ -6948,7 +10269,7 @@ var DavFront = class {
|
|
|
6948
10269
|
credentials;
|
|
6949
10270
|
/** Start listening; resolves with the URL to give a WebDAV client. */
|
|
6950
10271
|
async start() {
|
|
6951
|
-
const server =
|
|
10272
|
+
const server = http2.createServer((req, res) => {
|
|
6952
10273
|
this.handle(req, res).catch((err) => {
|
|
6953
10274
|
this.opts.onError?.(err.message);
|
|
6954
10275
|
if (!res.headersSent) {
|
|
@@ -6991,8 +10312,8 @@ var DavFront = class {
|
|
|
6991
10312
|
}
|
|
6992
10313
|
async handle(req, res) {
|
|
6993
10314
|
const m = this.map;
|
|
6994
|
-
const plain = (
|
|
6995
|
-
res.writeHead(
|
|
10315
|
+
const plain = (status3, text) => {
|
|
10316
|
+
res.writeHead(status3, { "content-type": "text/plain; charset=utf-8", "content-length": String(Buffer.byteLength(text) + 1) });
|
|
6996
10317
|
res.end(`${text}
|
|
6997
10318
|
`);
|
|
6998
10319
|
};
|
|
@@ -7071,7 +10392,7 @@ var DavFront = class {
|
|
|
7071
10392
|
};
|
|
7072
10393
|
|
|
7073
10394
|
// src/http.ts
|
|
7074
|
-
import
|
|
10395
|
+
import http3 from "node:http";
|
|
7075
10396
|
import https from "node:https";
|
|
7076
10397
|
import { Readable } from "node:stream";
|
|
7077
10398
|
var USER_AGENT = `agentbox-cli/${VERSION} (node ${process.versions.node}; ${process.platform})`;
|
|
@@ -7117,7 +10438,7 @@ function encodeQuery(query, rawQuery) {
|
|
|
7117
10438
|
if (rawQuery) parts.push(rawQuery);
|
|
7118
10439
|
return parts.length ? `?${parts.join("&")}` : "";
|
|
7119
10440
|
}
|
|
7120
|
-
function apiErrorFrom(
|
|
10441
|
+
function apiErrorFrom(status3, text, what) {
|
|
7121
10442
|
let message = "";
|
|
7122
10443
|
let code2 = null;
|
|
7123
10444
|
try {
|
|
@@ -7130,20 +10451,20 @@ function apiErrorFrom(status2, text, what) {
|
|
|
7130
10451
|
message = text.trim().split("\n")[0] ?? "";
|
|
7131
10452
|
}
|
|
7132
10453
|
message = safeText(message.slice(0, 300));
|
|
7133
|
-
if (
|
|
10454
|
+
if (status3 === 401) {
|
|
7134
10455
|
return new ApiError(401, "the box did not accept this device's sign-in (the token was revoked or has expired); run `agentbox login` again", code2);
|
|
7135
10456
|
}
|
|
7136
|
-
if (
|
|
10457
|
+
if (status3 === 502 || status3 === 503 || status3 === 504) {
|
|
7137
10458
|
message = message || "the box is up, but the service behind it is not answering";
|
|
7138
10459
|
}
|
|
7139
|
-
return new ApiError(
|
|
10460
|
+
return new ApiError(status3, `${what}: ${message || `HTTP ${status3}`}`, code2);
|
|
7140
10461
|
}
|
|
7141
10462
|
var BoxClient = class {
|
|
7142
10463
|
constructor(origin, token) {
|
|
7143
10464
|
this.token = token;
|
|
7144
10465
|
this.url = new URL(origin);
|
|
7145
10466
|
this.origin = this.url.origin;
|
|
7146
|
-
const Agent = this.url.protocol === "https:" ? https.Agent :
|
|
10467
|
+
const Agent = this.url.protocol === "https:" ? https.Agent : http3.Agent;
|
|
7147
10468
|
this.agent = new Agent({ keepAlive: true, maxSockets: 8 });
|
|
7148
10469
|
}
|
|
7149
10470
|
token;
|
|
@@ -7166,7 +10487,7 @@ var BoxClient = class {
|
|
|
7166
10487
|
get hasToken() {
|
|
7167
10488
|
return this.token !== null;
|
|
7168
10489
|
}
|
|
7169
|
-
request(method,
|
|
10490
|
+
request(method, path22, opts = {}) {
|
|
7170
10491
|
const headers = { ...this.baseHeaders(opts.auth !== false), ...opts.headers ?? {} };
|
|
7171
10492
|
let payload = null;
|
|
7172
10493
|
const body = opts.body;
|
|
@@ -7181,8 +10502,8 @@ var BoxClient = class {
|
|
|
7181
10502
|
}
|
|
7182
10503
|
if (Buffer.isBuffer(payload)) headers["content-length"] = String(payload.length);
|
|
7183
10504
|
}
|
|
7184
|
-
const target = `${
|
|
7185
|
-
const lib = this.url.protocol === "https:" ? https :
|
|
10505
|
+
const target = `${path22}${encodeQuery(opts.query, opts.rawQuery)}`;
|
|
10506
|
+
const lib = this.url.protocol === "https:" ? https : http3;
|
|
7186
10507
|
return new Promise((resolve, reject) => {
|
|
7187
10508
|
const req = lib.request(
|
|
7188
10509
|
{
|
|
@@ -7227,21 +10548,21 @@ var BoxClient = class {
|
|
|
7227
10548
|
* A JSON call: the parsed answer on 2xx, else an {@link ApiError} with what
|
|
7228
10549
|
* the box said. `what` names the action for the message ("listing /x").
|
|
7229
10550
|
*/
|
|
7230
|
-
async json(method,
|
|
7231
|
-
const res = await this.request(method,
|
|
10551
|
+
async json(method, path22, opts = {}) {
|
|
10552
|
+
const res = await this.request(method, path22, { ...opts, headers: { accept: "application/json", ...opts.headers ?? {} } });
|
|
7232
10553
|
const text = await res.text();
|
|
7233
|
-
if (res.status < 200 || res.status >= 300) throw apiErrorFrom(res.status, text, opts.what ?? `${method} ${
|
|
10554
|
+
if (res.status < 200 || res.status >= 300) throw apiErrorFrom(res.status, text, opts.what ?? `${method} ${path22}`);
|
|
7234
10555
|
if (res.status === 204 || text === "") return void 0;
|
|
7235
10556
|
try {
|
|
7236
10557
|
return JSON.parse(text);
|
|
7237
10558
|
} catch {
|
|
7238
|
-
throw new CliError(`${opts.what ??
|
|
10559
|
+
throw new CliError(`${opts.what ?? path22}: the box answered with something that is not JSON (is ${this.origin} an agentbox?)`);
|
|
7239
10560
|
}
|
|
7240
10561
|
}
|
|
7241
10562
|
/** A request whose answer must be 2xx; the body is left to the caller. */
|
|
7242
|
-
async ok(method,
|
|
7243
|
-
const res = await this.request(method,
|
|
7244
|
-
if (res.status < 200 || res.status >= 300) throw apiErrorFrom(res.status, await res.text(), opts.what ?? `${method} ${
|
|
10563
|
+
async ok(method, path22, opts = {}) {
|
|
10564
|
+
const res = await this.request(method, path22, opts);
|
|
10565
|
+
if (res.status < 200 || res.status >= 300) throw apiErrorFrom(res.status, await res.text(), opts.what ?? `${method} ${path22}`);
|
|
7245
10566
|
return res;
|
|
7246
10567
|
}
|
|
7247
10568
|
close() {
|
|
@@ -7255,11 +10576,11 @@ async function gvfsFolder(root, port, secret, ms) {
|
|
|
7255
10576
|
for (; ; ) {
|
|
7256
10577
|
let names = [];
|
|
7257
10578
|
try {
|
|
7258
|
-
names =
|
|
10579
|
+
names = fs21.readdirSync(root);
|
|
7259
10580
|
} catch {
|
|
7260
10581
|
}
|
|
7261
10582
|
const name = gvfsMountName(names, port, secret);
|
|
7262
|
-
if (name) return
|
|
10583
|
+
if (name) return path19.join(root, name);
|
|
7263
10584
|
if (Date.now() > deadline) return null;
|
|
7264
10585
|
await new Promise((r) => setTimeout(r, 100));
|
|
7265
10586
|
}
|
|
@@ -7319,7 +10640,7 @@ password ${credentials.password}
|
|
|
7319
10640
|
}
|
|
7320
10641
|
let plan;
|
|
7321
10642
|
try {
|
|
7322
|
-
plan = mountPlan({ platform: ctx.platform, url, dir, boxName: name, home:
|
|
10643
|
+
plan = mountPlan({ platform: ctx.platform, url, dir, boxName: name, home: os12.homedir(), env: ctx.env, credentials });
|
|
7323
10644
|
} catch (err) {
|
|
7324
10645
|
throw new CliError(err.message, EXIT.USAGE);
|
|
7325
10646
|
}
|
|
@@ -7328,14 +10649,14 @@ password ${credentials.password}
|
|
|
7328
10649
|
throw new CliError(`${plan.helper} was not found, so the box cannot be mounted here: ${plan.install}. Or serve it with \`agentbox mount --no-mount\` and use any WebDAV client.`);
|
|
7329
10650
|
}
|
|
7330
10651
|
let madeMountpoint = false;
|
|
7331
|
-
if (plan.mountpoint && !
|
|
7332
|
-
|
|
10652
|
+
if (plan.mountpoint && !fs21.existsSync(plan.mountpoint)) {
|
|
10653
|
+
fs21.mkdirSync(plan.mountpoint, { recursive: true });
|
|
7333
10654
|
madeMountpoint = true;
|
|
7334
10655
|
}
|
|
7335
|
-
if (plan.link &&
|
|
10656
|
+
if (plan.link && fs21.existsSync(plan.link)) throw new CliError(`${plan.link} already exists; name a new folder for the link, or leave it out`);
|
|
7336
10657
|
const mounted = await runStep(plan.mount, helper, ctx.env);
|
|
7337
10658
|
if (mounted.code !== 0) {
|
|
7338
|
-
if (madeMountpoint)
|
|
10659
|
+
if (madeMountpoint) fs21.rmSync(plan.mountpoint, { recursive: false, force: true });
|
|
7339
10660
|
throw new CliError(`${plan.helper} could not mount it${mounted.output ? `: ${mounted.output}` : ""}`);
|
|
7340
10661
|
}
|
|
7341
10662
|
let where = plan.where;
|
|
@@ -7357,7 +10678,7 @@ password ${credentials.password}
|
|
|
7357
10678
|
}
|
|
7358
10679
|
let linked = false;
|
|
7359
10680
|
if (plan.link && where) {
|
|
7360
|
-
|
|
10681
|
+
fs21.symlinkSync(where, plan.link, "dir");
|
|
7361
10682
|
linked = true;
|
|
7362
10683
|
}
|
|
7363
10684
|
if (!gioOnly) {
|
|
@@ -7372,10 +10693,10 @@ password ${credentials.password}
|
|
|
7372
10693
|
const done = await runStep(unmount, findOnPath(unmount.command, ctx.env, ctx.platform) ?? unmount.command, ctx.env);
|
|
7373
10694
|
if (done.code !== 0) ctx.warn(`${unmount.command} ${unmount.args.join(" ")} failed${done.output ? `: ${done.output}` : ""}; unmount it yourself`);
|
|
7374
10695
|
}
|
|
7375
|
-
if (linked)
|
|
10696
|
+
if (linked) fs21.rmSync(plan.link, { force: true });
|
|
7376
10697
|
if (madeMountpoint) {
|
|
7377
10698
|
try {
|
|
7378
|
-
|
|
10699
|
+
fs21.rmdirSync(plan.mountpoint);
|
|
7379
10700
|
} catch {
|
|
7380
10701
|
}
|
|
7381
10702
|
}
|
|
@@ -7387,12 +10708,12 @@ password ${credentials.password}
|
|
|
7387
10708
|
var MOUNT_COMMANDS = [mount];
|
|
7388
10709
|
|
|
7389
10710
|
// src/commands/terminal.ts
|
|
7390
|
-
import
|
|
7391
|
-
import
|
|
7392
|
-
import
|
|
10711
|
+
import fs22 from "node:fs";
|
|
10712
|
+
import os13 from "node:os";
|
|
10713
|
+
import path20 from "node:path";
|
|
7393
10714
|
|
|
7394
10715
|
// src/ttyd.ts
|
|
7395
|
-
import { EventEmitter } from "node:events";
|
|
10716
|
+
import { EventEmitter as EventEmitter3 } from "node:events";
|
|
7396
10717
|
var CLIENT = { INPUT: 48, RESIZE: 49, PAUSE: 50, RESUME: 51 };
|
|
7397
10718
|
var SERVER = { OUTPUT: 48, TITLE: 49, PREFERENCES: 50 };
|
|
7398
10719
|
function handshakeFrame(columns, rows, authToken = "") {
|
|
@@ -7427,7 +10748,7 @@ function parseServerFrame(frame) {
|
|
|
7427
10748
|
return { kind: "unknown", command: command2 ?? -1 };
|
|
7428
10749
|
}
|
|
7429
10750
|
}
|
|
7430
|
-
var TtydSession = class extends
|
|
10751
|
+
var TtydSession = class extends EventEmitter3 {
|
|
7431
10752
|
constructor(client, basePath) {
|
|
7432
10753
|
super();
|
|
7433
10754
|
this.client = client;
|
|
@@ -7465,9 +10786,9 @@ var TtydSession = class extends EventEmitter {
|
|
|
7465
10786
|
else if (parsed.kind === "title") this.emit("title", parsed.title);
|
|
7466
10787
|
});
|
|
7467
10788
|
ws.on("unexpected-response", (_req, res) => {
|
|
7468
|
-
const
|
|
10789
|
+
const status3 = res.statusCode ?? 0;
|
|
7469
10790
|
res.resume();
|
|
7470
|
-
const message =
|
|
10791
|
+
const message = status3 === 401 ? "the box did not accept this device's sign-in; run `agentbox login` again" : status3 === 403 ? "the box refused the terminal (origin check); is the box's URL right?" : status3 === 404 ? `this box has no ${this.basePath} terminal` : status3 === 502 || status3 === 503 ? `the ${this.basePath} terminal is not running on the box` : `the box refused the terminal (HTTP ${status3})`;
|
|
7471
10792
|
this.finish({ reason: "error", message });
|
|
7472
10793
|
ws.terminate();
|
|
7473
10794
|
});
|
|
@@ -7865,11 +11186,11 @@ agentbox: ${message}\r
|
|
|
7865
11186
|
|
|
7866
11187
|
// src/commands/terminal.ts
|
|
7867
11188
|
function cdCommand(dir) {
|
|
7868
|
-
const
|
|
11189
|
+
const quote2 = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
7869
11190
|
let target;
|
|
7870
11191
|
if (dir === "~") target = "~";
|
|
7871
|
-
else if (dir.startsWith("~/")) target = `~/${
|
|
7872
|
-
else target =
|
|
11192
|
+
else if (dir.startsWith("~/")) target = `~/${quote2(dir.slice(2))}`;
|
|
11193
|
+
else target = quote2(dir);
|
|
7873
11194
|
return ` cd -- ${target} && clear\r`;
|
|
7874
11195
|
}
|
|
7875
11196
|
async function connectTerminal(ctx, basePath, initialInput) {
|
|
@@ -7921,8 +11242,8 @@ the network: faster, and no redraw glitches. Install it now with herdr's install
|
|
|
7921
11242
|
ctx.warn(`herdr's installer exited ${code2}`);
|
|
7922
11243
|
return null;
|
|
7923
11244
|
}
|
|
7924
|
-
const
|
|
7925
|
-
return findOnPath("herdr", ctx.env, ctx.platform) ?? [
|
|
11245
|
+
const home2 = (win ? ctx.env.USERPROFILE : ctx.env.HOME) || os13.homedir();
|
|
11246
|
+
return findOnPath("herdr", ctx.env, ctx.platform) ?? [path20.join(home2, ".local", "bin", "herdr")].find((f) => fs22.existsSync(f)) ?? null;
|
|
7926
11247
|
}
|
|
7927
11248
|
var attach = command({
|
|
7928
11249
|
path: ["attach"],
|
|
@@ -7972,11 +11293,11 @@ var shell = command({
|
|
|
7972
11293
|
var TERMINAL_COMMANDS = [attach, shell];
|
|
7973
11294
|
|
|
7974
11295
|
// src/commands/update.ts
|
|
7975
|
-
import { spawnSync as
|
|
7976
|
-
import { randomBytes as
|
|
7977
|
-
import
|
|
7978
|
-
import
|
|
7979
|
-
import
|
|
11296
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
11297
|
+
import { randomBytes as randomBytes9 } from "node:crypto";
|
|
11298
|
+
import fs23 from "node:fs";
|
|
11299
|
+
import os14 from "node:os";
|
|
11300
|
+
import path21 from "node:path";
|
|
7980
11301
|
var MAX_BUNDLE = 16 * 1024 * 1024;
|
|
7981
11302
|
function bundleVersion(bundle) {
|
|
7982
11303
|
const head = (typeof bundle === "string" ? bundle : bundle.subarray(0, 512).toString("utf8")).split("\n", 3);
|
|
@@ -7989,17 +11310,17 @@ function selfInstall(argv1) {
|
|
|
7989
11310
|
if (!argv1) return null;
|
|
7990
11311
|
let file;
|
|
7991
11312
|
try {
|
|
7992
|
-
file =
|
|
11313
|
+
file = fs23.realpathSync(argv1);
|
|
7993
11314
|
} catch {
|
|
7994
11315
|
return null;
|
|
7995
11316
|
}
|
|
7996
11317
|
let checkout = false;
|
|
7997
11318
|
let npm = false;
|
|
7998
11319
|
try {
|
|
7999
|
-
const root =
|
|
8000
|
-
const pkg = JSON.parse(
|
|
11320
|
+
const root = path21.join(path21.dirname(file), "..");
|
|
11321
|
+
const pkg = JSON.parse(fs23.readFileSync(path21.join(root, "package.json"), "utf8"));
|
|
8001
11322
|
if (pkg.name === NPM_PACKAGE) {
|
|
8002
|
-
if (
|
|
11323
|
+
if (fs23.existsSync(path21.join(root, "src", "cli.ts"))) checkout = true;
|
|
8003
11324
|
else npm = true;
|
|
8004
11325
|
}
|
|
8005
11326
|
} catch {
|
|
@@ -8013,13 +11334,13 @@ function npmVersionFor(boxVersion) {
|
|
|
8013
11334
|
function nodeCredentials(env, platform = process.platform) {
|
|
8014
11335
|
const candidates = [];
|
|
8015
11336
|
if (platform === "win32") {
|
|
8016
|
-
if (env.LOCALAPPDATA &&
|
|
11337
|
+
if (env.LOCALAPPDATA && path21.win32.isAbsolute(env.LOCALAPPDATA)) candidates.push(path21.join(env.LOCALAPPDATA, "agentbox", "node", "credentials.json"));
|
|
8017
11338
|
} else {
|
|
8018
11339
|
const xdg = env.XDG_DATA_HOME;
|
|
8019
|
-
if (xdg &&
|
|
8020
|
-
candidates.push(
|
|
11340
|
+
if (xdg && path21.isAbsolute(xdg)) candidates.push(path21.join(xdg, "agentbox", "node", "credentials.json"));
|
|
11341
|
+
candidates.push(path21.join(env.HOME || os14.homedir(), ".local", "share", "agentbox", "node", "credentials.json"));
|
|
8021
11342
|
}
|
|
8022
|
-
return candidates.find((f) =>
|
|
11343
|
+
return candidates.find((f) => fs23.existsSync(f)) ?? null;
|
|
8023
11344
|
}
|
|
8024
11345
|
function signedUpdatesOnly(env, platform = process.platform, standalone = STANDALONE) {
|
|
8025
11346
|
const creds = nodeCredentials(env, platform);
|
|
@@ -8087,7 +11408,7 @@ var update = command({
|
|
|
8087
11408
|
}
|
|
8088
11409
|
return;
|
|
8089
11410
|
}
|
|
8090
|
-
const current =
|
|
11411
|
+
const current = fs23.readFileSync(self.file);
|
|
8091
11412
|
if (bundle.equals(current)) {
|
|
8092
11413
|
ctx.out(`Already up to date: agentbox ${VERSION}, the same build ${name} serves.
|
|
8093
11414
|
`);
|
|
@@ -8098,24 +11419,24 @@ var update = command({
|
|
|
8098
11419
|
`);
|
|
8099
11420
|
return;
|
|
8100
11421
|
}
|
|
8101
|
-
const dir =
|
|
8102
|
-
const tmp =
|
|
11422
|
+
const dir = path21.dirname(self.file);
|
|
11423
|
+
const tmp = path21.join(dir, `.agentbox-update-${randomBytes9(4).toString("hex")}.mjs`);
|
|
8103
11424
|
try {
|
|
8104
|
-
|
|
8105
|
-
|
|
11425
|
+
fs23.writeFileSync(tmp, bundle, { mode: 493 });
|
|
11426
|
+
fs23.chmodSync(tmp, 493);
|
|
8106
11427
|
} catch (err) {
|
|
8107
|
-
|
|
11428
|
+
fs23.rmSync(tmp, { force: true });
|
|
8108
11429
|
throw new CliError(`cannot write to ${dir} (${err.message}); reinstall with the box's install script`);
|
|
8109
11430
|
}
|
|
8110
|
-
const check =
|
|
11431
|
+
const check = spawnSync3(process.execPath, [tmp, "--version"], { encoding: "utf8", timeout: 3e4 });
|
|
8111
11432
|
if (check.status !== 0) {
|
|
8112
|
-
|
|
11433
|
+
fs23.rmSync(tmp, { force: true });
|
|
8113
11434
|
throw new CliError(`the downloaded build does not run here: ${(check.stderr || check.stdout || "").trim().slice(0, 300)}`);
|
|
8114
11435
|
}
|
|
8115
11436
|
try {
|
|
8116
|
-
|
|
11437
|
+
fs23.renameSync(tmp, self.file);
|
|
8117
11438
|
} catch (err) {
|
|
8118
|
-
|
|
11439
|
+
fs23.rmSync(tmp, { force: true });
|
|
8119
11440
|
throw new CliError(`cannot replace ${self.file}: ${err.message}`);
|
|
8120
11441
|
}
|
|
8121
11442
|
ctx.out(`Updated agentbox ${VERSION} \u2192 ${safeText(version)} from ${name}.
|
|
@@ -8125,7 +11446,7 @@ var update = command({
|
|
|
8125
11446
|
var UPDATE_COMMANDS = [update];
|
|
8126
11447
|
|
|
8127
11448
|
// src/commands/index.ts
|
|
8128
|
-
var
|
|
11449
|
+
var COMMANDS2 = [
|
|
8129
11450
|
...AUTH_COMMANDS,
|
|
8130
11451
|
...STATUS_COMMANDS,
|
|
8131
11452
|
...TERMINAL_COMMANDS,
|
|
@@ -8136,6 +11457,7 @@ var COMMANDS = [
|
|
|
8136
11457
|
...APPS_COMMANDS,
|
|
8137
11458
|
...AGENT_COMMANDS,
|
|
8138
11459
|
...HERDR_COMMANDS,
|
|
11460
|
+
...NODE_COMMANDS,
|
|
8139
11461
|
...UPDATE_COMMANDS
|
|
8140
11462
|
];
|
|
8141
11463
|
function optionLine(o) {
|
|
@@ -8143,8 +11465,8 @@ function optionLine(o) {
|
|
|
8143
11465
|
return ` ${flag.padEnd(26)} ${o.description}`;
|
|
8144
11466
|
}
|
|
8145
11467
|
function mainHelp() {
|
|
8146
|
-
const width2 = Math.max(...
|
|
8147
|
-
const lines =
|
|
11468
|
+
const width2 = Math.max(...COMMANDS2.filter((c) => !c.hidden).map((c) => c.path.join(" ").length));
|
|
11469
|
+
const lines = COMMANDS2.filter((c) => !c.hidden).map((c) => ` ${c.path.join(" ").padEnd(width2)} ${c.summary}`);
|
|
8148
11470
|
return [
|
|
8149
11471
|
`agentbox ${VERSION} \u2014 your agentbox, from this machine`,
|
|
8150
11472
|
"",
|
|
@@ -8177,7 +11499,7 @@ function commandHelp(c) {
|
|
|
8177
11499
|
].join("\n");
|
|
8178
11500
|
}
|
|
8179
11501
|
function groupHelp(prefix) {
|
|
8180
|
-
const members =
|
|
11502
|
+
const members = COMMANDS2.filter((c) => !c.hidden && prefix.every((w, i) => c.path[i] === w) && c.path.length > prefix.length);
|
|
8181
11503
|
if (members.length === 0) return null;
|
|
8182
11504
|
const width2 = Math.max(...members.map((c) => c.path.join(" ").length));
|
|
8183
11505
|
return [
|
|
@@ -8191,7 +11513,7 @@ function groupHelp(prefix) {
|
|
|
8191
11513
|
}
|
|
8192
11514
|
|
|
8193
11515
|
// src/context.ts
|
|
8194
|
-
import
|
|
11516
|
+
import os15 from "node:os";
|
|
8195
11517
|
var VERSION_CHECK_MS = 24 * 60 * 6e4;
|
|
8196
11518
|
var Context = class {
|
|
8197
11519
|
constructor(io, env, config, globals, platform = process.platform, now = Date.now) {
|
|
@@ -8249,7 +11571,7 @@ var Context = class {
|
|
|
8249
11571
|
}
|
|
8250
11572
|
/** Where temporary files go: `$TMPDIR` (or Windows' `%TEMP%`), else the system's. */
|
|
8251
11573
|
tempRoot() {
|
|
8252
|
-
return this.env.TMPDIR || this.env.TEMP || this.env.TMP ||
|
|
11574
|
+
return this.env.TMPDIR || this.env.TEMP || this.env.TMP || os15.tmpdir();
|
|
8253
11575
|
}
|
|
8254
11576
|
printJson(value) {
|
|
8255
11577
|
this.out(`${JSON.stringify(value, null, 2)}
|
|
@@ -8332,7 +11654,7 @@ function describeLocalError(err) {
|
|
|
8332
11654
|
return { message: e.path ? `${e.path}: ${what}` : `${what} (${e.syscall ?? e.code})`, code: e.code === "ENOENT" ? EXIT.NOT_FOUND : EXIT.FAILURE };
|
|
8333
11655
|
}
|
|
8334
11656
|
var INTERRUPT_GRACE_MS = 1500;
|
|
8335
|
-
async function
|
|
11657
|
+
async function run3(argv, deps = {}) {
|
|
8336
11658
|
const io = deps.io ?? defaultIo();
|
|
8337
11659
|
const env = deps.env ?? process.env;
|
|
8338
11660
|
const fail = (message, code2) => {
|
|
@@ -8342,7 +11664,7 @@ async function run(argv, deps = {}) {
|
|
|
8342
11664
|
};
|
|
8343
11665
|
let parsed;
|
|
8344
11666
|
try {
|
|
8345
|
-
parsed = parseArgs(argv,
|
|
11667
|
+
parsed = parseArgs(argv, COMMANDS2);
|
|
8346
11668
|
} catch (err) {
|
|
8347
11669
|
if (err instanceof UsageError) return fail(err.message, EXIT.USAGE);
|
|
8348
11670
|
throw err;
|
|
@@ -8406,6 +11728,6 @@ if (nodeMajor() < MIN_NODE_MAJOR) {
|
|
|
8406
11728
|
`);
|
|
8407
11729
|
process.exit(1);
|
|
8408
11730
|
}
|
|
8409
|
-
var code = await
|
|
11731
|
+
var code = await run3(process.argv.slice(2), { signals: true });
|
|
8410
11732
|
process.exitCode = code;
|
|
8411
11733
|
process.stdout.write("", () => process.exit(code));
|