@jabezpauls/agentbox 0.1.10 → 0.1.11

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.
Files changed (2) hide show
  1. package/dist/agentbox.mjs +2811 -418
  2. 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.10
2
+ // agentbox-cli v0.1.11
3
3
  import { createRequire as __agentboxCreateRequire } from "node:module";
4
4
  const require = __agentboxCreateRequire(import.meta.url);
5
5
  var __create = Object.create;
@@ -2272,12 +2272,12 @@ 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 EventEmitter4 = __require("events");
2275
+ var EventEmitter5 = __require("events");
2276
2276
  var https2 = __require("https");
2277
2277
  var http4 = __require("http");
2278
- var net5 = __require("net");
2278
+ var net6 = __require("net");
2279
2279
  var tls = __require("tls");
2280
- var { randomBytes: randomBytes10, createHash: createHash5 } = __require("crypto");
2280
+ var { randomBytes: randomBytes13, createHash: createHash6 } = __require("crypto");
2281
2281
  var { Duplex: Duplex3, Readable: Readable2 } = __require("stream");
2282
2282
  var { URL: URL2 } = __require("url");
2283
2283
  var PerMessageDeflate2 = require_permessage_deflate();
@@ -2304,7 +2304,7 @@ var require_websocket = __commonJS({
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 EventEmitter4 {
2307
+ var WebSocket2 = class _WebSocket extends EventEmitter5 {
2308
2308
  /**
2309
2309
  * Create a new `WebSocket`.
2310
2310
  *
@@ -2815,7 +2815,7 @@ var require_websocket = __commonJS({
2815
2815
  }
2816
2816
  }
2817
2817
  const defaultPort = isSecure ? 443 : 80;
2818
- const key = randomBytes10(16).toString("base64");
2818
+ const key = randomBytes13(16).toString("base64");
2819
2819
  const request2 = isSecure ? https2.request : http4.request;
2820
2820
  const protocolSet = /* @__PURE__ */ new Set();
2821
2821
  let perMessageDeflate;
@@ -2945,7 +2945,7 @@ var require_websocket = __commonJS({
2945
2945
  abortHandshake(websocket, socket2, "Invalid Upgrade header");
2946
2946
  return;
2947
2947
  }
2948
- const digest = createHash5("sha1").update(key + GUID).digest("base64");
2948
+ const digest = createHash6("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;
@@ -3019,12 +3019,12 @@ var require_websocket = __commonJS({
3019
3019
  }
3020
3020
  function netConnect(options) {
3021
3021
  options.path = options.socketPath;
3022
- return net5.connect(options);
3022
+ return net6.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 = net5.isIP(options.host) ? "" : options.host;
3027
+ options.servername = net6.isIP(options.host) ? "" : options.host;
3028
3028
  }
3029
3029
  return tls.connect(options);
3030
3030
  }
@@ -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 EventEmitter4 = __require("events");
3314
+ var EventEmitter5 = __require("events");
3315
3315
  var http4 = __require("http");
3316
3316
  var { Duplex: Duplex3 } = __require("stream");
3317
- var { createHash: createHash5 } = __require("crypto");
3317
+ var { createHash: createHash6 } = __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 EventEmitter4 {
3327
+ var WebSocketServer2 = class extends EventEmitter5 {
3328
3328
  /**
3329
3329
  * Create a `WebSocketServer` instance.
3330
3330
  *
@@ -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 = createHash5("sha1").update(key + GUID).digest("base64");
3624
+ const digest = createHash6("sha1").update(key + GUID).digest("base64");
3625
3625
  const headers = [
3626
3626
  "HTTP/1.1 101 Switching Protocols",
3627
3627
  "Upgrade: websocket",
@@ -3860,15 +3860,15 @@ function int(options, name, min, max) {
3860
3860
  if (!Number.isInteger(n) || n < min || n > max) throw new UsageError(`--${name} must be a whole number from ${min} to ${max}`);
3861
3861
  return n;
3862
3862
  }
3863
- function parseSize(raw) {
3864
- const m = /^(\d+(?:\.\d+)?)\s*([kmg]?)(i?b)?$/i.exec(raw.trim());
3865
- if (!m) throw new UsageError(`"${raw}" is not a size (e.g. 8M, 512K)`);
3863
+ function parseSize(raw2) {
3864
+ const m = /^(\d+(?:\.\d+)?)\s*([kmg]?)(i?b)?$/i.exec(raw2.trim());
3865
+ if (!m) throw new UsageError(`"${raw2}" is not a size (e.g. 8M, 512K)`);
3866
3866
  const unit = { "": 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3 }[(m[2] ?? "").toLowerCase()];
3867
3867
  return Math.floor(Number(m[1]) * unit);
3868
3868
  }
3869
3869
 
3870
3870
  // src/version.ts
3871
- var VERSION = true ? "v0.1.10" : "0.0.0-dev";
3871
+ var VERSION = true ? "v0.1.11" : "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) {
@@ -4345,12 +4345,12 @@ function openTunnel(client, target, opts = {}) {
4345
4345
  ws.once("error", (err) => fail(new CliError(`could not open a tunnel to ${client.origin}: ${err.message}`, EXIT.UNREACHABLE)));
4346
4346
  });
4347
4347
  }
4348
- function parseForwardSpec(raw) {
4349
- const m = /^(\d{1,5})(?::(\d{1,5}))?$/.exec(raw);
4348
+ function parseForwardSpec(raw2) {
4349
+ const m = /^(\d{1,5})(?::(\d{1,5}))?$/.exec(raw2);
4350
4350
  const remote = Number(m?.[1]);
4351
4351
  const local = m?.[2] !== void 0 ? Number(m[2]) : remote;
4352
4352
  if (!m || remote < 1 || remote > 65535 || local < 1 || local > 65535) {
4353
- throw new UsageError(`"${raw}" is not a port to forward: give <port> or <remote port>:<local port>`);
4353
+ throw new UsageError(`"${raw2}" is not a port to forward: give <port> or <remote port>:<local port>`);
4354
4354
  }
4355
4355
  return { remote, local };
4356
4356
  }
@@ -4442,10 +4442,10 @@ var forward = command({
4442
4442
  var FORWARD_COMMANDS = [forward];
4443
4443
 
4444
4444
  // src/commands/apps.ts
4445
- function parseExpiry(raw) {
4446
- if (raw === "never") return null;
4447
- const m = /^(\d+)([smhdw])$/.exec(raw.trim());
4448
- if (!m) throw new UsageError(`"${raw}" is not an expiry: give e.g. 30m, 12h, 7d, 2w or never`);
4445
+ function parseExpiry(raw2) {
4446
+ if (raw2 === "never") return null;
4447
+ const m = /^(\d+)([smhdw])$/.exec(raw2.trim());
4448
+ if (!m) throw new UsageError(`"${raw2}" is not an expiry: give e.g. 30m, 12h, 7d, 2w or never`);
4449
4449
  const unit = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 }[m[2]];
4450
4450
  return Number(m[1]) * unit;
4451
4451
  }
@@ -4690,15 +4690,15 @@ function checkBoxName(name) {
4690
4690
  }
4691
4691
  return name;
4692
4692
  }
4693
- function normalizeBoxUrl(raw) {
4694
- let s = raw.trim();
4693
+ function normalizeBoxUrl(raw2) {
4694
+ let s = raw2.trim();
4695
4695
  if (s === "") throw new UsageError("give the box's address, e.g. https://work.example.com");
4696
4696
  if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = `https://${s}`;
4697
4697
  let url;
4698
4698
  try {
4699
4699
  url = new URL(s);
4700
4700
  } catch {
4701
- throw new UsageError(`"${raw}" is not a URL`);
4701
+ throw new UsageError(`"${raw2}" is not a URL`);
4702
4702
  }
4703
4703
  if (url.protocol !== "https:" && url.protocol !== "http:") {
4704
4704
  throw new UsageError(`a box is reached over https (or http on your own machine), not ${url.protocol}`);
@@ -4734,9 +4734,9 @@ var ConfigStore = class {
4734
4734
  lockTimeoutMs;
4735
4735
  /** The configuration as it is on disk now; empty when there is none yet. */
4736
4736
  load() {
4737
- let raw;
4737
+ let raw2;
4738
4738
  try {
4739
- raw = fs2.readFileSync(this.file, "utf8");
4739
+ raw2 = fs2.readFileSync(this.file, "utf8");
4740
4740
  } catch (err) {
4741
4741
  if (err.code === "ENOENT") return emptyConfig();
4742
4742
  throw new CliError(`cannot read ${this.file}: ${err.message}`);
@@ -4744,7 +4744,7 @@ var ConfigStore = class {
4744
4744
  this.tighten();
4745
4745
  let data;
4746
4746
  try {
4747
- data = JSON.parse(raw);
4747
+ data = JSON.parse(raw2);
4748
4748
  } catch {
4749
4749
  throw new CliError(`${this.file} is not valid JSON; fix it or remove it and sign in again`);
4750
4750
  }
@@ -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(path22) {
4994
+ function encodePathParam(path33) {
4995
4995
  let out = "";
4996
- for (const ch of path22) {
4996
+ for (const ch of path33) {
4997
4997
  const cp = ch.codePointAt(0) ?? 0;
4998
4998
  out += cp >= 56448 && cp <= 56575 ? `%${(cp - 56320).toString(16).toUpperCase()}` : encodeURIComponent(ch);
4999
4999
  }
@@ -5605,11 +5605,11 @@ var sshSetup = command({
5605
5605
  `);
5606
5606
  }
5607
5607
  });
5608
- function parseTarget(raw) {
5609
- if (raw === "herdr") return { kind: "herdr" };
5610
- const m = /^tcp:(\d{1,5})$/.exec(raw);
5608
+ function parseTarget(raw2) {
5609
+ if (raw2 === "herdr") return { kind: "herdr" };
5610
+ const m = /^tcp:(\d{1,5})$/.exec(raw2);
5611
5611
  const port = Number(m?.[1]);
5612
- if (!m || port < 1 || port > 65535) throw new UsageError(`"${raw}" is not a tunnel target: give tcp:<port> or herdr`);
5612
+ if (!m || port < 1 || port > 65535) throw new UsageError(`"${raw2}" is not a tunnel target: give tcp:<port> or herdr`);
5613
5613
  return { kind: "tcp", port };
5614
5614
  }
5615
5615
  var proxy = command({
@@ -6573,13 +6573,13 @@ var CMD_SAFE = /^[A-Za-z0-9 _.:\\()~-]+$/;
6573
6573
  function editorCommand(editor, file, platform, env = process.env) {
6574
6574
  if (platform !== "win32") return { command: "/bin/sh", args: ["-c", `trap : INT QUIT; ${editor} "$@"`, "sh", file], shell: false };
6575
6575
  const [first = "notepad", ...rest] = splitCommand(editor);
6576
- const resolved = /[\\/]/.test(first) ? first : findOnPath(first, env, "win32") ?? first;
6577
- if (!/\.(cmd|bat)$/i.test(resolved)) return { command: resolved, args: [...rest, file], shell: false };
6576
+ const resolved2 = /[\\/]/.test(first) ? first : findOnPath(first, env, "win32") ?? first;
6577
+ if (!/\.(cmd|bat)$/i.test(resolved2)) return { command: resolved2, args: [...rest, file], shell: false };
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
6581
  const quote2 = (w) => `"${w.replace(/"/g, "")}"`;
6582
- return { command: [resolved, ...rest, file].map(quote2).join(" "), args: [], shell: true };
6582
+ return { command: [resolved2, ...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) => {
@@ -6807,10 +6807,10 @@ var add = command({
6807
6807
  var HERDR_COMMANDS = [add, call, socket];
6808
6808
 
6809
6809
  // src/commands/node.ts
6810
- import { spawn as spawn7 } from "node:child_process";
6811
- import fs20 from "node:fs";
6810
+ import { spawn as spawn9 } from "node:child_process";
6811
+ import fs22 from "node:fs";
6812
6812
  import os11 from "node:os";
6813
- import path18 from "node:path";
6813
+ import path29 from "node:path";
6814
6814
 
6815
6815
  // src/node/control.ts
6816
6816
  import { randomBytes as randomBytes4, timingSafeEqual } from "node:crypto";
@@ -6870,7 +6870,7 @@ function writePrivate2(file, contents, mode = 384) {
6870
6870
  }
6871
6871
 
6872
6872
  // src/node/control.ts
6873
- var COMMANDS = /* @__PURE__ */ new Set(["status", "pause", "resume", "leave"]);
6873
+ var COMMANDS = /* @__PURE__ */ new Set(["status", "pause", "resume", "leave", "preview", "preview-list", "preview-stop"]);
6874
6874
  async function startControl(socketPath, secretFile, handle) {
6875
6875
  const secret = randomBytes4(32).toString("base64url");
6876
6876
  writePrivate2(secretFile, secret);
@@ -6898,7 +6898,8 @@ async function startControl(socketPath, secretFile, handle) {
6898
6898
  const got = Buffer.from(typeof req.secret === "string" ? req.secret : "");
6899
6899
  if (got.length !== want.length || !timingSafeEqual(got, want)) return reply({ ok: false, error: "refused" });
6900
6900
  if (typeof req.command !== "string" || !COMMANDS.has(req.command)) return reply({ ok: false, error: "unknown command" });
6901
- handle(req.command).then(
6901
+ const args = typeof req.args === "object" && req.args !== null && !Array.isArray(req.args) ? req.args : {};
6902
+ handle(req.command, args).then(
6902
6903
  (out) => reply({ ok: true, ...out }),
6903
6904
  (err) => reply({ ok: false, error: err.message })
6904
6905
  );
@@ -6919,7 +6920,7 @@ async function startControl(socketPath, secretFile, handle) {
6919
6920
  }
6920
6921
  };
6921
6922
  }
6922
- function callControl(socketPath, secretFile, command2, timeoutMs = 1e4) {
6923
+ function callControl(socketPath, secretFile, command2, timeoutMs = 1e4, args = {}) {
6923
6924
  let secret;
6924
6925
  try {
6925
6926
  secret = fs9.readFileSync(secretFile, "utf8").trim();
@@ -6933,7 +6934,7 @@ function callControl(socketPath, secretFile, command2, timeoutMs = 1e4) {
6933
6934
  sock.destroy();
6934
6935
  resolve(null);
6935
6936
  }, timeoutMs);
6936
- sock.on("connect", () => sock.write(`${JSON.stringify({ secret, command: command2 })}
6937
+ sock.on("connect", () => sock.write(`${JSON.stringify({ secret, command: command2, args })}
6937
6938
  `));
6938
6939
  sock.on("data", (c) => buf += c.toString("utf8"));
6939
6940
  sock.on("end", () => {
@@ -7044,7 +7045,9 @@ function decodeOpen(payload) {
7044
7045
  } catch {
7045
7046
  }
7046
7047
  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
+ const out = { kind: v.kind === "upgrade" ? "upgrade" : "http", session: id(v.session), grant: id(v.grant) };
7049
+ if (typeof v.app === "number" && Number.isInteger(v.app) && v.app >= 1 && v.app <= 65535) out.app = v.app;
7050
+ return out;
7048
7051
  }
7049
7052
  function windowPayload(n) {
7050
7053
  const b = Buffer.allocUnsafe(4);
@@ -7243,16 +7246,16 @@ var StreamTable = class {
7243
7246
 
7244
7247
  // src/node/credentials.ts
7245
7248
  function readCredentials(file) {
7246
- let raw;
7249
+ let raw2;
7247
7250
  try {
7248
- raw = fs10.readFileSync(file, "utf8");
7251
+ raw2 = fs10.readFileSync(file, "utf8");
7249
7252
  } catch (err) {
7250
7253
  if (err.code === "ENOENT") return null;
7251
7254
  throw err;
7252
7255
  }
7253
7256
  let c;
7254
7257
  try {
7255
- c = JSON.parse(raw);
7258
+ c = JSON.parse(raw2);
7256
7259
  } catch {
7257
7260
  throw new CliError(`${file} is not valid JSON; this machine cannot link until it is fixed or re-joined`);
7258
7261
  }
@@ -7272,7 +7275,7 @@ import fs12 from "node:fs";
7272
7275
  import path11 from "node:path";
7273
7276
 
7274
7277
  // ../../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";
7278
+ 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), run\n `agentbox-preview <port>` once it listens (on 127.0.0.1 or [::1]): it appears\n in the owner's Preview on the machines page. `agentbox-preview list` and\n `agentbox-preview stop <port>` manage them.\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
7279
 
7277
7280
  // src/node/update.ts
7278
7281
  import { spawnSync as spawnSync2 } from "node:child_process";
@@ -7353,16 +7356,16 @@ function asset(v, where, versioned) {
7353
7356
  return entry;
7354
7357
  }
7355
7358
  function parseManifest(bytes) {
7356
- let raw;
7359
+ let raw2;
7357
7360
  try {
7358
- raw = JSON.parse(bytes.toString("utf8"));
7361
+ raw2 = JSON.parse(bytes.toString("utf8"));
7359
7362
  } catch {
7360
7363
  throw new UpdateRefused("the release manifest is not JSON");
7361
7364
  }
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");
7365
+ if (!raw2 || typeof raw2 !== "object" || raw2.schema !== 1) throw new UpdateRefused("the release manifest is of a kind this node does not know; update it by hand");
7366
+ if (typeof raw2.version !== "string" || !parseVersion(raw2.version)) throw new UpdateRefused("the release manifest has no version");
7364
7367
  const platforms = {};
7365
- const given = raw.platforms ?? {};
7368
+ const given = raw2.platforms ?? {};
7366
7369
  for (const p of MACHINE_PLATFORMS) {
7367
7370
  const e = given[p];
7368
7371
  if (!e) continue;
@@ -7374,7 +7377,7 @@ function parseManifest(bytes) {
7374
7377
  if (e.nodeGui !== void 0) entry.nodeGui = asset(e.nodeGui, `${p} windowless node`, false);
7375
7378
  platforms[p] = entry;
7376
7379
  }
7377
- return { schema: 1, version: raw.version, platforms };
7380
+ return { schema: 1, version: raw2.version, platforms };
7378
7381
  }
7379
7382
 
7380
7383
  // src/node/update.ts
@@ -7684,14 +7687,14 @@ function expand(p, home2) {
7684
7687
  return p;
7685
7688
  }
7686
7689
  function parsePolicy(text, home2) {
7687
- let raw;
7690
+ let raw2;
7688
7691
  try {
7689
- raw = JSON.parse(text);
7692
+ raw2 = JSON.parse(text);
7690
7693
  } catch {
7691
7694
  return broken("policy.json is not valid JSON");
7692
7695
  }
7693
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return broken("policy.json is not an object");
7694
- const o = raw;
7696
+ if (typeof raw2 !== "object" || raw2 === null || Array.isArray(raw2)) return broken("policy.json is not an object");
7697
+ const o = raw2;
7695
7698
  for (const k of Object.keys(o)) if (!KEYS.has(k)) return broken(`policy.json has "${k}", which this node does not know`);
7696
7699
  const policy = { ...DENY_ALL, files: [] };
7697
7700
  for (const k of ["terminals", "ports", "share", "paused"]) {
@@ -7962,7 +7965,13 @@ var NodeLink = class extends EventEmitter {
7962
7965
  return this.paused;
7963
7966
  }
7964
7967
  sendPolicy(policy) {
7965
- if (this.stateNow === "online" && this.ws?.readyState === import_websocket.default.OPEN) this.ws.send(encodeControl({ type: "policy", policy }));
7968
+ this.sendControl({ type: "policy", policy });
7969
+ }
7970
+ /** Say `message` to the box while online; false when not online. */
7971
+ sendControl(message) {
7972
+ if (this.stateNow !== "online" || this.ws?.readyState !== import_websocket.default.OPEN) return false;
7973
+ this.ws.send(encodeControl(message));
7974
+ return true;
7966
7975
  }
7967
7976
  /** Tell the box this machine is leaving; resolves whether it was told. */
7968
7977
  leave(timeoutMs = 5e3) {
@@ -8340,8 +8349,8 @@ var Prompter = class {
8340
8349
  };
8341
8350
 
8342
8351
  // src/node/run.ts
8343
- import fs19 from "node:fs";
8344
- import path17 from "node:path";
8352
+ import fs21 from "node:fs";
8353
+ import path28 from "node:path";
8345
8354
 
8346
8355
  // ../hostcore/src/herdr/socket.ts
8347
8356
  import net4 from "node:net";
@@ -8993,9 +9002,9 @@ function control(a, msg, size) {
8993
9002
  break;
8994
9003
  }
8995
9004
  }
8996
- function parse(raw) {
9005
+ function parse(raw2) {
8997
9006
  try {
8998
- return JSON.parse(raw.toString());
9007
+ return JSON.parse(raw2.toString());
8999
9008
  } catch {
9000
9009
  return null;
9001
9010
  }
@@ -9015,8 +9024,8 @@ function single(socket2, streams, pane, cols, rows) {
9015
9024
  }
9016
9025
  };
9017
9026
  const attachment = streams.attach(pane, viewer, cols, rows);
9018
- socket2.on("message", (raw) => {
9019
- const msg = parse(raw);
9027
+ socket2.on("message", (raw2) => {
9028
+ const msg = parse(raw2);
9020
9029
  if (msg) control(attachment, msg, { cols, rows });
9021
9030
  });
9022
9031
  socket2.on("close", () => attachment.detach());
@@ -9047,8 +9056,8 @@ function shared(socket2, streams) {
9047
9056
  const attachment = streams.attach(pane, viewer, size.cols, size.rows);
9048
9057
  if (live) channels.set(ch, { attachment, size });
9049
9058
  };
9050
- socket2.on("message", (raw) => {
9051
- const msg = parse(raw);
9059
+ socket2.on("message", (raw2) => {
9060
+ const msg = parse(raw2);
9052
9061
  if (!msg) return;
9053
9062
  const ch = Number(msg.ch);
9054
9063
  if (!Number.isInteger(ch) || ch < 0 || ch > 65535) return;
@@ -9299,218 +9308,2370 @@ var HerdrSupervisor = class extends EventEmitter2 {
9299
9308
 
9300
9309
  // src/node/server.ts
9301
9310
  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);
9311
+
9312
+ // ../hostcore/src/files/entries.ts
9313
+ import fsp2 from "node:fs/promises";
9314
+ import path16 from "node:path";
9315
+
9316
+ // ../hostcore/src/files/names.ts
9317
+ var strict = new TextDecoder("utf-8", { fatal: true });
9318
+ function decodeName(bytes) {
9319
+ try {
9320
+ return strict.decode(bytes);
9321
+ } catch {
9322
+ return decodeLossless(bytes);
9323
+ }
9307
9324
  }
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
- });
9325
+ function decodeLossless(b) {
9326
+ let out = "";
9327
+ let i = 0;
9328
+ while (i < b.length) {
9329
+ const c = b[i];
9330
+ if (c < 128) {
9331
+ out += String.fromCharCode(c);
9332
+ i += 1;
9333
+ continue;
9334
+ }
9335
+ const len = sequenceLength(b, i);
9336
+ if (len === 0) {
9337
+ out += String.fromCharCode(56320 + c);
9338
+ i += 1;
9339
+ continue;
9340
+ }
9341
+ out += strict.decode(b.subarray(i, i + len));
9342
+ i += len;
9343
+ }
9344
+ return out;
9331
9345
  }
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
- );
9346
+ function sequenceLength(b, i) {
9347
+ const c = b[i];
9348
+ const cont = (k, lo = 128, hi = 191) => {
9349
+ const v = b[i + k];
9350
+ return v !== void 0 && v >= lo && v <= hi;
9351
+ };
9352
+ if (c >= 194 && c <= 223) return cont(1) ? 2 : 0;
9353
+ if (c === 224) return cont(1, 160) && cont(2) ? 3 : 0;
9354
+ if (c >= 225 && c <= 236 || c === 238 || c === 239) return cont(1) && cont(2) ? 3 : 0;
9355
+ if (c === 237) return cont(1, 128, 159) && cont(2) ? 3 : 0;
9356
+ if (c === 240) return cont(1, 144) && cont(2) && cont(3) ? 4 : 0;
9357
+ if (c >= 241 && c <= 243) return cont(1) && cont(2) && cont(3) ? 4 : 0;
9358
+ if (c === 244) return cont(1, 128, 143) && cont(2) && cont(3) ? 4 : 0;
9359
+ return 0;
9360
+ }
9361
+ function hasEscapes(s) {
9362
+ return new RegExp("\\p{Cs}", "u").test(s);
9363
+ }
9364
+ var NameError = class extends Error {
9365
+ };
9366
+ function encodeName(s) {
9367
+ if (!hasEscapes(s)) return Buffer.from(s, "utf8");
9368
+ const parts = [];
9369
+ let run4 = "";
9370
+ for (const ch of s) {
9371
+ const cp = ch.codePointAt(0);
9372
+ if (cp >= 56448 && cp <= 56575) {
9373
+ if (run4) parts.push(Buffer.from(run4, "utf8"));
9374
+ run4 = "";
9375
+ parts.push(Buffer.from([cp - 56320]));
9376
+ } else if (cp >= 55296 && cp <= 57343) {
9377
+ throw new NameError("path contains an unpaired surrogate");
9378
+ } else {
9379
+ run4 += ch;
9380
+ }
9381
+ }
9382
+ if (run4) parts.push(Buffer.from(run4, "utf8"));
9383
+ return Buffer.concat(parts);
9342
9384
  }
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());
9385
+ function fsPath(s) {
9386
+ return hasEscapes(s) ? encodeName(s) : s;
9387
+ }
9388
+ function parseQuery(rawUrl) {
9389
+ const out = /* @__PURE__ */ new Map();
9390
+ const q = rawUrl.indexOf("?");
9391
+ if (q === -1) return out;
9392
+ for (const pair of rawUrl.slice(q + 1).split("&")) {
9393
+ if (pair === "") continue;
9394
+ const eq = pair.indexOf("=");
9395
+ const key = decodeComponent(eq === -1 ? pair : pair.slice(0, eq));
9396
+ const value = eq === -1 ? "" : decodeComponent(pair.slice(eq + 1));
9397
+ const list = out.get(key);
9398
+ if (list) list.push(value);
9399
+ else out.set(key, [value]);
9355
9400
  }
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);
9401
+ return out;
9402
+ }
9403
+ function decodeComponent(s, plusIsSpace = true) {
9404
+ const bytes = [];
9405
+ for (let i = 0; i < s.length; i++) {
9406
+ const ch = s.charCodeAt(i);
9407
+ if (ch === 37 && /^[0-9a-fA-F]{2}$/.test(s.slice(i + 1, i + 3))) {
9408
+ bytes.push(parseInt(s.slice(i + 1, i + 3), 16));
9409
+ i += 2;
9410
+ } else if (ch === 43 && plusIsSpace) {
9411
+ bytes.push(32);
9412
+ } else if (ch < 128) {
9413
+ bytes.push(ch);
9414
+ } else {
9415
+ const cp = s.codePointAt(i);
9416
+ for (const b of Buffer.from(String.fromCodePoint(cp), "utf8")) bytes.push(b);
9417
+ if (cp > 65535) i += 1;
9418
+ }
9365
9419
  }
9366
- openOf(req) {
9367
- return this.opens.get(req.socket) ?? { kind: "http", session: null, grant: null };
9420
+ return decodeName(Uint8Array.from(bytes));
9421
+ }
9422
+
9423
+ // ../hostcore/src/files/roots.ts
9424
+ import fsp from "node:fs/promises";
9425
+ import path15 from "node:path";
9426
+ var FilesError = class extends Error {
9427
+ constructor(status3, message, code2) {
9428
+ super(message);
9429
+ this.status = status3;
9430
+ this.code = code2;
9431
+ this.name = "FilesError";
9368
9432
  }
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" } };
9433
+ status;
9434
+ code;
9435
+ };
9436
+ var MAX_PATH = 4096;
9437
+ var RESERVED = ["trash", "uploads", "clones"];
9438
+ function within2(child, parent) {
9439
+ if (child === parent) return true;
9440
+ const base = parent.endsWith(path15.sep) ? parent : parent + path15.sep;
9441
+ return child.startsWith(base);
9442
+ }
9443
+ var Roots = class {
9444
+ /** Where a relative path hangs: the first root configured. */
9445
+ primary;
9446
+ home;
9447
+ roots;
9448
+ all;
9449
+ realCache = /* @__PURE__ */ new Map();
9450
+ caseInsensitive;
9451
+ isHidden;
9452
+ isWriteDenied;
9453
+ refusePath;
9454
+ constructor(opts) {
9455
+ this.roots = opts.roots.map((r) => ({ id: r.id, path: path15.resolve(r.path), mode: r.mode, stateDir: path15.resolve(r.stateDir) }));
9456
+ const first = this.roots[0];
9457
+ if (!first) throw new Error("at least one root is required");
9458
+ this.primary = first;
9459
+ this.home = this.roots.find((r) => r.id === "home");
9460
+ this.all = [...this.roots].sort((a, b) => b.path.length - a.path.length);
9461
+ this.caseInsensitive = opts.caseInsensitive ?? false;
9462
+ this.isHidden = opts.isHidden;
9463
+ this.isWriteDenied = opts.isWriteDenied;
9464
+ this.refusePath = opts.refusePath;
9465
+ }
9466
+ byId(id) {
9467
+ const root = this.roots.find((r) => r.id === id);
9468
+ if (!root) throw new FilesError(404, `no root named ${id}`, "not-found");
9469
+ return root;
9470
+ }
9471
+ list() {
9472
+ return [...this.roots];
9473
+ }
9474
+ /** {@link within}, ignoring case when the roots were configured to. */
9475
+ inside(child, parent) {
9476
+ return this.caseInsensitive ? within2(child.toLowerCase(), parent.toLowerCase()) : within2(child, parent);
9477
+ }
9478
+ /** True when a resolved path is hidden by the configured predicate. */
9479
+ hidden(realPath) {
9480
+ return this.isHidden?.(realPath) ?? false;
9481
+ }
9482
+ /** A resolved path that is hidden reads as one that does not exist. */
9483
+ assertVisible(realPath, where) {
9484
+ if (this.hidden(realPath)) throw new FilesError(404, `${where} does not exist`, "not-found");
9485
+ }
9486
+ /** Refuse a change under a read-only root, or to a path the predicate denies. */
9487
+ assertWritable(root, realPath, where) {
9488
+ if (root.mode === "read") throw new FilesError(403, `${root.path} is read-only`, "read-only");
9489
+ if (this.isWriteDenied?.(realPath)) throw new FilesError(403, `permission denied: ${where}`, "denied");
9490
+ }
9491
+ /** The root's own real path, resolved once. */
9492
+ async realRoot(root) {
9493
+ const cached = this.realCache.get(root.path);
9494
+ if (cached) return cached;
9495
+ let real;
9496
+ try {
9497
+ real = await fsp.realpath(root.path);
9498
+ } catch {
9499
+ throw new FilesError(404, `${root.path} does not exist`);
9500
+ }
9501
+ this.realCache.set(root.path, real);
9502
+ return real;
9503
+ }
9504
+ /** The state directory of a root, e.g. `/workspace/.agentbox`. */
9505
+ stateDir(root) {
9506
+ return root.stateDir;
9507
+ }
9508
+ /** The state directory's name when it sits directly in its root, e.g. `.agentbox`. */
9509
+ stateName(root) {
9510
+ return path15.dirname(root.stateDir) === root.path ? path15.basename(root.stateDir) : null;
9511
+ }
9512
+ /** The state directory as resolved on disk, or null when it cannot be. */
9513
+ async realStateDir(root) {
9514
+ if (within2(root.stateDir, root.path)) {
9515
+ const real = await this.realRoot(root).catch(() => null);
9516
+ return real === null ? null : path15.join(real, path15.relative(root.path, root.stateDir));
9517
+ }
9518
+ return fsp.realpath(root.stateDir).catch(() => null);
9519
+ }
9520
+ /** Place a client path lexically. Throws for anything outside every root. */
9521
+ locate(input) {
9522
+ if (typeof input !== "string") throw new FilesError(400, "path required");
9523
+ if (input.length > MAX_PATH) throw new FilesError(400, "path too long");
9524
+ if (input.includes("\0")) throw new FilesError(400, "path contains a NUL byte");
9525
+ const refused = this.refusePath?.(input) ?? null;
9526
+ if (refused !== null) throw new FilesError(400, refused, "refused");
9527
+ if (hasEscapes(input)) {
9528
+ try {
9529
+ encodeName(input);
9530
+ } catch (err) {
9531
+ if (err instanceof NameError) throw new FilesError(400, err.message);
9532
+ throw err;
9533
+ }
9534
+ }
9535
+ let abs;
9536
+ if (input === "~" || input.startsWith("~/")) {
9537
+ if (!this.home) throw new FilesError(403, `${input} is outside every root`, "outside");
9538
+ abs = path15.resolve(this.home.path, "." + input.slice(1));
9539
+ } else abs = path15.resolve(this.primary.path, input === "" ? "." : input);
9540
+ const root = this.all.find((r) => this.inside(abs, r.path));
9541
+ if (!root) throw new FilesError(403, `${input} is outside ${this.outsideWhat()}`, "outside");
9542
+ return { root, abs };
9543
+ }
9544
+ /** What a path outside every root is said to be outside of. */
9545
+ outsideWhat() {
9546
+ const ids = this.roots.map((r) => r.id);
9547
+ if (ids.length === 2 && ids[0] === "workspace" && ids[1] === "home") return "the workspace and home";
9548
+ return "every root";
9549
+ }
9550
+ /** True when `abs` is the root itself or inside its reserved state dirs. */
9551
+ isReserved(loc) {
9552
+ if (loc.abs === loc.root.path) return true;
9553
+ const state = this.stateDir(loc.root);
9554
+ if (this.inside(loc.abs, state) && this.inside(state, loc.abs)) return true;
9555
+ return RESERVED.some((d) => this.inside(loc.abs, path15.join(state, d)));
9556
+ }
9557
+ /**
9558
+ * Refuse to change the root itself or the API's own state, anything under a
9559
+ * read-only root, and a path the write-denied predicate names (checked on
9560
+ * the resolved path when the reference carries one).
9561
+ */
9562
+ assertMutable(loc) {
9563
+ if (loc.abs === loc.root.path) throw new FilesError(403, "the root itself cannot be changed", "reserved");
9564
+ if (this.isReserved(loc)) {
9565
+ throw new FilesError(403, `${loc.abs} holds agentbox's own state`, "reserved");
9380
9566
  }
9381
- return null;
9567
+ this.assertWritable(loc.root, "fs" in loc ? loc.fs : loc.abs, loc.abs);
9382
9568
  }
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;
9569
+ /**
9570
+ * Refuse to copy or move anything that is, holds, or sits inside a root's
9571
+ * `.agentbox`. Carrying the trash and upload scratch along is never what a
9572
+ * person meant — and a copy is built inside that very scratch space, so a
9573
+ * copy of it would copy itself until the disk filled. Checked on the path
9574
+ * as named and as resolved, since a link can lead there.
9575
+ */
9576
+ async assertMovable(ref) {
9577
+ for (const root of this.all) {
9578
+ const lexical = this.stateDir(root);
9579
+ const real = await this.realStateDir(root);
9580
+ for (const [p, state] of [
9581
+ [ref.abs, lexical],
9582
+ [ref.fs, real]
9583
+ ]) {
9584
+ if (state && (this.inside(p, state) || this.inside(state, p))) {
9585
+ throw new FilesError(403, `${ref.abs} holds agentbox's own state`, "reserved");
9586
+ }
9587
+ }
9391
9588
  }
9392
- return n;
9393
9589
  }
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
- });
9590
+ /** Resolve through every symlink; the destination must stay in the root. */
9591
+ async target(input) {
9592
+ const loc = this.locate(input);
9593
+ const rootReal = await this.realRoot(loc.root);
9594
+ let real;
9595
+ try {
9596
+ real = decodeName(await fsp.realpath(fsPath(loc.abs), { encoding: "buffer" }));
9597
+ } catch (err) {
9598
+ throw fsError(err, loc.abs);
9599
+ }
9600
+ if (!this.inside(real, rootReal)) {
9601
+ throw new FilesError(403, `${loc.abs} leads outside its root`, "outside");
9602
+ }
9603
+ this.assertVisible(real, loc.abs);
9604
+ return { ...loc, real };
9404
9605
  }
9405
- get openChannels() {
9406
- return this.channels.size;
9606
+ /**
9607
+ * Address an existing entry itself (a symlink, not what it points at). The
9608
+ * parent is resolved and confined; the entry must exist.
9609
+ */
9610
+ async entry(input) {
9611
+ const loc = this.locate(input);
9612
+ if (loc.abs === loc.root.path) {
9613
+ return { ...loc, fs: await this.realRoot(loc.root) };
9614
+ }
9615
+ const parent = await this.realParent(loc);
9616
+ const fsp_ = path15.join(parent, path15.basename(loc.abs));
9617
+ this.assertVisible(fsp_, loc.abs);
9618
+ try {
9619
+ await fsp.lstat(fsPath(fsp_));
9620
+ } catch (err) {
9621
+ throw fsError(err, loc.abs);
9622
+ }
9623
+ return { ...loc, fs: fsp_ };
9407
9624
  }
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);
9625
+ /**
9626
+ * Address a path to create. The nearest existing ancestor is resolved and
9627
+ * confined; missing directories in between are not created here (see
9628
+ * {@link ensureParent}). Whether the final name exists is the caller's call.
9629
+ */
9630
+ async creatable(input) {
9631
+ const loc = this.locate(input);
9632
+ if (loc.abs === loc.root.path) throw new FilesError(409, "the root already exists", "exists");
9633
+ const rootReal = await this.realRoot(loc.root);
9634
+ const missing = [path15.basename(loc.abs)];
9635
+ let dir = path15.dirname(loc.abs);
9636
+ for (; ; ) {
9637
+ try {
9638
+ const real = decodeName(await fsp.realpath(fsPath(dir), { encoding: "buffer" }));
9639
+ if (!this.inside(real, rootReal)) throw new FilesError(403, `${loc.abs} leads outside its root`, "outside");
9640
+ const st = await fsp.stat(fsPath(real));
9641
+ if (!st.isDirectory()) throw new FilesError(409, `${dir} is not a directory`, "not-a-directory");
9642
+ const created = path15.join(real, ...missing.reverse());
9643
+ this.assertVisible(created, loc.abs);
9644
+ this.assertWritable(loc.root, created, loc.abs);
9645
+ return { ...loc, fs: created };
9646
+ } catch (err) {
9647
+ if (err instanceof FilesError) throw err;
9648
+ const code2 = err.code;
9649
+ if (code2 !== "ENOENT" || dir === loc.root.path) throw fsError(err, dir);
9650
+ missing.push(path15.basename(dir));
9651
+ dir = path15.dirname(dir);
9432
9652
  }
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." });
9653
+ }
9654
+ }
9655
+ /**
9656
+ * Create the missing directories between a {@link creatable} path's real
9657
+ * ancestor and its parent. Each is created (never followed) in turn, so a
9658
+ * link planted on the way cannot divert the write.
9659
+ */
9660
+ async ensureParent(ref) {
9661
+ const parent = path15.dirname(ref.fs);
9662
+ const rootReal = await this.realRoot(ref.root);
9663
+ const parts = [];
9664
+ let dir = parent;
9665
+ for (; ; ) {
9666
+ try {
9667
+ const st = await fsp.lstat(fsPath(dir));
9668
+ if (st.isDirectory()) break;
9669
+ throw new FilesError(409, `${dir} is not a directory`, "not-a-directory");
9670
+ } catch (err) {
9671
+ if (err instanceof FilesError) throw err;
9672
+ if (err.code !== "ENOENT") throw fsError(err, dir);
9673
+ parts.push(dir);
9674
+ dir = path15.dirname(dir);
9440
9675
  }
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
- }
9676
+ }
9677
+ if (!this.inside(dir, rootReal)) throw new FilesError(403, `${ref.abs} leads outside its root`, "outside");
9678
+ for (const d of parts.reverse()) {
9679
+ try {
9680
+ await fsp.mkdir(fsPath(d));
9681
+ } catch (err) {
9682
+ if (err.code !== "EEXIST") throw fsError(err, d);
9683
+ const st = await fsp.lstat(fsPath(d));
9684
+ if (!st.isDirectory()) throw new FilesError(409, `${d} is not a directory`, "not-a-directory");
9473
9685
  }
9474
- default:
9475
- return json(res, 404, { error: "not found" });
9476
9686
  }
9477
9687
  }
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
- });
9688
+ async realParent(loc) {
9689
+ const rootReal = await this.realRoot(loc.root);
9690
+ const dir = path15.dirname(loc.abs);
9691
+ let real;
9692
+ try {
9693
+ real = decodeName(await fsp.realpath(fsPath(dir), { encoding: "buffer" }));
9694
+ } catch (err) {
9695
+ throw fsError(err, loc.abs);
9696
+ }
9697
+ if (!this.inside(real, rootReal)) throw new FilesError(403, `${loc.abs} leads outside its root`, "outside");
9698
+ return real;
9501
9699
  }
9502
- /** `/ws/events`: a fresh snapshot, then every hub message as it comes. */
9700
+ /** The lexical path a real path inside a root is reported as. */
9701
+ present(root, rootReal, real) {
9702
+ if (real === rootReal) return root.path;
9703
+ return path15.join(root.path, real.slice(rootReal.length + 1));
9704
+ }
9705
+ };
9706
+ function fsError(err, where) {
9707
+ if (err instanceof FilesError) return err;
9708
+ const code2 = err?.code;
9709
+ switch (code2) {
9710
+ case "ENOENT":
9711
+ return new FilesError(404, `${where} does not exist`, "not-found");
9712
+ case "EEXIST":
9713
+ case "ENOTEMPTY":
9714
+ return new FilesError(409, `${where} already exists`, "exists");
9715
+ case "ENOTDIR":
9716
+ return new FilesError(409, `a parent of ${where} is not a directory`, "not-a-directory");
9717
+ case "EISDIR":
9718
+ return new FilesError(409, `${where} is a directory`, "is-a-directory");
9719
+ case "ELOOP":
9720
+ return new FilesError(400, `${where} is a symlink loop`, "loop");
9721
+ case "EACCES":
9722
+ case "EPERM":
9723
+ return new FilesError(403, `permission denied: ${where}`, "denied");
9724
+ case "ENAMETOOLONG":
9725
+ return new FilesError(400, `name too long: ${where}`, "too-long");
9726
+ case "ENOSPC":
9727
+ case "EDQUOT":
9728
+ return new FilesError(507, "no space left on the volume", "no-space");
9729
+ case "EINVAL":
9730
+ return new FilesError(400, `invalid operation on ${where}`, "invalid");
9731
+ default:
9732
+ return err instanceof Error ? err : new Error(String(err));
9733
+ }
9734
+ }
9735
+
9736
+ // ../hostcore/src/files/entries.ts
9737
+ var MAX_PAGE = 5e3;
9738
+ function typeOf(st) {
9739
+ if (st.isSymbolicLink()) return "symlink";
9740
+ if (st.isDirectory()) return "dir";
9741
+ if (st.isFile()) return "file";
9742
+ return "other";
9743
+ }
9744
+ async function describe(abs, fs26, rootReal, git) {
9745
+ let st;
9746
+ try {
9747
+ st = await fsp2.lstat(fsPath(fs26));
9748
+ } catch (err) {
9749
+ throw fsError(err, abs);
9750
+ }
9751
+ return describeStat(abs, fs26, st, rootReal, git);
9752
+ }
9753
+ async function describeStat(abs, fs26, st, rootReal, git) {
9754
+ const name = abs === "/" ? "/" : path16.basename(abs);
9755
+ const type = typeOf(st);
9756
+ const entry = {
9757
+ name,
9758
+ path: abs,
9759
+ type,
9760
+ size: type === "dir" ? 0 : st.size,
9761
+ mtime: Math.round(st.mtimeMs)
9762
+ };
9763
+ if (hasEscapes(name)) entry.rawName = true;
9764
+ if (type === "symlink") {
9765
+ try {
9766
+ entry.target = decodeName(await fsp2.readlink(fsPath(fs26), { encoding: "buffer" }));
9767
+ } catch {
9768
+ entry.target = "";
9769
+ }
9770
+ entry.targetType = null;
9771
+ try {
9772
+ const real = decodeName(await fsp2.realpath(fsPath(fs26), { encoding: "buffer" }));
9773
+ if (within2(real, rootReal)) {
9774
+ const t2 = typeOf(await fsp2.stat(fsPath(real)));
9775
+ entry.targetType = t2 === "symlink" ? null : t2;
9776
+ }
9777
+ } catch {
9778
+ }
9779
+ }
9780
+ if (git) entry.git = git(abs, type === "dir" || entry.targetType === "dir");
9781
+ return entry;
9782
+ }
9783
+ var collator = new Intl.Collator(void 0, { numeric: true, sensitivity: "base" });
9784
+ var ListingCache = class {
9785
+ cache = /* @__PURE__ */ new Map();
9786
+ held = 0;
9787
+ ttlMs;
9788
+ maxDirs;
9789
+ maxNames;
9790
+ settleMs;
9791
+ constructor(opts = {}) {
9792
+ this.ttlMs = opts.ttlMs ?? 5e3;
9793
+ this.maxDirs = opts.maxDirs ?? 64;
9794
+ this.maxNames = opts.maxNames ?? 2e5;
9795
+ this.settleMs = opts.settleMs ?? 2e3;
9796
+ }
9797
+ /** What is held now: directories, and names across them. */
9798
+ get size() {
9799
+ return { dirs: this.cache.size, names: this.held };
9800
+ }
9801
+ async names(real, where) {
9802
+ const now = Date.now();
9803
+ this.expire(now);
9804
+ let stamp;
9805
+ let mtimeMs;
9806
+ let dirents;
9807
+ try {
9808
+ const st = await fsp2.stat(fsPath(real), { bigint: true });
9809
+ stamp = `${st.ino}:${st.mtimeNs}:${st.ctimeNs}`;
9810
+ mtimeMs = Number(st.mtimeNs / 1000000n);
9811
+ const hit = this.cache.get(real);
9812
+ if (hit && hit.stamp === stamp) {
9813
+ this.cache.delete(real);
9814
+ this.cache.set(real, hit);
9815
+ return hit.names;
9816
+ }
9817
+ dirents = await fsp2.readdir(fsPath(real), { withFileTypes: true, encoding: "buffer" });
9818
+ } catch (err) {
9819
+ throw fsError(err, where);
9820
+ }
9821
+ const names = dirents.map((d) => ({ name: decodeName(d.name), dir: d.isDirectory() })).sort((a, b) => a.dir === b.dir ? collator.compare(a.name, b.name) : a.dir ? -1 : 1);
9822
+ this.forget(real);
9823
+ const settled = now - mtimeMs >= this.settleMs;
9824
+ if (this.ttlMs > 0 && settled && names.length <= this.maxNames) {
9825
+ this.cache.set(real, { at: now, stamp, names });
9826
+ this.held += names.length;
9827
+ while (this.cache.size > this.maxDirs || this.held > this.maxNames) this.forget(this.cache.keys().next().value);
9828
+ }
9829
+ return names;
9830
+ }
9831
+ forget(real) {
9832
+ const kept = this.cache.get(real);
9833
+ if (!kept) return;
9834
+ this.cache.delete(real);
9835
+ this.held -= kept.names.length;
9836
+ }
9837
+ /** Drop what has outlived its time, used or not, so nothing idles in memory. */
9838
+ expire(now) {
9839
+ for (const [real, kept] of this.cache) if (now - kept.at >= this.ttlMs) this.forget(real);
9840
+ }
9841
+ };
9842
+ async function listDirectory(roots, dir, opts) {
9843
+ const rootReal = await roots.realRoot(dir.root);
9844
+ const all = await (opts.cache ?? new ListingCache({ ttlMs: 0 })).names(dir.real, dir.abs);
9845
+ const shown = all.filter((d) => !roots.hidden(path16.join(dir.real, d.name)));
9846
+ const named = opts.hidden ? shown : shown.filter((d) => !d.name.startsWith("."));
9847
+ const page = named.slice(opts.offset, opts.offset + opts.limit);
9848
+ const entries = await mapLimit(page, 64, async (d) => {
9849
+ const abs = path16.join(dir.abs, d.name);
9850
+ try {
9851
+ return await describe(abs, path16.join(dir.real, d.name), rootReal, opts.git);
9852
+ } catch (err) {
9853
+ if (err instanceof FilesError && err.status === 404) return null;
9854
+ throw err;
9855
+ }
9856
+ });
9857
+ const kept = entries.filter((e) => e !== null);
9858
+ return {
9859
+ path: dir.abs,
9860
+ // The wire type names the box's two roots; a machine's are its own ids.
9861
+ root: dir.root.id,
9862
+ entries: kept,
9863
+ total: named.length,
9864
+ offset: opts.offset,
9865
+ truncated: opts.offset + page.length < named.length
9866
+ };
9867
+ }
9868
+ async function mapLimit(items, limit, fn) {
9869
+ const out = new Array(items.length);
9870
+ let next = 0;
9871
+ const worker = async () => {
9872
+ while (next < items.length) {
9873
+ const i = next++;
9874
+ out[i] = await fn(items[i]);
9875
+ }
9876
+ };
9877
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
9878
+ return out;
9879
+ }
9880
+
9881
+ // ../hostcore/src/files/git.ts
9882
+ import { spawn as spawn7 } from "node:child_process";
9883
+ import fsp3 from "node:fs/promises";
9884
+ import path17 from "node:path";
9885
+ function runGit(args, opts) {
9886
+ return new Promise((resolve, reject) => {
9887
+ const child = spawn7(opts.git ?? "git", args, {
9888
+ cwd: opts.cwd,
9889
+ // Status must never take the index lock: an agent committing in the
9890
+ // same repository would otherwise see "index.lock exists" at random.
9891
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C", GIT_TERMINAL_PROMPT: "0" },
9892
+ stdio: ["ignore", "pipe", "pipe"]
9893
+ });
9894
+ const chunks = [];
9895
+ let size = 0;
9896
+ const max = opts.maxBytes ?? 64 * 1024 * 1024;
9897
+ let stderr = "";
9898
+ const timer = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs ?? 1e4);
9899
+ child.stdout.on("data", (c) => {
9900
+ size += c.length;
9901
+ if (size > max) child.kill("SIGKILL");
9902
+ else chunks.push(c);
9903
+ });
9904
+ child.stderr.on("data", (c) => {
9905
+ if (stderr.length < 4096) stderr += c.toString();
9906
+ });
9907
+ child.on("error", (err) => {
9908
+ clearTimeout(timer);
9909
+ reject(err);
9910
+ });
9911
+ child.on("close", (code2, signal) => {
9912
+ clearTimeout(timer);
9913
+ if (code2 === 0) resolve(Buffer.concat(chunks));
9914
+ else reject(new Error(`git ${args[0]} failed (${signal ?? code2}): ${stderr.trim().slice(0, 300)}`));
9915
+ });
9916
+ });
9917
+ }
9918
+ function parseStatus(top, out) {
9919
+ const status3 = {
9920
+ top,
9921
+ branch: null,
9922
+ detached: false,
9923
+ upstream: null,
9924
+ ahead: 0,
9925
+ behind: 0,
9926
+ files: /* @__PURE__ */ new Map(),
9927
+ dirty: /* @__PURE__ */ new Set(),
9928
+ changed: 0
9929
+ };
9930
+ const records = [];
9931
+ let start = 0;
9932
+ for (let i = 0; i < out.length; i++) {
9933
+ if (out[i] === 0) {
9934
+ records.push(out.subarray(start, i));
9935
+ start = i + 1;
9936
+ }
9937
+ }
9938
+ if (start < out.length) records.push(out.subarray(start));
9939
+ const add2 = (p, s) => {
9940
+ const rel = p.replace(/\/+$/, "");
9941
+ status3.files.set(rel, s);
9942
+ if (s === "ignored") return;
9943
+ status3.changed += 1;
9944
+ let dir = path17.posix.dirname(rel);
9945
+ for (; ; ) {
9946
+ const key = dir === "." ? "" : dir;
9947
+ if (status3.dirty.has(key)) break;
9948
+ status3.dirty.add(key);
9949
+ if (key === "") break;
9950
+ dir = path17.posix.dirname(dir);
9951
+ }
9952
+ };
9953
+ for (let r = 0; r < records.length; r++) {
9954
+ const rec = decodeName(records[r]);
9955
+ if (rec.startsWith("# ")) {
9956
+ const [key, ...rest] = rec.slice(2).split(" ");
9957
+ const value = rest.join(" ");
9958
+ if (key === "branch.head") {
9959
+ status3.detached = value === "(detached)";
9960
+ status3.branch = status3.detached ? null : value;
9961
+ } else if (key === "branch.upstream") {
9962
+ status3.upstream = value;
9963
+ } else if (key === "branch.ab") {
9964
+ const m = /^\+(\d+) -(\d+)$/.exec(value);
9965
+ if (m) {
9966
+ status3.ahead = Number(m[1]);
9967
+ status3.behind = Number(m[2]);
9968
+ }
9969
+ }
9970
+ continue;
9971
+ }
9972
+ const kind = rec[0];
9973
+ if (kind === "?") add2(rec.slice(2), "untracked");
9974
+ else if (kind === "!") add2(rec.slice(2), "ignored");
9975
+ else if (kind === "1") add2(fieldsAfter(rec, 8), classify(rec.slice(2, 4)));
9976
+ else if (kind === "2") {
9977
+ add2(fieldsAfter(rec, 9), "renamed");
9978
+ r += 1;
9979
+ } else if (kind === "u") add2(fieldsAfter(rec, 10), "conflicted");
9980
+ }
9981
+ return status3;
9982
+ }
9983
+ function fieldsAfter(rec, n) {
9984
+ let i = 0;
9985
+ for (let k = 0; k < n; k++) {
9986
+ i = rec.indexOf(" ", i) + 1;
9987
+ if (i === 0) return "";
9988
+ }
9989
+ return rec.slice(i);
9990
+ }
9991
+ function classify(xy) {
9992
+ if (xy.includes("D")) return "deleted";
9993
+ if (xy.includes("A")) return "added";
9994
+ return "modified";
9995
+ }
9996
+ var GitStatusCache = class {
9997
+ ttlMs;
9998
+ timeoutMs;
9999
+ git;
10000
+ cache = /* @__PURE__ */ new Map();
10001
+ constructor(opts = {}) {
10002
+ this.ttlMs = opts.ttlMs ?? 2e3;
10003
+ this.timeoutMs = opts.timeoutMs ?? 1e4;
10004
+ this.git = opts.git ?? "git";
10005
+ }
10006
+ /**
10007
+ * The top of the repository containing `dir`, looking no higher than
10008
+ * `stop` (a root): the nearest ancestor with a `.git` entry.
10009
+ */
10010
+ async repoFor(dir, stop) {
10011
+ let d = dir;
10012
+ for (; ; ) {
10013
+ try {
10014
+ await fsp3.lstat(fsPath(path17.join(d, ".git")));
10015
+ return d;
10016
+ } catch {
10017
+ }
10018
+ if (d === stop || !within2(d, stop)) return null;
10019
+ d = path17.dirname(d);
10020
+ }
10021
+ }
10022
+ /** The status of the repository at `top`, or null when git cannot say. */
10023
+ status(top) {
10024
+ const now = Date.now();
10025
+ const hit = this.cache.get(top);
10026
+ if (hit && now - hit.at < this.ttlMs) return hit.value;
10027
+ if (hasEscapes(top)) return Promise.resolve(null);
10028
+ const value = runGit(
10029
+ ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=normal", "--ignored=traditional"],
10030
+ { cwd: top, timeoutMs: this.timeoutMs, git: this.git }
10031
+ ).then(
10032
+ (out) => parseStatus(top, out),
10033
+ () => null
10034
+ );
10035
+ this.cache.set(top, { at: now, value });
10036
+ if (this.cache.size > 256) {
10037
+ for (const [k, v] of this.cache) if (now - v.at >= this.ttlMs) this.cache.delete(k);
10038
+ }
10039
+ return value;
10040
+ }
10041
+ /** Seconds-since-epoch of the last commit, in milliseconds, or null. */
10042
+ async lastCommit(top) {
10043
+ try {
10044
+ const out = await runGit(["log", "-1", "--format=%ct"], { cwd: top, timeoutMs: this.timeoutMs, git: this.git });
10045
+ const n = Number(out.toString().trim());
10046
+ return Number.isFinite(n) && n > 0 ? n * 1e3 : null;
10047
+ } catch {
10048
+ return null;
10049
+ }
10050
+ }
10051
+ /** A per-entry lookup for listing `dir`, or undefined outside any repository. */
10052
+ async lookupFor(dir, stop) {
10053
+ const top = await this.repoFor(dir, stop);
10054
+ if (!top) return void 0;
10055
+ const st = await this.status(top);
10056
+ if (!st) return void 0;
10057
+ return (abs, isDir2) => statusOf(st, path17.relative(top, abs).split(path17.sep).join("/"), isDir2);
10058
+ }
10059
+ };
10060
+ function statusOf(st, rel, isDir2) {
10061
+ if (rel === ".git" || rel.startsWith(".git/")) return null;
10062
+ const own = st.files.get(rel);
10063
+ if (own) return own;
10064
+ if (isDir2 && st.dirty.has(rel)) return "modified";
10065
+ let dir = path17.posix.dirname(rel);
10066
+ while (dir !== "." && dir !== "") {
10067
+ const s = st.files.get(dir);
10068
+ if (s === "untracked" || s === "ignored") return s;
10069
+ dir = path17.posix.dirname(dir);
10070
+ }
10071
+ return null;
10072
+ }
10073
+
10074
+ // ../hostcore/src/files/ops.ts
10075
+ import fsp5 from "node:fs/promises";
10076
+ import path19 from "node:path";
10077
+
10078
+ // ../hostcore/src/files/tree.ts
10079
+ import { randomBytes as randomBytes8 } from "node:crypto";
10080
+ import { constants } from "node:fs";
10081
+ import fsp4 from "node:fs/promises";
10082
+ import path18 from "node:path";
10083
+ async function copyTree(src, dst) {
10084
+ const st = await fsp4.lstat(fsPath(src));
10085
+ if (st.isSymbolicLink()) {
10086
+ const link = await fsp4.readlink(fsPath(src), { encoding: "buffer" });
10087
+ await fsp4.symlink(link, fsPath(dst));
10088
+ } else if (st.isDirectory()) {
10089
+ await fsp4.mkdir(fsPath(dst), { mode: st.mode & 4095 });
10090
+ const names = await fsp4.readdir(fsPath(src), { encoding: "buffer" });
10091
+ for (const raw2 of names) {
10092
+ const name = decodeName(raw2);
10093
+ await copyTree(path18.join(src, name), path18.join(dst, name));
10094
+ }
10095
+ } else if (st.isFile()) {
10096
+ await fsp4.copyFile(fsPath(src), fsPath(dst), constants.COPYFILE_EXCL);
10097
+ }
10098
+ }
10099
+ async function removeTree(p) {
10100
+ await fsp4.rm(fsPath(p), { recursive: true, force: true });
10101
+ }
10102
+ function scratchName(dir, label) {
10103
+ return path18.join(dir, `.${label}-${randomBytes8(8).toString("hex")}`);
10104
+ }
10105
+ async function moveTree(src, dst, scratchDir) {
10106
+ try {
10107
+ await fsp4.rename(fsPath(src), fsPath(dst));
10108
+ return;
10109
+ } catch (err) {
10110
+ if (err.code !== "EXDEV") throw err;
10111
+ }
10112
+ await fsp4.mkdir(fsPath(scratchDir), { recursive: true });
10113
+ const tmp = scratchName(scratchDir, "move");
10114
+ try {
10115
+ await copyTree(src, tmp);
10116
+ await noClobberRename(tmp, dst);
10117
+ } catch (err) {
10118
+ await removeTree(tmp).catch(() => {
10119
+ });
10120
+ throw err;
10121
+ }
10122
+ await removeTree(src);
10123
+ }
10124
+ async function noClobberRename(src, dst) {
10125
+ const st = await fsp4.lstat(fsPath(src));
10126
+ if (st.isFile()) {
10127
+ try {
10128
+ await fsp4.link(fsPath(src), fsPath(dst));
10129
+ await fsp4.unlink(fsPath(src));
10130
+ return;
10131
+ } catch (err) {
10132
+ const code2 = err.code;
10133
+ if (code2 !== "EPERM" && code2 !== "ENOTSUP" && code2 !== "EOPNOTSUPP" && code2 !== "EXDEV") throw err;
10134
+ }
10135
+ }
10136
+ try {
10137
+ await fsp4.lstat(fsPath(dst));
10138
+ const err = new Error(`${dst} exists`);
10139
+ err.code = "EEXIST";
10140
+ throw err;
10141
+ } catch (err) {
10142
+ if (err.code !== "ENOENT") throw err;
10143
+ }
10144
+ await fsp4.rename(fsPath(src), fsPath(dst));
10145
+ }
10146
+
10147
+ // ../hostcore/src/files/ops.ts
10148
+ var MAX_WRITE2 = 1024 * 1024;
10149
+ var FileOps = class {
10150
+ constructor(roots, trash) {
10151
+ this.roots = roots;
10152
+ this.trash = trash;
10153
+ }
10154
+ roots;
10155
+ trash;
10156
+ scratch(ref) {
10157
+ return path19.join(this.roots.stateDir(ref.root), "uploads");
10158
+ }
10159
+ async realRoot(ref) {
10160
+ return this.roots.realRoot(ref.root);
10161
+ }
10162
+ /** Write a small text file: new by default, replaced with `overwrite`. */
10163
+ async write(body) {
10164
+ if (typeof body.content !== "string") throw new FilesError(400, "content must be text");
10165
+ const data = Buffer.from(body.content, "utf8");
10166
+ if (data.length > MAX_WRITE2) throw new FilesError(413, `write takes at most ${MAX_WRITE2} bytes; upload larger files`);
10167
+ const dest = await this.roots.creatable(body.path);
10168
+ this.roots.assertMutable(dest);
10169
+ await this.roots.ensureParent(dest);
10170
+ if (body.overwrite === true) {
10171
+ const existing = await fsp5.lstat(fsPath(dest.fs)).catch(() => null);
10172
+ if (existing?.isDirectory()) throw new FilesError(409, `${dest.abs} is a directory`, "is-a-directory");
10173
+ const dir = this.scratch(dest);
10174
+ await fsp5.mkdir(dir, { recursive: true });
10175
+ const tmp = scratchName(dir, "write");
10176
+ await fsp5.writeFile(tmp, data);
10177
+ if (existing?.isFile()) await fsp5.chmod(tmp, existing.mode & 4095);
10178
+ try {
10179
+ await fsp5.rename(tmp, fsPath(dest.fs));
10180
+ } catch (err) {
10181
+ await fsp5.rm(tmp, { force: true });
10182
+ throw fsError(err, dest.abs);
10183
+ }
10184
+ } else {
10185
+ try {
10186
+ await fsp5.writeFile(fsPath(dest.fs), data, { flag: "wx" });
10187
+ } catch (err) {
10188
+ throw fsError(err, dest.abs);
10189
+ }
10190
+ }
10191
+ return describe(dest.abs, dest.fs, await this.realRoot(dest));
10192
+ }
10193
+ /** Make a directory and any missing parents. An existing directory is fine. */
10194
+ async mkdir(body) {
10195
+ const dest = await this.roots.creatable(body.path);
10196
+ this.roots.assertMutable(dest);
10197
+ await this.roots.ensureParent(dest);
10198
+ try {
10199
+ await fsp5.mkdir(fsPath(dest.fs));
10200
+ } catch (err) {
10201
+ if (err.code !== "EEXIST") throw fsError(err, dest.abs);
10202
+ const st = await fsp5.lstat(fsPath(dest.fs));
10203
+ if (!st.isDirectory()) throw new FilesError(409, `${dest.abs} exists and is not a directory`, "exists");
10204
+ }
10205
+ return describe(dest.abs, dest.fs, await this.realRoot(dest));
10206
+ }
10207
+ /** Rename or move `from` to `to` — the entry itself, not what a link points at. */
10208
+ async move(body) {
10209
+ const src = await this.roots.entry(body.from);
10210
+ this.roots.assertMutable(src);
10211
+ await this.roots.assertMovable(src);
10212
+ const dest = await this.roots.creatable(body.to);
10213
+ this.roots.assertMutable(dest);
10214
+ if (dest.abs === src.abs) return describe(src.abs, src.fs, await this.roots.realRoot(src.root));
10215
+ await this.refuseIntoItself(src.fs, dest.fs, src.abs);
10216
+ await this.clear(dest, body.overwrite === true);
10217
+ await this.roots.ensureParent(dest);
10218
+ try {
10219
+ if (src.root.id === dest.root.id) await noClobberRename(src.fs, dest.fs);
10220
+ else await moveTree(src.fs, dest.fs, this.scratch(dest));
10221
+ } catch (err) {
10222
+ throw fsError(err, dest.abs);
10223
+ }
10224
+ return describe(dest.abs, dest.fs, await this.realRoot(dest));
10225
+ }
10226
+ /**
10227
+ * Copy `from` to `to`. A directory is copied whole, symlinks as symlinks.
10228
+ * The copy is built under a scratch name and renamed into place, so the
10229
+ * destination never shows a half-finished tree.
10230
+ */
10231
+ async copy(body) {
10232
+ const src = await this.roots.entry(body.from);
10233
+ await this.roots.assertMovable(src);
10234
+ const dest = await this.roots.creatable(body.to);
10235
+ this.roots.assertMutable(dest);
10236
+ if (dest.abs === src.abs) throw new FilesError(409, "a copy needs a different name", "exists");
10237
+ await this.refuseIntoItself(src.fs, dest.fs, src.abs);
10238
+ await this.clear(dest, body.overwrite === true);
10239
+ await this.roots.ensureParent(dest);
10240
+ const dir = this.scratch(dest);
10241
+ await fsp5.mkdir(dir, { recursive: true });
10242
+ const tmp = scratchName(dir, "copy");
10243
+ try {
10244
+ await copyTree(src.fs, tmp);
10245
+ await noClobberRename(tmp, dest.fs);
10246
+ } catch (err) {
10247
+ await removeTree(tmp).catch(() => {
10248
+ });
10249
+ throw fsError(err, dest.abs);
10250
+ }
10251
+ return describe(dest.abs, dest.fs, await this.realRoot(dest));
10252
+ }
10253
+ /** A directory cannot be moved or copied into its own subtree. */
10254
+ async refuseIntoItself(srcFs, destFs, abs) {
10255
+ const st = await fsp5.lstat(fsPath(srcFs));
10256
+ if (st.isDirectory() && within2(destFs, srcFs)) {
10257
+ throw new FilesError(400, `${abs} cannot go inside itself`, "into-itself");
10258
+ }
10259
+ }
10260
+ /** Make way at `dest`: refuse, or with `overwrite` move what is there to the trash. */
10261
+ async clear(dest, overwrite) {
10262
+ const st = await fsp5.lstat(fsPath(dest.fs)).catch(() => null);
10263
+ if (!st) return;
10264
+ if (!overwrite) throw new FilesError(409, `${dest.abs} already exists`, "exists");
10265
+ await this.trash.put(dest.root, dest.abs, dest.fs);
10266
+ }
10267
+ };
10268
+
10269
+ // ../hostcore/src/files/trash.ts
10270
+ import { randomBytes as randomBytes9 } from "node:crypto";
10271
+ import fsp6 from "node:fs/promises";
10272
+ import path20 from "node:path";
10273
+ var TRASH_ID = /^[0-9a-f]{24}$/;
10274
+ var Trash = class {
10275
+ constructor(roots) {
10276
+ this.roots = roots;
10277
+ }
10278
+ roots;
10279
+ dir(root) {
10280
+ return path20.join(this.roots.stateDir(root), "trash");
10281
+ }
10282
+ async trash(paths2) {
10283
+ if (!Array.isArray(paths2) || paths2.length === 0) throw new FilesError(400, "paths required");
10284
+ const result = { trashed: [], missing: [] };
10285
+ for (const p of paths2) {
10286
+ const loc = this.roots.locate(p);
10287
+ this.roots.assertMutable(loc);
10288
+ let ref;
10289
+ try {
10290
+ ref = await this.roots.entry(p);
10291
+ } catch (err) {
10292
+ if (err instanceof FilesError && err.status === 404) {
10293
+ result.missing.push(loc.abs);
10294
+ continue;
10295
+ }
10296
+ throw err;
10297
+ }
10298
+ result.trashed.push(await this.put(ref.root, ref.abs, ref.fs));
10299
+ }
10300
+ return result;
10301
+ }
10302
+ /** Move the entry at real path `fs` (reported as `abs`) into `root`'s trash. */
10303
+ async put(root, abs, fs26) {
10304
+ const st = await fsp6.lstat(fsPath(fs26)).catch((err) => {
10305
+ throw fsError(err, abs);
10306
+ });
10307
+ const id = randomBytes9(12).toString("hex");
10308
+ const home2 = path20.join(this.dir(root), id);
10309
+ const type = typeOf(st);
10310
+ const meta = {
10311
+ v: 1,
10312
+ id,
10313
+ name: path20.basename(abs),
10314
+ originalPath: abs,
10315
+ root: root.id,
10316
+ type,
10317
+ size: type === "file" ? st.size : null,
10318
+ trashedAt: Date.now()
10319
+ };
10320
+ await fsp6.mkdir(home2, { recursive: true });
10321
+ await writeJson(path20.join(home2, "meta.json"), meta);
10322
+ try {
10323
+ await moveTree(fs26, path20.join(home2, "item"), home2);
10324
+ } catch (err) {
10325
+ await removeTree(home2).catch(() => {
10326
+ });
10327
+ throw fsError(err, abs);
10328
+ }
10329
+ return view(meta);
10330
+ }
10331
+ /** Everything in every root's trash, most recent first. */
10332
+ async list() {
10333
+ const items = [];
10334
+ for (const root of this.roots.list()) {
10335
+ let ids;
10336
+ try {
10337
+ ids = await fsp6.readdir(this.dir(root));
10338
+ } catch {
10339
+ continue;
10340
+ }
10341
+ for (const id of ids) {
10342
+ if (!TRASH_ID.test(id)) continue;
10343
+ const meta = await this.read(root, id);
10344
+ if (meta) items.push(view(meta));
10345
+ }
10346
+ }
10347
+ return items.sort((a, b) => b.trashedAt - a.trashedAt);
10348
+ }
10349
+ async read(root, id) {
10350
+ try {
10351
+ const parsed = JSON.parse(await fsp6.readFile(path20.join(this.dir(root), id, "meta.json"), "utf8"));
10352
+ return parsed && parsed.id === id ? parsed : null;
10353
+ } catch {
10354
+ return null;
10355
+ }
10356
+ }
10357
+ async find(id) {
10358
+ if (!TRASH_ID.test(id)) throw new FilesError(400, "invalid trash id");
10359
+ for (const root of this.roots.list()) {
10360
+ const meta = await this.read(root, id);
10361
+ if (meta) return { root, meta };
10362
+ }
10363
+ throw new FilesError(404, "no such item in the trash", "not-found");
10364
+ }
10365
+ /**
10366
+ * Put an item back where it came from, or at `to`. The destination must not
10367
+ * exist; missing parent directories are recreated.
10368
+ */
10369
+ async restore(id, to) {
10370
+ const { root, meta } = await this.find(id);
10371
+ const home2 = path20.join(this.dir(root), id);
10372
+ const dest = await this.roots.creatable(to === void 0 ? meta.originalPath : to);
10373
+ this.roots.assertMutable(dest);
10374
+ try {
10375
+ await fsp6.lstat(fsPath(path20.join(home2, "item")));
10376
+ } catch {
10377
+ await removeTree(home2);
10378
+ throw new FilesError(410, "the trashed item is gone", "gone");
10379
+ }
10380
+ await this.roots.ensureParent(dest);
10381
+ try {
10382
+ await fsp6.lstat(fsPath(dest.fs));
10383
+ throw new FilesError(409, `${dest.abs} already exists`, "exists");
10384
+ } catch (err) {
10385
+ if (err instanceof FilesError) throw err;
10386
+ }
10387
+ try {
10388
+ if (dest.root.id === root.id) await noClobberRename(path20.join(home2, "item"), dest.fs);
10389
+ else await moveTree(path20.join(home2, "item"), dest.fs, path20.join(this.roots.stateDir(dest.root), "uploads"));
10390
+ } catch (err) {
10391
+ throw fsError(err, dest.abs);
10392
+ }
10393
+ await removeTree(home2);
10394
+ return describe(dest.abs, dest.fs, await this.roots.realRoot(dest.root));
10395
+ }
10396
+ /** Delete an item for good. */
10397
+ async remove(id) {
10398
+ const { root } = await this.find(id);
10399
+ await removeTree(path20.join(this.dir(root), id));
10400
+ }
10401
+ /**
10402
+ * Delete for good what was trashed before `before` (ms), and entries whose
10403
+ * metadata cannot be read; returns how many went. The box keeps its trash
10404
+ * until it is emptied; a machine's node sweeps its own after 7 days.
10405
+ */
10406
+ async expire(before) {
10407
+ let n = 0;
10408
+ for (const root of this.roots.list()) {
10409
+ let ids;
10410
+ try {
10411
+ ids = await fsp6.readdir(this.dir(root));
10412
+ } catch {
10413
+ continue;
10414
+ }
10415
+ for (const id of ids) {
10416
+ if (!TRASH_ID.test(id)) continue;
10417
+ const meta = await this.read(root, id);
10418
+ if (meta && meta.trashedAt >= before) continue;
10419
+ if (!meta) {
10420
+ const st = await fsp6.stat(path20.join(this.dir(root), id)).catch(() => null);
10421
+ if (st && st.mtimeMs >= before) continue;
10422
+ }
10423
+ await removeTree(path20.join(this.dir(root), id)).catch(() => {
10424
+ });
10425
+ n += 1;
10426
+ }
10427
+ }
10428
+ return n;
10429
+ }
10430
+ /** Delete everything in the trash for good; returns how many items went. */
10431
+ async empty() {
10432
+ const items = await this.list();
10433
+ for (const item of items) await this.remove(item.id).catch(() => {
10434
+ });
10435
+ return items.length;
10436
+ }
10437
+ };
10438
+ function view(meta) {
10439
+ return {
10440
+ id: meta.id,
10441
+ name: meta.name,
10442
+ originalPath: meta.originalPath,
10443
+ root: meta.root,
10444
+ type: meta.type,
10445
+ size: meta.size,
10446
+ trashedAt: meta.trashedAt
10447
+ };
10448
+ }
10449
+ async function writeJson(file, value) {
10450
+ const tmp = `${file}.${randomBytes9(4).toString("hex")}.tmp`;
10451
+ await fsp6.writeFile(tmp, JSON.stringify(value));
10452
+ await fsp6.rename(tmp, file);
10453
+ }
10454
+
10455
+ // ../hostcore/src/files/uploads.ts
10456
+ import { randomBytes as randomBytes10 } from "node:crypto";
10457
+ import { createWriteStream } from "node:fs";
10458
+ import fsp7 from "node:fs/promises";
10459
+ import path21 from "node:path";
10460
+ import { Transform as Transform2 } from "node:stream";
10461
+ import { pipeline } from "node:stream/promises";
10462
+ var MAX_CHUNK2 = 50 * 1024 * 1024;
10463
+ var UPLOAD_TTL_MS = 24 * 60 * 60 * 1e3;
10464
+ var UPLOAD_ID = /^[0-9a-f]{32}$/;
10465
+ var Uploads = class {
10466
+ constructor(roots, trash, opts = {}) {
10467
+ this.roots = roots;
10468
+ this.trash = trash;
10469
+ this.opts = opts;
10470
+ }
10471
+ roots;
10472
+ trash;
10473
+ opts;
10474
+ queues = /* @__PURE__ */ new Map();
10475
+ dir(root) {
10476
+ return path21.join(this.roots.stateDir(root), "uploads");
10477
+ }
10478
+ /** Run `work` after any earlier work on the same upload has settled. */
10479
+ serial(id, work) {
10480
+ const prev = this.queues.get(id) ?? Promise.resolve();
10481
+ const next = prev.then(work, work);
10482
+ const tail = next.then(
10483
+ () => {
10484
+ },
10485
+ () => {
10486
+ }
10487
+ );
10488
+ this.queues.set(id, tail);
10489
+ void tail.then(() => {
10490
+ if (this.queues.get(id) === tail) this.queues.delete(id);
10491
+ });
10492
+ return next;
10493
+ }
10494
+ async start(body) {
10495
+ const size = body.size;
10496
+ if (typeof size !== "number" || !Number.isSafeInteger(size) || size < 0) {
10497
+ throw new FilesError(400, "size must be a byte count");
10498
+ }
10499
+ const overwrite = body.overwrite === true;
10500
+ const dest = await this.roots.creatable(body.path);
10501
+ this.roots.assertMutable(dest);
10502
+ await this.checkDestination(dest.fs, dest.abs, overwrite);
10503
+ const root = dest.root;
10504
+ const dir = this.dir(root);
10505
+ await fsp7.mkdir(dir, { recursive: true });
10506
+ const fsStat = await fsp7.statfs(dir);
10507
+ if (size > fsStat.bavail * fsStat.bsize) {
10508
+ throw new FilesError(507, "not enough space left on the volume for this upload", "no-space");
10509
+ }
10510
+ const id = randomBytes10(16).toString("hex");
10511
+ const now = Date.now();
10512
+ const rec = {
10513
+ v: 1,
10514
+ id,
10515
+ root: root.id,
10516
+ path: dest.abs,
10517
+ size,
10518
+ received: 0,
10519
+ overwrite,
10520
+ created: now,
10521
+ updated: now,
10522
+ done: false
10523
+ };
10524
+ await fsp7.writeFile(path21.join(dir, `${id}.part`), "");
10525
+ await this.save(rec);
10526
+ return view2(rec);
10527
+ }
10528
+ async checkDestination(fs26, abs, overwrite) {
10529
+ try {
10530
+ const st = await fsp7.lstat(fsPath(fs26));
10531
+ if (st.isDirectory()) throw new FilesError(409, `${abs} is a directory`, "is-a-directory");
10532
+ if (!overwrite) throw new FilesError(409, `${abs} already exists`, "exists");
10533
+ } catch (err) {
10534
+ if (err instanceof FilesError) throw err;
10535
+ if (err.code !== "ENOENT") throw fsError(err, abs);
10536
+ }
10537
+ }
10538
+ async save(rec) {
10539
+ const file = path21.join(this.dir(this.roots.byId(rec.root)), `${rec.id}.json`);
10540
+ const tmp = `${file}.${randomBytes10(4).toString("hex")}.tmp`;
10541
+ await fsp7.writeFile(tmp, JSON.stringify(rec));
10542
+ await fsp7.rename(tmp, file);
10543
+ }
10544
+ async load(id) {
10545
+ if (!UPLOAD_ID.test(id)) throw new FilesError(400, "invalid upload id");
10546
+ for (const root of this.roots.list()) {
10547
+ try {
10548
+ const rec = JSON.parse(await fsp7.readFile(path21.join(this.dir(root), `${id}.json`), "utf8"));
10549
+ if (rec.id === id) return rec;
10550
+ } catch {
10551
+ }
10552
+ }
10553
+ throw new FilesError(404, "no such upload", "not-found");
10554
+ }
10555
+ async get(id) {
10556
+ return view2(await this.load(id));
10557
+ }
10558
+ /**
10559
+ * Write one chunk at `offset`. `length` is the declared body length when the
10560
+ * request gave one, checked before a byte is read.
10561
+ */
10562
+ put(id, offset, body, length) {
10563
+ return this.serial(id, async () => {
10564
+ const rec = await this.load(id);
10565
+ if (rec.done) throw new FilesError(409, "this upload has already finished", "finished");
10566
+ const at = Number(offset);
10567
+ if (!Number.isSafeInteger(at) || at < 0) throw new FilesError(400, "offset must be a byte offset");
10568
+ if (at > rec.received) {
10569
+ throw new FilesError(409, `expected a chunk at offset ${rec.received}`, `offset:${rec.received}`);
10570
+ }
10571
+ const max = this.opts.maxChunk ?? MAX_CHUNK2;
10572
+ if (length !== null && length > max) throw new FilesError(413, `a chunk may be at most ${max} bytes`, "too-large");
10573
+ if (length !== null && at + length > rec.size) {
10574
+ throw new FilesError(400, "the chunk runs past the declared size", "past-end");
10575
+ }
10576
+ let written = 0;
10577
+ const limit = new Transform2({
10578
+ transform(chunk, _enc, cb) {
10579
+ written += chunk.length;
10580
+ if (written > max) cb(new FilesError(413, `a chunk may be at most ${max} bytes`, "too-large"));
10581
+ else if (at + written > rec.size) cb(new FilesError(400, "the chunk runs past the declared size", "past-end"));
10582
+ else cb(null, chunk);
10583
+ }
10584
+ });
10585
+ const part = path21.join(this.dir(this.roots.byId(rec.root)), `${id}.part`);
10586
+ await pipeline(body, limit, createWriteStream(part, { flags: "r+", start: at }));
10587
+ rec.received = Math.max(rec.received, at + written);
10588
+ rec.updated = Date.now();
10589
+ await this.save(rec);
10590
+ return view2(rec);
10591
+ });
10592
+ }
10593
+ /** Move the finished file into place. Repeating it answers the same. */
10594
+ finish(id) {
10595
+ return this.serial(id, async () => {
10596
+ const rec = await this.load(id);
10597
+ if (rec.done && rec.entry) return rec.entry;
10598
+ if (rec.received !== rec.size) {
10599
+ throw new FilesError(409, `only ${rec.received} of ${rec.size} bytes have arrived`, `offset:${rec.received}`);
10600
+ }
10601
+ const root = this.roots.byId(rec.root);
10602
+ const part = path21.join(this.dir(root), `${id}.part`);
10603
+ const fh = await fsp7.open(part, "r+");
10604
+ try {
10605
+ await fh.truncate(rec.size);
10606
+ await fh.sync();
10607
+ } finally {
10608
+ await fh.close();
10609
+ }
10610
+ const dest = await this.roots.creatable(rec.path);
10611
+ this.roots.assertMutable(dest);
10612
+ await this.checkDestination(dest.fs, dest.abs, rec.overwrite);
10613
+ await this.roots.ensureParent(dest);
10614
+ const existing = await fsp7.lstat(fsPath(dest.fs)).catch(() => null);
10615
+ if (existing) {
10616
+ if (existing.isFile()) await fsp7.chmod(part, existing.mode & 4095);
10617
+ await this.trash.put(dest.root, dest.abs, dest.fs);
10618
+ }
10619
+ try {
10620
+ await noClobberRename(part, dest.fs);
10621
+ } catch (err) {
10622
+ throw fsError(err, dest.abs);
10623
+ }
10624
+ const entry = await describe(dest.abs, dest.fs, await this.roots.realRoot(dest.root));
10625
+ rec.done = true;
10626
+ rec.entry = entry;
10627
+ rec.updated = Date.now();
10628
+ await this.save(rec);
10629
+ return entry;
10630
+ });
10631
+ }
10632
+ /** Abandon an upload and its data. */
10633
+ cancel(id) {
10634
+ return this.serial(id, async () => {
10635
+ const rec = await this.load(id);
10636
+ const dir = this.dir(this.roots.byId(rec.root));
10637
+ await fsp7.rm(path21.join(dir, `${id}.part`), { force: true });
10638
+ await fsp7.rm(path21.join(dir, `${id}.json`), { force: true });
10639
+ });
10640
+ }
10641
+ /**
10642
+ * Remove uploads (finished or not) untouched for longer than the TTL, plus
10643
+ * any scratch file left behind by an interrupted copy or move.
10644
+ */
10645
+ async sweep(now = Date.now()) {
10646
+ const ttl = this.opts.ttlMs ?? UPLOAD_TTL_MS;
10647
+ let removed = 0;
10648
+ for (const root of this.roots.list()) {
10649
+ const dir = this.dir(root);
10650
+ let names;
10651
+ try {
10652
+ names = await fsp7.readdir(dir);
10653
+ } catch {
10654
+ continue;
10655
+ }
10656
+ for (const name of names) {
10657
+ const file = path21.join(dir, name);
10658
+ const st = await fsp7.lstat(file).catch(() => null);
10659
+ if (!st || now - st.mtimeMs < ttl) continue;
10660
+ const m = /^([0-9a-f]{32})\.(json|part)$/.exec(name);
10661
+ if (m && name.endsWith(".json")) {
10662
+ const rec = await this.load(m[1]).catch(() => null);
10663
+ if (rec && now - rec.updated < ttl) continue;
10664
+ }
10665
+ await removeTree(file).catch(() => {
10666
+ });
10667
+ removed += 1;
10668
+ }
10669
+ }
10670
+ return removed;
10671
+ }
10672
+ /** Sweep now and then every hour, until the returned stop is called. */
10673
+ startSweeping(everyMs = 60 * 60 * 1e3) {
10674
+ void this.sweep().catch(() => {
10675
+ });
10676
+ const timer = setInterval(() => void this.sweep().catch(() => {
10677
+ }), everyMs);
10678
+ timer.unref?.();
10679
+ return () => clearInterval(timer);
10680
+ }
10681
+ };
10682
+ function view2(rec) {
10683
+ return {
10684
+ id: rec.id,
10685
+ path: rec.path,
10686
+ size: rec.size,
10687
+ received: rec.received,
10688
+ overwrite: rec.overwrite,
10689
+ created: rec.created,
10690
+ updated: rec.updated,
10691
+ done: rec.done
10692
+ };
10693
+ }
10694
+
10695
+ // ../hostcore/src/files/service.ts
10696
+ var FilesService = class {
10697
+ roots;
10698
+ git;
10699
+ trash;
10700
+ uploads;
10701
+ ops;
10702
+ fd;
10703
+ /** Sorted directory names, shared by the listing and WebDAV. */
10704
+ listings = new ListingCache();
10705
+ constructor(opts) {
10706
+ this.roots = new Roots({
10707
+ roots: opts.roots,
10708
+ ...opts.caseInsensitive === void 0 ? {} : { caseInsensitive: opts.caseInsensitive },
10709
+ ...opts.isHidden === void 0 ? {} : { isHidden: opts.isHidden },
10710
+ ...opts.isWriteDenied === void 0 ? {} : { isWriteDenied: opts.isWriteDenied },
10711
+ ...opts.refusePath === void 0 ? {} : { refusePath: opts.refusePath }
10712
+ });
10713
+ this.git = new GitStatusCache(opts.gitTtlMs === void 0 ? {} : { ttlMs: opts.gitTtlMs });
10714
+ this.trash = new Trash(this.roots);
10715
+ this.uploads = new Uploads(this.roots, this.trash, opts.maxChunk === void 0 ? {} : { maxChunk: opts.maxChunk });
10716
+ this.ops = new FileOps(this.roots, this.trash);
10717
+ this.fd = opts.fd;
10718
+ }
10719
+ /** Sweep abandoned uploads now and hourly; returns a stop. */
10720
+ startMaintenance() {
10721
+ return this.uploads.startSweeping();
10722
+ }
10723
+ };
10724
+
10725
+ // ../hostcore/src/files/http.ts
10726
+ import { createReadStream } from "node:fs";
10727
+ import fsp9 from "node:fs/promises";
10728
+ import path23 from "node:path";
10729
+ import { pipeline as pipeline2 } from "node:stream";
10730
+
10731
+ // ../hostcore/src/files/raw.ts
10732
+ var INLINE_TYPES = {
10733
+ ".png": "image/png",
10734
+ ".jpg": "image/jpeg",
10735
+ ".jpeg": "image/jpeg",
10736
+ ".gif": "image/gif",
10737
+ ".webp": "image/webp",
10738
+ ".avif": "image/avif",
10739
+ ".bmp": "image/bmp",
10740
+ ".ico": "image/x-icon",
10741
+ ".pdf": "application/pdf"
10742
+ };
10743
+ var DOWNLOAD_TYPES = {
10744
+ ...INLINE_TYPES,
10745
+ ".html": "text/html",
10746
+ ".htm": "text/html",
10747
+ ".svg": "image/svg+xml",
10748
+ ".json": "application/json",
10749
+ // Never a script or stylesheet type: with nosniff, a browser then refuses
10750
+ // to run the file as code even if it is asked to (see loadedAsCode).
10751
+ ".js": "text/plain",
10752
+ ".mjs": "text/plain",
10753
+ ".css": "text/plain",
10754
+ ".md": "text/markdown",
10755
+ ".txt": "text/plain",
10756
+ ".csv": "text/csv",
10757
+ ".xml": "application/xml",
10758
+ ".zip": "application/zip",
10759
+ ".gz": "application/gzip",
10760
+ ".tar": "application/x-tar",
10761
+ ".mp4": "video/mp4",
10762
+ ".webm": "video/webm",
10763
+ ".mp3": "audio/mpeg",
10764
+ ".wav": "audio/wav",
10765
+ ".woff2": "font/woff2"
10766
+ };
10767
+ function etagOf(st) {
10768
+ return `"${st.size.toString(16)}-${Math.round(st.mtimeMs).toString(16)}"`;
10769
+ }
10770
+ function looksLikeText(head) {
10771
+ if (head.includes(0)) return false;
10772
+ for (let cut = 0; cut < 4 && cut <= head.length; cut++) {
10773
+ try {
10774
+ new TextDecoder("utf-8", { fatal: true }).decode(head.subarray(0, head.length - cut));
10775
+ return true;
10776
+ } catch {
10777
+ }
10778
+ }
10779
+ return false;
10780
+ }
10781
+ function disposition(kind, name) {
10782
+ const ascii = name.replace(/[^\x20-\x7e]|["\\%;]/g, "_") || "download";
10783
+ const exact = [...encodeName(name)].map((b) => /[A-Za-z0-9!#$&+.^_`|~-]/.test(String.fromCharCode(b)) ? String.fromCharCode(b) : `%${b.toString(16).toUpperCase().padStart(2, "0")}`).join("");
10784
+ return `${kind}; filename="${ascii}"; filename*=UTF-8''${exact}`;
10785
+ }
10786
+ function parseRange(header, size) {
10787
+ if (!header) return null;
10788
+ const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
10789
+ if (!m) return null;
10790
+ const [, a, b] = m;
10791
+ if (a === "" && b === "") return null;
10792
+ let start;
10793
+ let end;
10794
+ if (a === "") {
10795
+ const n = Number(b);
10796
+ if (n === 0) return "bad";
10797
+ start = Math.max(0, size - n);
10798
+ end = size - 1;
10799
+ } else {
10800
+ start = Number(a);
10801
+ end = b === "" ? size - 1 : Math.min(Number(b), size - 1);
10802
+ }
10803
+ if (start >= size || start > end) return "bad";
10804
+ return { start, end };
10805
+ }
10806
+
10807
+ // ../hostcore/src/files/search.ts
10808
+ import { spawn as spawn8 } from "node:child_process";
10809
+ import fs17 from "node:fs";
10810
+ import fsp8 from "node:fs/promises";
10811
+ import path22 from "node:path";
10812
+ var SKIP = /* @__PURE__ */ new Set([".git", "node_modules", ".agentbox"]);
10813
+ var fdCache;
10814
+ function findFd(env = process.env) {
10815
+ if (fdCache !== void 0) return fdCache;
10816
+ fdCache = null;
10817
+ for (const dir of (env.PATH ?? "").split(path22.delimiter)) {
10818
+ for (const name of ["fdfind", "fd"]) {
10819
+ const candidate = path22.join(dir, name);
10820
+ try {
10821
+ fs17.accessSync(candidate, fs17.constants.X_OK);
10822
+ fdCache = candidate;
10823
+ return fdCache;
10824
+ } catch {
10825
+ }
10826
+ }
10827
+ }
10828
+ return fdCache;
10829
+ }
10830
+ function score(rel, query) {
10831
+ const q = query.toLowerCase();
10832
+ const name = path22.basename(rel).toLowerCase();
10833
+ let s = name === q ? 0 : name.startsWith(q) ? 1 : name.includes(q) ? 2 : 3;
10834
+ s = s * 1e3 + rel.split("/").length * 10 + Math.min(rel.length, 9);
10835
+ return s;
10836
+ }
10837
+ async function searchNames(opts) {
10838
+ const query = opts.query.trim();
10839
+ if (!query) return [];
10840
+ const fd = opts.fd === void 0 ? findFd() : opts.fd;
10841
+ const gather = Math.min(opts.limit * 10, 2e3);
10842
+ const found = fd ? await runFd(fd, opts.dir, query, gather, opts.timeoutMs ?? 5e3) : await walk(opts.dir, query, gather);
10843
+ const scored = found.filter((p) => within2(p, opts.dir) && p !== opts.dir).map((p) => ({ p, s: score(path22.relative(opts.dir, p), query) })).sort((a, b) => a.s - b.s || a.p.localeCompare(b.p));
10844
+ return scored.slice(0, opts.limit).map((x) => x.p);
10845
+ }
10846
+ function runFd(bin, dir, query, max, timeoutMs) {
10847
+ const args = [
10848
+ "--fixed-strings",
10849
+ "--ignore-case",
10850
+ "--absolute-path",
10851
+ "--print0",
10852
+ "--color=never",
10853
+ "--max-results",
10854
+ String(max),
10855
+ ...[...SKIP].flatMap((d) => ["--exclude", d])
10856
+ ];
10857
+ if (query.includes("/")) args.push("--full-path");
10858
+ args.push("--", query, dir);
10859
+ return new Promise((resolve) => {
10860
+ const child = spawn8(bin, args, { stdio: ["ignore", "pipe", "ignore"] });
10861
+ const chunks = [];
10862
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
10863
+ child.stdout.on("data", (c) => chunks.push(c));
10864
+ const done = () => {
10865
+ clearTimeout(timer);
10866
+ const out = Buffer.concat(chunks);
10867
+ const paths2 = [];
10868
+ let start = 0;
10869
+ for (let i = 0; i < out.length; i++) {
10870
+ if (out[i] === 0) {
10871
+ paths2.push(decodeName(out.subarray(start, i)).replace(/\/+$/, ""));
10872
+ start = i + 1;
10873
+ }
10874
+ }
10875
+ resolve(paths2);
10876
+ };
10877
+ child.on("error", done);
10878
+ child.on("close", done);
10879
+ });
10880
+ }
10881
+ async function walk(dir, query, max) {
10882
+ const q = query.toLowerCase();
10883
+ const full = q.includes("/");
10884
+ const out = [];
10885
+ const queue = [dir];
10886
+ let visited = 0;
10887
+ while (queue.length > 0 && out.length < max && visited < 2e4) {
10888
+ const d = queue.shift();
10889
+ visited += 1;
10890
+ let names;
10891
+ try {
10892
+ names = await fsp8.readdir(fsPath(d), { encoding: "buffer" });
10893
+ } catch {
10894
+ continue;
10895
+ }
10896
+ for (const raw2 of names) {
10897
+ const name = decodeName(raw2);
10898
+ if (name.startsWith(".") || SKIP.has(name)) continue;
10899
+ const p = path22.join(d, name);
10900
+ const hay = (full ? path22.relative(dir, p) : name).toLowerCase();
10901
+ if (hay.includes(q)) out.push(p);
10902
+ const st = await fsp8.lstat(fsPath(p)).catch(() => null);
10903
+ if (st?.isDirectory()) queue.push(p);
10904
+ }
10905
+ }
10906
+ return out;
10907
+ }
10908
+
10909
+ // ../hostcore/src/files/http.ts
10910
+ var MAX_JSON = 64 * 1024;
10911
+ var MAX_WRITE_JSON = 6 * 1024 * 1024;
10912
+ function send(res, status3, body) {
10913
+ const text = JSON.stringify(body);
10914
+ res.writeHead(status3, { "content-type": "application/json", "content-length": Buffer.byteLength(text), "cache-control": "no-store" });
10915
+ res.end(text);
10916
+ }
10917
+ function sendError(res, err) {
10918
+ const e = fsError(err, "path");
10919
+ if (!(e instanceof FilesError)) throw e;
10920
+ send(res, e.status, e.code ? { error: e.message, code: e.code } : { error: e.message });
10921
+ }
10922
+ function readBody(req, max) {
10923
+ return new Promise((resolve, reject) => {
10924
+ const chunks = [];
10925
+ let size = 0;
10926
+ req.on("data", (c) => {
10927
+ size += c.length;
10928
+ if (size > max) {
10929
+ reject(new FilesError(413, "request body too large"));
10930
+ req.destroy();
10931
+ return;
10932
+ }
10933
+ chunks.push(c);
10934
+ });
10935
+ req.on("end", () => {
10936
+ const text = Buffer.concat(chunks).toString("utf8");
10937
+ if (text.trim() === "") return resolve({});
10938
+ try {
10939
+ const v = JSON.parse(text);
10940
+ if (typeof v === "object" && v !== null && !Array.isArray(v)) return resolve(v);
10941
+ } catch {
10942
+ }
10943
+ reject(new FilesError(400, "the body must be a JSON object"));
10944
+ });
10945
+ req.on("error", reject);
10946
+ });
10947
+ }
10948
+ function one(q, key) {
10949
+ return q.get(key)?.[0];
10950
+ }
10951
+ function flag(q, key) {
10952
+ const v = one(q, key);
10953
+ return v !== void 0 && v !== "0" && v !== "false";
10954
+ }
10955
+ function int2(q, key, fallback, min, max) {
10956
+ const v = Number(one(q, key));
10957
+ return Number.isFinite(v) ? Math.min(max, Math.max(min, Math.trunc(v))) : fallback;
10958
+ }
10959
+ var CODE = /* @__PURE__ */ new Set(["script", "worker", "sharedworker", "serviceworker", "style", "audioworklet", "paintworklet", "xslt"]);
10960
+ async function isText(file, size) {
10961
+ if (size === 0) return true;
10962
+ const fh = await fsp9.open(fsPath(file), "r");
10963
+ try {
10964
+ const buf = Buffer.alloc(Math.min(8192, size));
10965
+ const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
10966
+ return looksLikeText(buf.subarray(0, bytesRead));
10967
+ } finally {
10968
+ await fh.close();
10969
+ }
10970
+ }
10971
+ async function raw(files, req, res, q) {
10972
+ const dest = req.headers["sec-fetch-dest"];
10973
+ if (typeof dest === "string" && CODE.has(dest.toLowerCase())) throw new FilesError(403, "a file cannot be loaded as a script or stylesheet", "not-code");
10974
+ const ref = await files.roots.target(one(q, "path"));
10975
+ const st = await fsp9.stat(fsPath(ref.real));
10976
+ if (!st.isFile()) throw new FilesError(409, `${ref.abs} is not a file`, "not-a-file");
10977
+ const name = path23.basename(ref.abs);
10978
+ const inline = flag(q, "inline") && await isText(ref.real, st.size);
10979
+ const headers = {
10980
+ "content-type": inline ? "text/plain; charset=utf-8" : "application/octet-stream",
10981
+ "content-disposition": disposition(inline ? "inline" : "attachment", name),
10982
+ "cache-control": "private, no-cache",
10983
+ "last-modified": st.mtime.toUTCString(),
10984
+ etag: etagOf(st)
10985
+ };
10986
+ const range = parseRange(req.headers.range, st.size);
10987
+ if (range === "bad") {
10988
+ res.writeHead(416, { ...headers, "content-range": `bytes */${st.size}`, "content-length": "0" });
10989
+ res.end();
10990
+ return;
10991
+ }
10992
+ const start = range ? range.start : 0;
10993
+ const end = range ? range.end : st.size - 1;
10994
+ const length = st.size === 0 ? 0 : end - start + 1;
10995
+ if (range) headers["content-range"] = `bytes ${start}-${end}/${st.size}`;
10996
+ headers["content-length"] = String(length);
10997
+ res.writeHead(range ? 206 : 200, headers);
10998
+ if (req.method === "HEAD" || length === 0) {
10999
+ res.end();
11000
+ return;
11001
+ }
11002
+ pipeline2(createReadStream(fsPath(ref.real), { start, end }), res, () => {
11003
+ });
11004
+ }
11005
+ async function search(files, q) {
11006
+ const { roots } = files;
11007
+ const dir = await roots.target(one(q, "path") ?? "");
11008
+ const rootReal = await roots.realRoot(dir.root);
11009
+ const found = await searchNames({
11010
+ dir: dir.real,
11011
+ query: one(q, "q") ?? "",
11012
+ limit: int2(q, "limit", 50, 1, 200),
11013
+ ...files.fd === void 0 ? {} : { fd: files.fd }
11014
+ });
11015
+ const out = [];
11016
+ for (const real of found) {
11017
+ if (!roots.inside(real, rootReal) || roots.hidden(real)) continue;
11018
+ try {
11019
+ out.push(await describe(roots.present(dir.root, rootReal, real), real, rootReal));
11020
+ } catch {
11021
+ }
11022
+ }
11023
+ return out;
11024
+ }
11025
+ var TRASH_ITEM = /^\/api\/files\/trash\/([0-9a-f]{24})(\/restore)?$/;
11026
+ var UPLOAD = /^\/api\/files\/uploads\/([0-9a-f]{32})(\/finish)?$/;
11027
+ async function serveFiles(files, req, res) {
11028
+ const url = req.url ?? "/";
11029
+ const pathname = url.split("?", 1)[0] ?? "/";
11030
+ if (pathname !== "/api/files" && !pathname.startsWith("/api/files/")) return false;
11031
+ const method = req.method ?? "GET";
11032
+ const q = parseQuery(url);
11033
+ const { roots, ops, trash, uploads } = files;
11034
+ try {
11035
+ const route = `${method} ${pathname}`;
11036
+ switch (route) {
11037
+ case "GET /api/files/list": {
11038
+ const dir = await roots.target(one(q, "path") ?? "");
11039
+ const st = await fsp9.stat(fsPath(dir.real));
11040
+ if (!st.isDirectory()) throw new FilesError(409, `${dir.abs} is not a directory`, "not-a-directory");
11041
+ send(
11042
+ res,
11043
+ 200,
11044
+ await listDirectory(roots, dir, {
11045
+ hidden: flag(q, "hidden"),
11046
+ offset: int2(q, "offset", 0, 0, Number.MAX_SAFE_INTEGER),
11047
+ limit: int2(q, "limit", MAX_PAGE, 1, MAX_PAGE),
11048
+ cache: files.listings
11049
+ })
11050
+ );
11051
+ return true;
11052
+ }
11053
+ case "GET /api/files/stat": {
11054
+ const ref = await roots.entry(one(q, "path") ?? "");
11055
+ send(res, 200, await describe(ref.abs, ref.fs, await roots.realRoot(ref.root)));
11056
+ return true;
11057
+ }
11058
+ case "GET /api/files/raw":
11059
+ case "HEAD /api/files/raw":
11060
+ await raw(files, req, res, q);
11061
+ return true;
11062
+ case "GET /api/files/zip":
11063
+ send(res, 501, { error: "folders cannot be downloaded as a zip from a machine yet; download its files one by one", code: "unsupported" });
11064
+ return true;
11065
+ case "GET /api/files/search":
11066
+ send(res, 200, await search(files, q));
11067
+ return true;
11068
+ case "POST /api/files/write":
11069
+ send(res, 200, await ops.write(await readBody(req, MAX_WRITE_JSON)));
11070
+ return true;
11071
+ case "POST /api/files/mkdir":
11072
+ send(res, 200, await ops.mkdir(await readBody(req, MAX_JSON)));
11073
+ return true;
11074
+ case "POST /api/files/move":
11075
+ send(res, 200, await ops.move(await readBody(req, MAX_JSON)));
11076
+ return true;
11077
+ case "POST /api/files/copy":
11078
+ send(res, 200, await ops.copy(await readBody(req, MAX_JSON)));
11079
+ return true;
11080
+ case "POST /api/files/trash":
11081
+ send(res, 200, await trash.trash((await readBody(req, MAX_JSON)).paths));
11082
+ return true;
11083
+ case "GET /api/files/trash":
11084
+ send(res, 200, await trash.list());
11085
+ return true;
11086
+ case "DELETE /api/files/trash":
11087
+ send(res, 200, { removed: await trash.empty() });
11088
+ return true;
11089
+ case "POST /api/files/uploads": {
11090
+ const session = await uploads.start(await readBody(req, MAX_JSON));
11091
+ send(res, 200, { uploadId: session.id, ...session });
11092
+ return true;
11093
+ }
11094
+ }
11095
+ const t2 = TRASH_ITEM.exec(pathname);
11096
+ if (t2) {
11097
+ const id = t2[1];
11098
+ if (t2[2] && method === "POST") {
11099
+ send(res, 200, await trash.restore(id, (await readBody(req, MAX_JSON)).to));
11100
+ return true;
11101
+ }
11102
+ if (!t2[2] && method === "DELETE") {
11103
+ await trash.remove(id);
11104
+ res.writeHead(204, { "cache-control": "no-store" });
11105
+ res.end();
11106
+ return true;
11107
+ }
11108
+ }
11109
+ const u = UPLOAD.exec(pathname);
11110
+ if (u) {
11111
+ const id = u[1];
11112
+ if (u[2] && method === "POST") {
11113
+ send(res, 200, await uploads.finish(id));
11114
+ return true;
11115
+ }
11116
+ if (!u[2]) {
11117
+ if (method === "GET") {
11118
+ send(res, 200, await uploads.get(id));
11119
+ return true;
11120
+ }
11121
+ if (method === "DELETE") {
11122
+ await uploads.cancel(id);
11123
+ res.writeHead(204, { "cache-control": "no-store" });
11124
+ res.end();
11125
+ return true;
11126
+ }
11127
+ if (method === "PUT") {
11128
+ const declared = req.headers["content-length"];
11129
+ const length = declared === void 0 ? null : Number(declared);
11130
+ const done = await uploads.put(id, one(q, "offset"), req, Number.isFinite(length) ? length : null);
11131
+ if (!req.readableEnded) res.setHeader("connection", "close");
11132
+ send(res, 200, done);
11133
+ return true;
11134
+ }
11135
+ }
11136
+ }
11137
+ send(res, 404, { error: "not found" });
11138
+ } catch (err) {
11139
+ if (!req.readableEnded) res.setHeader("connection", "close");
11140
+ if (res.headersSent) res.destroy();
11141
+ else sendError(res, err);
11142
+ }
11143
+ return true;
11144
+ }
11145
+
11146
+ // src/node/preview.ts
11147
+ import { EventEmitter as EventEmitter3 } from "node:events";
11148
+ import net5 from "node:net";
11149
+ import path25 from "node:path";
11150
+
11151
+ // src/node/service-linux.ts
11152
+ import { execFile } from "node:child_process";
11153
+ import fs18 from "node:fs";
11154
+ import os9 from "node:os";
11155
+ import path24 from "node:path";
11156
+ var UNIT_NAME = "agentbox-node.service";
11157
+ var runCommand = (cmd, args) => new Promise((resolve) => {
11158
+ execFile(cmd, args, { timeout: 3e4 }, (err, stdout, stderr) => {
11159
+ const code2 = err ? typeof err.code === "number" ? err.code : 127 : 0;
11160
+ resolve({ code: code2, stdout: String(stdout), stderr: String(stderr) });
11161
+ });
11162
+ });
11163
+ function unitDir(env = process.env, home2 = os9.homedir()) {
11164
+ const xdg = env.XDG_CONFIG_HOME;
11165
+ return path24.join(xdg && path24.isAbsolute(xdg) ? xdg : path24.join(home2, ".config"), "systemd", "user");
11166
+ }
11167
+ function execStart(execPath = process.execPath, script = process.argv[1]) {
11168
+ const isSea = script === void 0 || path24.resolve(script) === path24.resolve(execPath);
11169
+ return isSea ? [execPath, "node", "run"] : [execPath, path24.resolve(script), "node", "run"];
11170
+ }
11171
+ function quote(arg) {
11172
+ return `"${arg.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
11173
+ }
11174
+ function unitText(command2) {
11175
+ return [
11176
+ "[Unit]",
11177
+ "Description=agentbox node: this machine's link to its box",
11178
+ "After=network-online.target",
11179
+ "Wants=network-online.target",
11180
+ "",
11181
+ "[Service]",
11182
+ "Type=simple",
11183
+ `ExecStart=${command2.map(quote).join(" ")}`,
11184
+ "Restart=on-failure",
11185
+ "RestartSec=5",
11186
+ // herdr, and the agents in it, outlive a restart of the node (an
11187
+ // update, a crash); the next node adopts it.
11188
+ "KillMode=process",
11189
+ "",
11190
+ "[Install]",
11191
+ "WantedBy=default.target",
11192
+ ""
11193
+ ].join("\n");
11194
+ }
11195
+ async function installService(opts) {
11196
+ const run4 = opts.run ?? runCommand;
11197
+ const dir = unitDir(opts.env);
11198
+ fs18.mkdirSync(dir, { recursive: true });
11199
+ const unitFile = path24.join(dir, UNIT_NAME);
11200
+ fs18.writeFileSync(unitFile, unitText(opts.command), { mode: 420 });
11201
+ for (const args of [["--user", "daemon-reload"], ["--user", "enable", UNIT_NAME], ["--user", "restart", UNIT_NAME]]) {
11202
+ const r = await run4("systemctl", args);
11203
+ if (r.code !== 0) {
11204
+ throw new Error(`systemctl ${args.join(" ")} failed: ${(r.stderr || r.stdout).trim() || `exit ${r.code}`}`);
11205
+ }
11206
+ }
11207
+ const user = opts.user ?? os9.userInfo().username;
11208
+ const linger = await run4("loginctl", ["enable-linger", user]);
11209
+ if (linger.code === 0) return { unitFile, lingering: true, note: null };
11210
+ const cron = `@reboot ${opts.command.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ")}`;
11211
+ return {
11212
+ unitFile,
11213
+ lingering: false,
11214
+ 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\`:
11215
+ ${cron}`
11216
+ };
11217
+ }
11218
+ async function removeService(opts = {}) {
11219
+ const run4 = opts.run ?? runCommand;
11220
+ const unitFile = path24.join(unitDir(opts.env), UNIT_NAME);
11221
+ if (!fs18.existsSync(unitFile)) return false;
11222
+ await run4("systemctl", ["--user", "disable", "--now", UNIT_NAME]);
11223
+ fs18.rmSync(unitFile, { force: true });
11224
+ await run4("systemctl", ["--user", "daemon-reload"]);
11225
+ return true;
11226
+ }
11227
+ function serviceInstalled(env = process.env) {
11228
+ return fs18.existsSync(path24.join(unitDir(env), UNIT_NAME));
11229
+ }
11230
+
11231
+ // src/node/preview.ts
11232
+ var MAX_APPS = 64;
11233
+ var CLOSED_GRACE_MS = 6e4;
11234
+ var CHECK_MS2 = 5e3;
11235
+ function connectLocal(port, timeoutMs = 5e3) {
11236
+ const attempt2 = (host) => new Promise((resolve, reject) => {
11237
+ const s = net5.connect({ host, port });
11238
+ const timer = setTimeout(() => s.destroy(new Error("timed out")), timeoutMs);
11239
+ s.once("connect", () => {
11240
+ clearTimeout(timer);
11241
+ s.off("error", reject);
11242
+ resolve(s);
11243
+ });
11244
+ s.once("error", (err) => {
11245
+ clearTimeout(timer);
11246
+ reject(err);
11247
+ });
11248
+ });
11249
+ return attempt2("127.0.0.1").catch(() => attempt2("::1"));
11250
+ }
11251
+ async function answers(port) {
11252
+ try {
11253
+ const s = await connectLocal(port, 2e3);
11254
+ s.destroy();
11255
+ return true;
11256
+ } catch {
11257
+ return false;
11258
+ }
11259
+ }
11260
+ function cleanName(raw2, port) {
11261
+ const s = typeof raw2 === "string" ? raw2.replace(/[^\w .()+@-]/g, "").trim().slice(0, 64) : "";
11262
+ return s || `port ${port}`;
11263
+ }
11264
+ var Previews = class extends EventEmitter3 {
11265
+ constructor(o = {}) {
11266
+ super();
11267
+ this.o = o;
11268
+ }
11269
+ o;
11270
+ apps = /* @__PURE__ */ new Map();
11271
+ timer = null;
11272
+ get now() {
11273
+ return (this.o.now ?? Date.now)();
11274
+ }
11275
+ list() {
11276
+ return [...this.apps.values()].map(({ port, name, registeredAt }) => ({ port, name, registeredAt }));
11277
+ }
11278
+ has(port) {
11279
+ return this.apps.has(port);
11280
+ }
11281
+ /** Register `port` (it must answer now). */
11282
+ async register(rawPort, rawName) {
11283
+ const port = typeof rawPort === "number" ? rawPort : Number(rawPort);
11284
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return { ok: false, error: "a port is a number from 1 to 65535" };
11285
+ const existing = this.apps.get(port);
11286
+ if (!existing && this.apps.size >= MAX_APPS) return { ok: false, error: `this machine has ${MAX_APPS} apps registered already; stop one first` };
11287
+ if (!await (this.o.probe ?? answers)(port)) return { ok: false, error: `nothing answers on port ${port} (on 127.0.0.1 or [::1]); start the server first` };
11288
+ const name = rawName === void 0 && existing ? existing.name : cleanName(rawName, port);
11289
+ const app = { port, name, registeredAt: existing?.registeredAt ?? this.now, closedSince: null };
11290
+ this.apps.set(port, app);
11291
+ if (!existing || existing.name !== name) this.emit("change");
11292
+ return { ok: true, app: { port, name, registeredAt: app.registeredAt }, existed: existing !== void 0 };
11293
+ }
11294
+ remove(port) {
11295
+ const had = this.apps.delete(port);
11296
+ if (had) this.emit("change");
11297
+ return had;
11298
+ }
11299
+ clear() {
11300
+ if (this.apps.size === 0) return;
11301
+ this.apps.clear();
11302
+ this.emit("change");
11303
+ }
11304
+ /** Probe every port; drop those closed for the grace period. */
11305
+ async check() {
11306
+ let changed = false;
11307
+ for (const app of [...this.apps.values()]) {
11308
+ const up = await (this.o.probe ?? answers)(app.port);
11309
+ if (this.apps.get(app.port) !== app) continue;
11310
+ if (up) app.closedSince = null;
11311
+ else if (app.closedSince === null) app.closedSince = this.now;
11312
+ else if (this.now - app.closedSince >= (this.o.graceMs ?? CLOSED_GRACE_MS)) {
11313
+ this.apps.delete(app.port);
11314
+ changed = true;
11315
+ }
11316
+ }
11317
+ if (changed) this.emit("change");
11318
+ }
11319
+ start(everyMs = CHECK_MS2) {
11320
+ if (this.timer) return;
11321
+ let busy = false;
11322
+ this.timer = setInterval(() => {
11323
+ if (busy) return;
11324
+ busy = true;
11325
+ void this.check().finally(() => busy = false);
11326
+ }, everyMs);
11327
+ this.timer.unref();
11328
+ }
11329
+ stop() {
11330
+ if (this.timer) clearInterval(this.timer);
11331
+ this.timer = null;
11332
+ }
11333
+ };
11334
+ function shQuote2(s) {
11335
+ return `'${s.replace(/'/g, "'\\''")}'`;
11336
+ }
11337
+ function writePreviewShim(shims, command2 = execStart().slice(0, -2)) {
11338
+ const file = path25.join(shims, "agentbox-preview");
11339
+ writePrivate2(
11340
+ file,
11341
+ `#!/bin/sh
11342
+ # agentbox: show an app on this machine in the owner's Preview. Written by the node.
11343
+ exec ${command2.map(shQuote2).join(" ")} node preview "$@"
11344
+ `,
11345
+ 448
11346
+ );
11347
+ return file;
11348
+ }
11349
+
11350
+ // src/node/server.ts
11351
+ var HOP = /* @__PURE__ */ new Set(["connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "te", "trailer"]);
11352
+ var MAX_BODY = 64 * 1024;
11353
+ function json(res, status3, body) {
11354
+ const text = JSON.stringify(body);
11355
+ res.writeHead(status3, { "content-type": "application/json", "content-length": Buffer.byteLength(text), "cache-control": "no-store" });
11356
+ res.end(text);
11357
+ }
11358
+ function readJson(req) {
11359
+ return new Promise((resolve) => {
11360
+ const chunks = [];
11361
+ let size = 0;
11362
+ req.on("data", (c) => {
11363
+ size += c.length;
11364
+ if (size > MAX_BODY) {
11365
+ resolve(null);
11366
+ req.destroy();
11367
+ return;
11368
+ }
11369
+ chunks.push(c);
11370
+ });
11371
+ req.on("end", () => {
11372
+ try {
11373
+ const v = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
11374
+ resolve(typeof v === "object" && v !== null && !Array.isArray(v) ? v : null);
11375
+ } catch {
11376
+ resolve(null);
11377
+ }
11378
+ });
11379
+ req.on("error", () => resolve(null));
11380
+ });
11381
+ }
11382
+ function refuseSocket(socket2, status3, body) {
11383
+ const text = JSON.stringify(body);
11384
+ socket2.end(
11385
+ `HTTP/1.1 ${status3} ${http.STATUS_CODES[status3] ?? ""}\r
11386
+ Connection: close\r
11387
+ Content-Type: application/json\r
11388
+ Content-Length: ${Buffer.byteLength(text)}\r
11389
+ \r
11390
+ ${text}`
11391
+ );
11392
+ }
11393
+ var NodeServer = class {
11394
+ constructor(o) {
11395
+ this.o = o;
11396
+ this.server = http.createServer({ requestTimeout: 0, headersTimeout: 3e4, keepAliveTimeout: 6e4 }, (req, res) => {
11397
+ void this.handle(req, res).catch((err) => {
11398
+ o.log?.(`server: ${err.message}`);
11399
+ if (!res.headersSent) json(res, 500, { error: "internal" });
11400
+ else res.destroy();
11401
+ });
11402
+ });
11403
+ this.server.on("upgrade", (req, socket2, head) => this.upgrade(req, socket2, head));
11404
+ this.server.on("clientError", (_err, socket2) => socket2.destroy());
11405
+ this.appServer = http.createServer({ requestTimeout: 0, headersTimeout: 3e4, keepAliveTimeout: 6e4 }, (req, res) => this.app(req, res));
11406
+ this.appServer.on("upgrade", (req, socket2, head) => this.appUpgrade(req, socket2, head));
11407
+ this.appServer.on("clientError", (_err, socket2) => socket2.destroy());
11408
+ }
11409
+ o;
11410
+ server;
11411
+ /** Streams for an app: every request on one goes to its registered port. */
11412
+ appServer;
11413
+ wss = new import_websocket_server.default({ noServer: true, maxPayload: 1024 * 1024 });
11414
+ opens = /* @__PURE__ */ new WeakMap();
11415
+ channels = /* @__PURE__ */ new Set();
11416
+ /** A stream from the link: a connection to this server (or, for an app, to the app's). */
11417
+ accept(stream, open3) {
11418
+ this.opens.set(stream, open3);
11419
+ (open3.app === void 0 ? this.server : this.appServer).emit("connection", stream);
11420
+ }
11421
+ openOf(req) {
11422
+ return this.opens.get(req.socket) ?? { kind: "http", session: null, grant: null };
11423
+ }
11424
+ /** Why `need` is not allowed for this stream now, or `null`. */
11425
+ refusal(need, open3) {
11426
+ const l = this.o.policy();
11427
+ const denied = (error, what) => ({
11428
+ status: 403,
11429
+ body: { error, message: l.problem ? `this machine's policy denies everything: ${l.problem}` : what }
11430
+ });
11431
+ if (need === "terminals" && !l.policy.terminals) return denied("terminals_off", "terminals are off in this machine's policy.json");
11432
+ if (need === "files" && l.policy.files.length === 0) return denied("files_off", "this machine's policy.json allows no folders");
11433
+ if (need === "ports") {
11434
+ if (!l.policy.ports) return denied("ports_off", "apps are off in this machine's policy.json (ports)");
11435
+ return null;
11436
+ }
11437
+ if (!this.o.presence.check(open3.grant, open3.session)) {
11438
+ return { status: 401, body: { error: "presence", message: "this machine wants proof that you are there: the machines passphrase" } };
11439
+ }
11440
+ return null;
11441
+ }
11442
+ /** Close every channel the policy or presence no longer allows. Returns how many. */
11443
+ enforce() {
11444
+ let n = 0;
11445
+ for (const ch of [...this.channels]) {
11446
+ if (this.refusal(ch.need, ch.open) === null && (ch.still?.() ?? true)) continue;
11447
+ this.channels.delete(ch);
11448
+ ch.close();
11449
+ n += 1;
11450
+ }
11451
+ return n;
11452
+ }
11453
+ /** Track a channel for as long as `until` lasts; holds its grant from idling out. */
11454
+ channel(need, open3, close, onEnd, still) {
11455
+ const ch = still ? { need, open: open3, close, still } : { need, open: open3, close };
11456
+ this.channels.add(ch);
11457
+ const release = open3.grant ? this.o.presence.hold(open3.grant) : () => {
11458
+ };
11459
+ onEnd(() => {
11460
+ this.channels.delete(ch);
11461
+ release();
11462
+ });
11463
+ }
11464
+ get openChannels() {
11465
+ return this.channels.size;
11466
+ }
11467
+ async handle(req, res) {
11468
+ const url = new URL(req.url ?? "/", "http://machine");
11469
+ const open3 = this.openOf(req);
11470
+ const route = `${req.method} ${url.pathname}`;
11471
+ switch (route) {
11472
+ case "GET /api/health": {
11473
+ const hub = this.o.hub();
11474
+ const l = this.o.policy();
11475
+ return json(res, 200, {
11476
+ ok: true,
11477
+ herdr: { connected: hub?.connected ?? false, version: hub?.version ?? null, protocol: hub?.protocol ?? null },
11478
+ workspaceRoot: this.o.home,
11479
+ homeRoot: this.o.home,
11480
+ sharing: false,
11481
+ publicUrl: null,
11482
+ machine: {
11483
+ terminals: l.policy.terminals,
11484
+ files: l.policy.files.map((f) => ({ path: f.path, mode: f.mode })),
11485
+ ports: l.policy.ports,
11486
+ problem: l.problem,
11487
+ presenceLocked: this.o.presence.locked()
11488
+ }
11489
+ });
11490
+ }
11491
+ case "POST /presence/challenge": {
11492
+ await readJson(req);
11493
+ const c = this.o.presence.challenge(open3.session);
11494
+ if (c === null) return json(res, 400, { error: "no_session", message: "no machines session" });
11495
+ if ("locked" in c) return json(res, 429, { error: "locked", until: c.until, message: lockedMessage(c.until) });
11496
+ return json(res, 200, c);
11497
+ }
11498
+ case "POST /presence/answer": {
11499
+ const b = await readJson(req);
11500
+ const r = this.o.presence.answer(open3.session, typeof b?.challenge === "string" ? b.challenge : "", typeof b?.answer === "string" ? b.answer : "");
11501
+ if (r.ok) return json(res, 200, { grant: r.grant, expiresAt: r.expiresAt });
11502
+ if (r.error === "locked") return json(res, 429, { error: "locked", until: r.until, message: lockedMessage(r.until) });
11503
+ if (r.error === "wrong") return json(res, 401, { error: "wrong", message: "That is not this machine's passphrase.", failures: r.failures });
11504
+ return json(res, 400, { error: r.error, message: "That challenge has expired; try again." });
11505
+ }
11506
+ case "GET /api/session":
11507
+ case "POST /api/rpc": {
11508
+ const no = this.refusal("terminals", open3);
11509
+ if (no) return json(res, no.status, no.body);
11510
+ const socket2 = this.o.herdrSocket();
11511
+ if (!socket2) return json(res, 503, { error: "herdr", message: "herdr is starting on this machine" });
11512
+ this.channel(
11513
+ "terminals",
11514
+ open3,
11515
+ () => res.destroy(),
11516
+ (cb) => res.once("close", cb)
11517
+ );
11518
+ if (route === "GET /api/session") {
11519
+ const hub = this.o.hub();
11520
+ if (!hub) return json(res, 503, { error: "herdr", message: "herdr is starting on this machine" });
11521
+ try {
11522
+ return json(res, 200, await hub.snapshot());
11523
+ } catch (err) {
11524
+ return json(res, 502, { error: "herdr", message: err.message });
11525
+ }
11526
+ }
11527
+ const b = await readJson(req);
11528
+ const method = b?.method;
11529
+ if (typeof method !== "string") return json(res, 400, { error: "method required" });
11530
+ if (!isAllowed(method)) return json(res, 403, { error: "method not allowed" });
11531
+ const params = typeof b?.params === "object" && b.params !== null ? b.params : {};
11532
+ try {
11533
+ return json(res, 200, { result: await request(socket2, method, params) });
11534
+ } catch (err) {
11535
+ if (err instanceof HerdrError) return json(res, 502, { error: { code: err.code, message: err.message } });
11536
+ return json(res, 502, { error: { code: "unreachable", message: err.message } });
11537
+ }
11538
+ }
11539
+ default: {
11540
+ if (url.pathname === "/api/files" || url.pathname.startsWith("/api/files/")) return this.files(req, res, open3);
11541
+ return json(res, 404, { error: "not found" });
11542
+ }
11543
+ }
11544
+ }
11545
+ async files(req, res, open3) {
11546
+ const no = this.refusal("files", open3);
11547
+ if (no) {
11548
+ req.resume();
11549
+ return json(res, no.status, no.body);
11550
+ }
11551
+ const svc = this.o.files?.() ?? null;
11552
+ if (!svc) return json(res, 403, { error: "files_off", message: "this machine's policy.json allows no folders" });
11553
+ this.channel(
11554
+ "files",
11555
+ open3,
11556
+ () => res.destroy(),
11557
+ (cb) => res.once("close", cb),
11558
+ () => this.o.files?.() === svc
11559
+ );
11560
+ await serveFiles(svc, req, res);
11561
+ }
11562
+ /** The port an app stream is for, if it may be served now; else the refusal to send. */
11563
+ appPort(open3) {
11564
+ const no = this.refusal("ports", open3);
11565
+ if (no) return no;
11566
+ const port = open3.app;
11567
+ if (port === void 0 || !this.o.previews?.has(port)) return { status: 404, body: { error: "not_found", message: "no app is registered on that port here" } };
11568
+ return { port };
11569
+ }
11570
+ /** A request for an app: passed on to its port as it came, its answer back as it comes (the gate applies the app policy). */
11571
+ app(req, res) {
11572
+ const open3 = this.openOf(req);
11573
+ const p = this.appPort(open3);
11574
+ if (!("port" in p)) {
11575
+ req.resume();
11576
+ return json(res, p.status, p.body);
11577
+ }
11578
+ const { port } = p;
11579
+ const headers = {};
11580
+ for (const [k, v] of Object.entries(req.headers)) if (v !== void 0 && !HOP.has(k)) headers[k] = v;
11581
+ if (req.headers["transfer-encoding"] !== void 0 && req.headers["content-length"] === void 0) headers["transfer-encoding"] = "chunked";
11582
+ connectLocal(port).then(
11583
+ (sock) => {
11584
+ if (res.destroyed) return void sock.destroy();
11585
+ const up = http.request({ method: req.method, path: req.url ?? "/", headers, createConnection: () => sock }, (upRes) => {
11586
+ const raw2 = [];
11587
+ for (let i = 0; i + 1 < upRes.rawHeaders.length; i += 2) {
11588
+ if (HOP.has(upRes.rawHeaders[i].toLowerCase())) continue;
11589
+ raw2.push(upRes.rawHeaders[i], upRes.rawHeaders[i + 1]);
11590
+ }
11591
+ res.writeHead(upRes.statusCode ?? 502, upRes.statusMessage, raw2);
11592
+ upRes.pipe(res);
11593
+ upRes.on("error", () => res.destroy());
11594
+ });
11595
+ up.on("error", () => res.headersSent ? res.destroy() : json(res, 502, { error: "app", message: `the app on port ${port} did not answer` }));
11596
+ res.once("close", () => {
11597
+ if (!res.writableFinished) up.destroy();
11598
+ });
11599
+ this.channel("ports", open3, () => res.destroy(), (cb) => res.once("close", cb), () => this.o.previews?.has(port) ?? false);
11600
+ req.pipe(up);
11601
+ },
11602
+ () => {
11603
+ req.resume();
11604
+ json(res, 502, { error: "app", message: `nothing answers on port ${port} here` });
11605
+ }
11606
+ );
11607
+ }
11608
+ /** An app's WebSocket (or any upgrade): its request head to the port, then bytes both ways. */
11609
+ appUpgrade(req, socket2, head) {
11610
+ socket2.on("error", () => socket2.destroy());
11611
+ const open3 = this.openOf(req);
11612
+ const p = this.appPort(open3);
11613
+ if (!("port" in p)) return refuseSocket(socket2, p.status, p.body);
11614
+ const { port } = p;
11615
+ connectLocal(port).then(
11616
+ (up) => {
11617
+ if (socket2.destroyed) return void up.destroy();
11618
+ let block = `${req.method ?? "GET"} ${req.url ?? "/"} HTTP/1.1\r
11619
+ `;
11620
+ for (let i = 0; i + 1 < req.rawHeaders.length; i += 2) block += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r
11621
+ `;
11622
+ up.write(`${block}\r
11623
+ `);
11624
+ if (head.length) up.write(head);
11625
+ const end = () => {
11626
+ up.destroy();
11627
+ socket2.destroy();
11628
+ };
11629
+ up.on("error", end);
11630
+ up.on("close", end);
11631
+ socket2.on("close", end);
11632
+ this.channel("ports", open3, end, (cb) => socket2.once("close", cb), () => this.o.previews?.has(port) ?? false);
11633
+ up.pipe(socket2);
11634
+ socket2.pipe(up);
11635
+ },
11636
+ () => refuseSocket(socket2, 502, { error: "app", message: `nothing answers on port ${port} here` })
11637
+ );
11638
+ }
11639
+ upgrade(req, socket2, head) {
11640
+ socket2.on("error", () => socket2.destroy());
11641
+ const url = new URL(req.url ?? "/", "http://machine");
11642
+ const open3 = this.openOf(req);
11643
+ if (url.pathname !== "/ws/terminal" && url.pathname !== "/ws/events") return refuseSocket(socket2, 404, { error: "not found" });
11644
+ const no = this.refusal("terminals", open3);
11645
+ if (no) return refuseSocket(socket2, no.status, no.body);
11646
+ const streams = this.o.streams();
11647
+ const hub = this.o.hub();
11648
+ if (!streams || !hub) return refuseSocket(socket2, 503, { error: "herdr", message: "herdr is starting on this machine" });
11649
+ this.wss.handleUpgrade(req, socket2, head, (ws) => {
11650
+ this.channel(
11651
+ "terminals",
11652
+ open3,
11653
+ () => ws.terminate(),
11654
+ (cb) => ws.once("close", cb)
11655
+ );
11656
+ if (url.pathname === "/ws/terminal") {
11657
+ serveTerminalSocket(ws, streams, { pane: url.searchParams.get("pane") ?? void 0, cols: url.searchParams.get("cols") ?? void 0, rows: url.searchParams.get("rows") ?? void 0 });
11658
+ return;
11659
+ }
11660
+ this.events(ws, hub);
11661
+ });
11662
+ }
11663
+ /** `/ws/events`: a fresh snapshot, then every hub message as it comes. */
9503
11664
  events(ws, hub) {
9504
11665
  heartbeat(ws);
9505
11666
  let off = null;
9506
- const send = (m) => {
11667
+ const send2 = (m) => {
9507
11668
  if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(m));
9508
11669
  };
9509
11670
  hub.snapshot().then(
9510
11671
  (snapshot) => {
9511
11672
  if (ws.readyState !== ws.OPEN) return;
9512
- send({ kind: "snapshot", snapshot });
9513
- off = hub.on(send);
11673
+ send2({ kind: "snapshot", snapshot });
11674
+ off = hub.on(send2);
9514
11675
  },
9515
11676
  () => ws.close()
9516
11677
  );
@@ -9521,6 +11682,7 @@ var NodeServer = class {
9521
11682
  this.channels.clear();
9522
11683
  this.wss.close();
9523
11684
  this.server.close();
11685
+ this.appServer.close();
9524
11686
  }
9525
11687
  };
9526
11688
  function lockedMessage(until) {
@@ -9528,91 +11690,11 @@ function lockedMessage(until) {
9528
11690
  return `Too many wrong passphrases: this machine has locked presence for ${mins} more minute${mins === 1 ? "" : "s"}.`;
9529
11691
  }
9530
11692
 
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
11693
  // src/node/specs.ts
9612
11694
  import { execFile as execFile2 } from "node:child_process";
9613
- import fs18 from "node:fs";
11695
+ import fs19 from "node:fs";
9614
11696
  import os10 from "node:os";
9615
- import path16 from "node:path";
11697
+ import path26 from "node:path";
9616
11698
  function run(cmd, args, timeoutMs = 3e3) {
9617
11699
  return new Promise((resolve) => {
9618
11700
  execFile2(cmd, args, { timeout: timeoutMs, windowsHide: true, maxBuffer: 256 * 1024 }, (err, stdout) => resolve(err ? null : stdout));
@@ -9620,10 +11702,10 @@ function run(cmd, args, timeoutMs = 3e3) {
9620
11702
  }
9621
11703
  function onPath(cmd, env) {
9622
11704
  const exts = process.platform === "win32" ? [".exe", ".cmd", ""] : [""];
9623
- for (const dir of (env.PATH ?? "").split(path16.delimiter)) {
11705
+ for (const dir of (env.PATH ?? "").split(path26.delimiter)) {
9624
11706
  for (const ext of exts) {
9625
11707
  try {
9626
- fs18.accessSync(path16.join(dir, cmd + ext), fs18.constants.X_OK);
11708
+ fs19.accessSync(path26.join(dir, cmd + ext), fs19.constants.X_OK);
9627
11709
  return true;
9628
11710
  } catch {
9629
11711
  }
@@ -9648,15 +11730,15 @@ async function gpus(env) {
9648
11730
  }
9649
11731
  function freeBytes(dir) {
9650
11732
  try {
9651
- const s = fs18.statfsSync(dir);
11733
+ const s = fs19.statfsSync(dir);
9652
11734
  return s.bavail * s.bsize;
9653
11735
  } catch {
9654
11736
  return null;
9655
11737
  }
9656
11738
  }
9657
11739
  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;
11740
+ const own = path26.join(binDir, process.platform === "win32" ? "claude.exe" : "claude");
11741
+ const cmd = fs19.existsSync(own) ? own : onPath("claude", env) ? "claude" : null;
9660
11742
  if (!cmd) return null;
9661
11743
  const out = await run(cmd, ["--version"], 5e3);
9662
11744
  return out ? /\d+\.\d+\.\d+/.exec(out)?.[0] ?? null : null;
@@ -9682,6 +11764,204 @@ async function gatherSpecs(opts) {
9682
11764
  };
9683
11765
  }
9684
11766
 
11767
+ // src/node/files.ts
11768
+ import { createHash as createHash5 } from "node:crypto";
11769
+ import fs20 from "node:fs";
11770
+ import path27 from "node:path";
11771
+ function pathOf(platform) {
11772
+ return platform === "win32" ? path27.win32 : path27.posix;
11773
+ }
11774
+ function pathLists(o) {
11775
+ const platform = o.platform ?? process.platform;
11776
+ const env = o.env ?? process.env;
11777
+ const P = pathOf(platform);
11778
+ const H = o.home;
11779
+ const N = o.nodeDir;
11780
+ const j = (...parts) => P.join(...parts);
11781
+ const nodeSecrets = ["credentials.json", "secrets", "claude", "control.secret", "control.sock", "state"].map((f) => j(N, f));
11782
+ const ci = platform === "win32" || platform === "darwin";
11783
+ const fold = (p) => ci ? P.normalize(p).toLowerCase() : P.normalize(p);
11784
+ const home2 = fold(H);
11785
+ const onPath2 = (env.PATH ?? env.Path ?? "").split(P.delimiter).filter((d) => d !== "" && P.isAbsolute(d) && fold(d) !== home2 && fold(d).startsWith(home2 + P.sep));
11786
+ if (platform === "win32") {
11787
+ const A = env.APPDATA && P.isAbsolute(env.APPDATA) ? env.APPDATA : j(H, "AppData", "Roaming");
11788
+ const L = env.LOCALAPPDATA && P.isAbsolute(env.LOCALAPPDATA) ? env.LOCALAPPDATA : j(H, "AppData", "Local");
11789
+ return {
11790
+ hidden: [
11791
+ j(H, ".ssh"),
11792
+ j(H, ".gnupg"),
11793
+ j(A, "gnupg"),
11794
+ j(A, "agentbox"),
11795
+ j(H, ".config", "agentbox"),
11796
+ ...nodeSecrets,
11797
+ j(H, ".claude", ".credentials.json"),
11798
+ j(A, "Microsoft", "Credentials"),
11799
+ j(L, "Microsoft", "Credentials"),
11800
+ j(A, "Microsoft", "Protect"),
11801
+ j(L, "Google", "Chrome", "User Data"),
11802
+ j(L, "Microsoft", "Edge", "User Data"),
11803
+ j(L, "BraveSoftware"),
11804
+ j(L, "Chromium"),
11805
+ j(L, "Vivaldi"),
11806
+ j(A, "Opera Software"),
11807
+ j(A, "Mozilla", "Firefox"),
11808
+ j(L, "Mozilla", "Firefox")
11809
+ ],
11810
+ writeDenied: [
11811
+ N,
11812
+ ...onPath2,
11813
+ j(H, "Documents", "PowerShell"),
11814
+ j(H, "Documents", "WindowsPowerShell"),
11815
+ j(A, "Microsoft", "Windows", "Start Menu", "Programs", "Startup"),
11816
+ j(H, ".gitconfig"),
11817
+ j(H, ".config", "git"),
11818
+ // Git Bash reads these.
11819
+ j(H, ".bashrc"),
11820
+ j(H, ".bash_profile"),
11821
+ j(H, ".profile"),
11822
+ j(H, ".ssh", "authorized_keys")
11823
+ ]
11824
+ };
11825
+ }
11826
+ const configs = [.../* @__PURE__ */ new Set([env.XDG_CONFIG_HOME && P.isAbsolute(env.XDG_CONFIG_HOME) ? env.XDG_CONFIG_HOME : j(H, ".config"), j(H, ".config")])];
11827
+ const inConfig = (...parts) => configs.map((c) => j(c, ...parts));
11828
+ const mac = platform === "darwin" ? j(H, "Library") : null;
11829
+ return {
11830
+ hidden: [
11831
+ j(H, ".ssh"),
11832
+ j(H, ".gnupg"),
11833
+ ...inConfig("agentbox"),
11834
+ ...nodeSecrets,
11835
+ j(H, ".claude", ".credentials.json"),
11836
+ j(H, ".mozilla"),
11837
+ j(H, ".librewolf"),
11838
+ j(H, "snap", "firefox"),
11839
+ j(H, "snap", "chromium"),
11840
+ j(H, ".var", "app", "org.mozilla.firefox"),
11841
+ j(H, ".var", "app", "com.google.Chrome"),
11842
+ j(H, ".var", "app", "org.chromium.Chromium"),
11843
+ j(H, ".var", "app", "com.brave.Browser"),
11844
+ ...["google-chrome", "google-chrome-beta", "google-chrome-unstable", "chromium", "BraveSoftware", "microsoft-edge", "vivaldi", "opera"].flatMap((b) => inConfig(b)),
11845
+ ...mac ? [
11846
+ j(mac, "Keychains"),
11847
+ j(mac, "Cookies"),
11848
+ j(mac, "Safari"),
11849
+ j(mac, "Application Support", "Google", "Chrome"),
11850
+ j(mac, "Application Support", "Firefox"),
11851
+ j(mac, "Application Support", "BraveSoftware"),
11852
+ j(mac, "Application Support", "Microsoft Edge"),
11853
+ j(mac, "Application Support", "Chromium")
11854
+ ] : []
11855
+ ],
11856
+ writeDenied: [
11857
+ N,
11858
+ ...onPath2,
11859
+ j(H, ".local", "bin"),
11860
+ j(H, "bin"),
11861
+ j(H, ".local", "share", "applications"),
11862
+ j(H, ".pam_environment"),
11863
+ ...[".bashrc", ".bash_aliases", ".bash_profile", ".bash_login", ".bash_logout", ".profile", ".zshrc", ".zshenv", ".zprofile", ".zlogin", ".zlogout", ".xprofile", ".xsession", ".xinitrc"].map((f) => j(H, f)),
11864
+ ...inConfig("fish"),
11865
+ ...inConfig("powershell"),
11866
+ ...inConfig("systemd", "user"),
11867
+ ...inConfig("environment.d"),
11868
+ ...inConfig("autostart"),
11869
+ ...inConfig("git"),
11870
+ j(H, ".ssh", "authorized_keys"),
11871
+ j(H, ".gitconfig"),
11872
+ ...mac ? [j(mac, "LaunchAgents")] : []
11873
+ ]
11874
+ };
11875
+ }
11876
+ function resolved(p) {
11877
+ try {
11878
+ return fs20.realpathSync.native(p);
11879
+ } catch {
11880
+ try {
11881
+ return path27.join(fs20.realpathSync.native(path27.dirname(p)), path27.basename(p));
11882
+ } catch {
11883
+ return null;
11884
+ }
11885
+ }
11886
+ }
11887
+ function refuseWindowsPath(input) {
11888
+ const s = input.replace(/\//g, "\\");
11889
+ if (s.startsWith("\\\\")) {
11890
+ return /^\\\\[.?]\\/.test(s) || s === "\\\\." || s === "\\\\?" ? "device paths (\\\\.\\, \\\\?\\) are refused" : "network (UNC) paths are refused";
11891
+ }
11892
+ const rest = s.replace(/^\\?[A-Za-z]:/, "");
11893
+ if (rest.includes(":")) return "alternate data streams (a `:` in a name) are refused";
11894
+ for (const part of rest.split("\\")) {
11895
+ if (part === "" || part === "." || part === "..") continue;
11896
+ if (/[. ]$/.test(part)) return "a name ending in a dot or a space is refused";
11897
+ if (/^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$)(\..*)?$/i.test(part)) return "DOS device names are refused";
11898
+ }
11899
+ return null;
11900
+ }
11901
+ function pathRules(o) {
11902
+ const platform = o.platform ?? process.platform;
11903
+ const P = pathOf(platform);
11904
+ const caseInsensitive = platform === "win32" || platform === "darwin";
11905
+ const fold = (p) => {
11906
+ const n = P.normalize(p);
11907
+ const t2 = n.length > 1 && n.endsWith(P.sep) && !/^[A-Za-z]:\\$/.test(n) ? n.slice(0, -1) : n;
11908
+ return caseInsensitive ? t2.toLowerCase() : t2;
11909
+ };
11910
+ const lists = pathLists(o);
11911
+ const expand2 = (list) => {
11912
+ const out = /* @__PURE__ */ new Set();
11913
+ for (const p of list) {
11914
+ out.add(p);
11915
+ if (o.resolve ?? platform === process.platform) {
11916
+ const r = resolved(p);
11917
+ if (r) out.add(r);
11918
+ }
11919
+ }
11920
+ return [...out];
11921
+ };
11922
+ const hidden = expand2(lists.hidden);
11923
+ const writeDenied = expand2(lists.writeDenied);
11924
+ const hiddenF = hidden.map(fold);
11925
+ const deniedF = writeDenied.map(fold);
11926
+ const under = (p, list) => {
11927
+ const f = fold(p);
11928
+ return list.some((e) => f === e || f.startsWith(e.endsWith(P.sep) ? e : e + P.sep));
11929
+ };
11930
+ const gitRuns = (p) => {
11931
+ const parts = fold(p).split(P.sep);
11932
+ for (let i = 0; i + 1 < parts.length; i++) {
11933
+ if (parts[i] === ".git" && (parts[i + 1] === "hooks" || parts[i + 1] === "config")) return true;
11934
+ }
11935
+ return false;
11936
+ };
11937
+ return {
11938
+ caseInsensitive,
11939
+ hidden,
11940
+ writeDenied,
11941
+ isHidden: (p) => under(p, hiddenF),
11942
+ isWriteDenied: (p) => under(p, deniedF) || gitRuns(p),
11943
+ refusePath: (input) => platform === "win32" ? refuseWindowsPath(input) : null
11944
+ };
11945
+ }
11946
+ function rootId(folder) {
11947
+ return `r${createHash5("sha256").update(folder).digest("hex").slice(0, 12)}`;
11948
+ }
11949
+ function filesFor(policy, paths2, rules) {
11950
+ if (policy.files.length === 0) return null;
11951
+ const roots = policy.files.map((f) => {
11952
+ const id = rootId(f.path);
11953
+ return { id, path: f.path, mode: f.mode, stateDir: path27.join(paths2.state, "files", id) };
11954
+ });
11955
+ return new FilesService({
11956
+ roots,
11957
+ caseInsensitive: rules.caseInsensitive,
11958
+ isHidden: rules.isHidden,
11959
+ isWriteDenied: rules.isWriteDenied,
11960
+ refusePath: rules.refusePath
11961
+ });
11962
+ }
11963
+ var TRASH_DAYS = 7;
11964
+
9685
11965
  // src/node/run.ts
9686
11966
  var RESTART_EXIT = 75;
9687
11967
  var HEADLESS = { cols: 200, rows: 50, ms: 3e4 };
@@ -9699,13 +11979,41 @@ async function runNode(o) {
9699
11979
  let policy = loadPolicy(o.paths.policy, o.home);
9700
11980
  const claude = claudePaths(o.paths);
9701
11981
  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) };
11982
+ const herdrEnv = { ...process.env, PATH: [claude.shims, o.paths.bin, process.env.PATH ?? ""].join(path28.delimiter) };
9703
11983
  delete herdrEnv.CLAUDE_CODE_OAUTH_TOKEN;
11984
+ const rules = pathRules({ home: o.home, nodeDir: o.paths.dir });
11985
+ let files = null;
11986
+ let filesKey = "";
11987
+ let stopUploads = () => {
11988
+ };
11989
+ const setFiles = (l) => {
11990
+ const key = JSON.stringify(l.policy.files);
11991
+ if (key === filesKey) return;
11992
+ filesKey = key;
11993
+ stopUploads();
11994
+ files = filesFor(l.policy, o.paths, rules);
11995
+ stopUploads = files ? files.startMaintenance() : () => {
11996
+ };
11997
+ };
11998
+ setFiles(policy);
11999
+ const sweepTrash = () => {
12000
+ void files?.trash.expire(now() - TRASH_DAYS * 24 * 60 * 6e4).then(
12001
+ (n) => n && o.log(`files: ${n} item(s) older than ${TRASH_DAYS} days removed from the trash`),
12002
+ () => {
12003
+ }
12004
+ );
12005
+ };
12006
+ sweepTrash();
12007
+ const trashSweeper = setInterval(sweepTrash, 60 * 6e4);
12008
+ trashSweeper.unref();
12009
+ const previews = new Previews({ now });
12010
+ previews.start();
12011
+ writePreviewShim(claude.shims);
9704
12012
  const presence = new PresenceGate({ presence: creds.presence, machineId: creds.machineId, hours: () => policy.policy.presenceHours, now, log: o.log });
9705
12013
  let hub = null;
9706
12014
  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;
12015
+ const herdrBin = path28.join(o.paths.bin, process.platform === "win32" ? "herdr.exe" : "herdr");
12016
+ const herdr = fs21.existsSync(herdrBin) ? new HerdrSupervisor({ bin: herdrBin, dir: o.paths.dir, env: herdrEnv }) : null;
9709
12017
  if (!herdr) o.log(`herdr is not installed (${herdrBin}); terminals and agents are unavailable until \`agentbox node join\` installs it`);
9710
12018
  const server = new NodeServer({
9711
12019
  herdrSocket: () => herdr?.socketPath ?? null,
@@ -9714,6 +12022,8 @@ async function runNode(o) {
9714
12022
  policy: () => policy,
9715
12023
  presence,
9716
12024
  home: o.home,
12025
+ files: () => files,
12026
+ previews,
9717
12027
  log: o.log
9718
12028
  });
9719
12029
  herdr?.on("log", (line) => o.log(line));
@@ -9767,6 +12077,14 @@ async function runNode(o) {
9767
12077
  ...o.link ?? {}
9768
12078
  });
9769
12079
  link.on("log", (line) => o.log(line));
12080
+ const sendApps = () => {
12081
+ link.sendControl({ type: "apps", apps: previews.list().map((a) => ({ port: a.port, name: a.name })) });
12082
+ };
12083
+ previews.on("change", () => {
12084
+ sendApps();
12085
+ server.enforce();
12086
+ });
12087
+ link.on("welcome", sendApps);
9770
12088
  link.on("stream", (stream, open3) => server.accept(stream, open3));
9771
12089
  link.on("control", (m) => {
9772
12090
  if (m.type !== "claude-token") return;
@@ -9779,6 +12097,8 @@ async function runNode(o) {
9779
12097
  const stopWatching = watchPolicy(o.paths.policy, o.home, (l) => {
9780
12098
  policy = l;
9781
12099
  sayPolicy(l);
12100
+ setFiles(l);
12101
+ if (!l.policy.ports) previews.clear();
9782
12102
  const closed = server.enforce();
9783
12103
  if (closed) o.log(`policy: closed ${closed} channel(s) it no longer allows`);
9784
12104
  if (l.policy.paused && !link.isPaused) link.pause();
@@ -9788,8 +12108,23 @@ async function runNode(o) {
9788
12108
  let finish = () => {
9789
12109
  };
9790
12110
  const done = new Promise((r) => finish = r);
9791
- const control2 = await startControl(o.paths.control, o.paths.controlSecret, async (command2) => {
12111
+ const control2 = await startControl(o.paths.control, o.paths.controlSecret, async (command2, args) => {
9792
12112
  switch (command2) {
12113
+ case "preview": {
12114
+ if (!policy.policy.ports) throw new Error(`apps are off in this machine's policy.json ("ports": false)`);
12115
+ if (args.share === true) throw new Error("apps on a machine cannot be shared publicly yet");
12116
+ const r = await previews.register(args.port, args.name);
12117
+ if (!r.ok) throw new Error(r.error);
12118
+ o.log(`preview: port ${r.app.port} registered as "${r.app.name}"`);
12119
+ return { app: r.app, existed: r.existed, online: link.state === "online" };
12120
+ }
12121
+ case "preview-list":
12122
+ return { apps: previews.list() };
12123
+ case "preview-stop": {
12124
+ const port = Number(args.port);
12125
+ if (!previews.remove(port)) throw new Error(`no app is registered on port ${args.port}`);
12126
+ return { removed: port };
12127
+ }
9793
12128
  case "status":
9794
12129
  return {
9795
12130
  state: link.state,
@@ -9819,7 +12154,7 @@ async function runNode(o) {
9819
12154
  }
9820
12155
  });
9821
12156
  link.on("revoked", () => {
9822
- fs19.rmSync(o.paths.secrets, { recursive: true, force: true });
12157
+ fs21.rmSync(o.paths.secrets, { recursive: true, force: true });
9823
12158
  writeCredentials(o.paths.credentials, { ...creds, revokedAt: now() });
9824
12159
  o.log(`${creds.box} revoked this machine. Its shared secrets are wiped; everything else is kept. Re-join it to use it again.`);
9825
12160
  finish(EXIT.OK);
@@ -9839,7 +12174,7 @@ async function runNode(o) {
9839
12174
  running: o.version,
9840
12175
  // Only the standalone executable replaces itself; agentbox.mjs under Node does not.
9841
12176
  nodePath: execStart().length === 3 ? process.execPath : null,
9842
- ...fs19.existsSync(herdrPath) ? { herdr: { path: herdrPath, version: HERDR_VERSION } } : {},
12177
+ ...fs21.existsSync(herdrPath) ? { herdr: { path: herdrPath, version: HERDR_VERSION } } : {},
9843
12178
  ...installedClaude(claude) ? { claude: { path: claude.bin, version: installedClaude(claude), versionFile: claude.versionFile } } : {},
9844
12179
  // Agents live in herdr: herdr and Claude Code are replaced only while none runs.
9845
12180
  idle: () => herdr ? herdr.idle() : true
@@ -9869,6 +12204,9 @@ async function runNode(o) {
9869
12204
  link.start();
9870
12205
  const code2 = await done;
9871
12206
  clearInterval(enforcer);
12207
+ clearInterval(trashSweeper);
12208
+ stopUploads();
12209
+ previews.stop();
9872
12210
  server.close();
9873
12211
  herdr?.stop();
9874
12212
  hub?.stop();
@@ -9888,22 +12226,22 @@ function paths(ctx) {
9888
12226
  function home(ctx) {
9889
12227
  return ctx.env.HOME || os11.homedir();
9890
12228
  }
9891
- function parseFolders(raw, homeDir2) {
12229
+ function parseFolders(raw2, homeDir2) {
9892
12230
  const out = [];
9893
- for (const part of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
12231
+ for (const part of raw2.split(",").map((s) => s.trim()).filter(Boolean)) {
9894
12232
  const m = /^(.*?)(?::(read|write))?$/.exec(part);
9895
12233
  let p = m[1];
9896
12234
  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");
12235
+ else if (p.startsWith("~/")) p = path29.join(homeDir2, p.slice(2));
12236
+ p = path29.resolve(p);
12237
+ if (p === path29.resolve(homeDir2)) throw new UsageError("not the whole home folder: name the project folders the box may use");
9900
12238
  out.push({ path: p, mode: m[2] ?? "write" });
9901
12239
  }
9902
12240
  return out;
9903
12241
  }
9904
12242
  function printPolicy(ctx, p) {
9905
12243
  try {
9906
- ctx.err(`${fs20.readFileSync(p.policy, "utf8")}`);
12244
+ ctx.err(`${fs22.readFileSync(p.policy, "utf8")}`);
9907
12245
  } catch {
9908
12246
  ctx.err("(no policy.json)\n");
9909
12247
  }
@@ -10078,9 +12416,9 @@ var leave = command({
10078
12416
  link.stop();
10079
12417
  }
10080
12418
  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 });
12419
+ fs22.rmSync(np.secrets, { recursive: true, force: true });
12420
+ fs22.rmSync(np.credentials, { force: true });
12421
+ fs22.rmSync(np.controlSecret, { force: true });
10084
12422
  ctx.err(told ? `Left ${creds.box}.
10085
12423
  ` : `Left here; ${creds.box} could not be told, so it lists this machine until the owner removes it.
10086
12424
  `);
@@ -10097,7 +12435,7 @@ var logs = command({
10097
12435
  async run(ctx, p) {
10098
12436
  const args = ["--user", "-u", UNIT_NAME, "-n", "200", "--no-pager", ...bool(p.options, "follow") ? ["-f"] : []];
10099
12437
  return new Promise((resolve) => {
10100
- const child = spawn7("journalctl", args, { stdio: "inherit" });
12438
+ const child = spawn9("journalctl", args, { stdio: "inherit" });
10101
12439
  child.on("error", () => {
10102
12440
  ctx.err("journalctl is not available here; run `agentbox node run` in a terminal to see the log.\n");
10103
12441
  resolve(EXIT.FAILURE);
@@ -10125,26 +12463,81 @@ var passphrase = command({
10125
12463
  ctx.err("This machine now proves presence with the new passphrase.\n");
10126
12464
  }
10127
12465
  });
10128
- var NODE_COMMANDS = [join, run2, status2, pause, resume, leave, logs, passphrase];
12466
+ var preview = command({
12467
+ path: ["node", "preview"],
12468
+ summary: "show an app on this machine in the owner's Preview (agentbox-preview)",
12469
+ usage: "<port> | open <port> | list | stop <port>",
12470
+ details: [
12471
+ "agentbox-preview <port> [--name n] register a server already listening on <port>",
12472
+ "agentbox-preview list what is registered",
12473
+ "agentbox-preview stop <port> take it off the Preview",
12474
+ "",
12475
+ "Only registered ports are ever reached, on 127.0.0.1 or [::1]. A registration ends when",
12476
+ "the port has not answered for 60 seconds, or when the node restarts. Apps on a machine are",
12477
+ "private: the owner opens them from the machines page. `start -- <command>` is not",
12478
+ "available on a machine yet: start the server yourself, then register its port."
12479
+ ].join("\n"),
12480
+ operands: { min: 1, max: 2 },
12481
+ json: true,
12482
+ options: [{ name: "name", type: "string", value: "name", description: "what to call it (default: the port)" }],
12483
+ async run(ctx, p) {
12484
+ const np = paths(ctx);
12485
+ const [verb, arg] = p.operands;
12486
+ const ask2 = async (command2, args = {}) => {
12487
+ const r2 = await callControl(np.control, np.controlSecret, command2, 15e3, args);
12488
+ if (r2 === null) throw new CliError("the node is not running on this machine (`agentbox node status`)", EXIT.UNREACHABLE);
12489
+ if (r2.ok !== true) throw new CliError(String(r2.error ?? "the node refused"));
12490
+ return r2;
12491
+ };
12492
+ if (verb === "list") {
12493
+ const r2 = await ask2("preview-list");
12494
+ const apps = Array.isArray(r2.apps) ? r2.apps : [];
12495
+ if (ctx.json) return ctx.printJson({ apps });
12496
+ if (apps.length === 0) ctx.out("No apps registered on this machine.\n");
12497
+ for (const a of apps) ctx.out(`${a.port} ${safeText(a.name)}
12498
+ `);
12499
+ return;
12500
+ }
12501
+ if (verb === "start") throw new UsageError("`start` is not available on a machine yet: start the server yourself, then `agentbox-preview <port>`");
12502
+ if (verb === "stop") {
12503
+ if (!arg) throw new UsageError("stop needs a port");
12504
+ await ask2("preview-stop", { port: Number(arg) });
12505
+ ctx.err(`Port ${arg} is off the Preview.
12506
+ `);
12507
+ return;
12508
+ }
12509
+ const port = verb === "open" ? arg : verb;
12510
+ if (!port || !/^\d{1,5}$/.test(port)) throw new UsageError("name a port: agentbox-preview <port>");
12511
+ const name = str(p.options, "name");
12512
+ const r = await ask2("preview", { port: Number(port), ...name === void 0 ? {} : { name } });
12513
+ const app = r.app;
12514
+ if (ctx.json) return ctx.printJson({ app, online: r.online === true });
12515
+ ctx.err(
12516
+ `Port ${app.port} is in the owner's Preview as "${safeText(app.name)}" (the machines page, this machine, Preview).${r.online === true ? "" : " The machine is not linked to its box right now; it shows there once it is."}
12517
+ `
12518
+ );
12519
+ }
12520
+ });
12521
+ var NODE_COMMANDS = [join, run2, status2, pause, resume, leave, logs, passphrase, preview];
10129
12522
 
10130
12523
  // src/commands/mount.ts
10131
- import fs21 from "node:fs";
12524
+ import fs23 from "node:fs";
10132
12525
  import os12 from "node:os";
10133
- import path19 from "node:path";
12526
+ import path30 from "node:path";
10134
12527
 
10135
12528
  // src/dav.ts
10136
- import { randomBytes as randomBytes8, timingSafeEqual as timingSafeEqual4 } from "node:crypto";
12529
+ import { randomBytes as randomBytes11, timingSafeEqual as timingSafeEqual4 } from "node:crypto";
10137
12530
  import http2 from "node:http";
10138
12531
  var REMOTE_PREFIX = "/api/dav";
10139
12532
  var BadPath = class extends Error {
10140
12533
  };
10141
12534
  function percentDecode(s) {
10142
12535
  const out = [];
10143
- const raw = Buffer.from(s, "utf8");
10144
- for (let i = 0; i < raw.length; i++) {
10145
- const b = raw[i];
10146
- if (b === 37 && i + 2 < raw.length) {
10147
- const hex = String.fromCharCode(raw[i + 1], raw[i + 2]);
12536
+ const raw2 = Buffer.from(s, "utf8");
12537
+ for (let i = 0; i < raw2.length; i++) {
12538
+ const b = raw2[i];
12539
+ if (b === 37 && i + 2 < raw2.length) {
12540
+ const hex = String.fromCharCode(raw2[i + 1], raw2[i + 2]);
10148
12541
  if (/^[0-9A-Fa-f]{2}$/.test(hex)) {
10149
12542
  out.push(parseInt(hex, 16));
10150
12543
  i += 2;
@@ -10242,7 +12635,7 @@ var DROP_REQUEST = /* @__PURE__ */ new Set(["host", "authorization", "proxy-auth
10242
12635
  var DROP_RESPONSE = /* @__PURE__ */ new Set(["set-cookie", "www-authenticate", "strict-transport-security", "alt-svc"]);
10243
12636
  var MAX_REWRITE = 64 * 1024 * 1024;
10244
12637
  function newCredentials() {
10245
- return { user: "agentbox", password: randomBytes8(24).toString("base64url") };
12638
+ return { user: "agentbox", password: randomBytes11(24).toString("base64url") };
10246
12639
  }
10247
12640
  function basicMatches(header, want) {
10248
12641
  const m = /^Basic\s+([A-Za-z0-9+/=]+)\s*$/i.exec(header ?? "");
@@ -10255,7 +12648,7 @@ var DavFront = class {
10255
12648
  constructor(client, opts = {}) {
10256
12649
  this.client = client;
10257
12650
  this.opts = opts;
10258
- this.secret = opts.secret ?? randomBytes8(16).toString("base64url");
12651
+ this.secret = opts.secret ?? randomBytes11(16).toString("base64url");
10259
12652
  this.credentials = opts.credentials === void 0 ? newCredentials() : opts.credentials;
10260
12653
  }
10261
12654
  client;
@@ -10433,7 +12826,7 @@ function encodeQuery(query, rawQuery) {
10433
12826
  for (const [k, v] of Object.entries(query ?? {})) {
10434
12827
  if (v === void 0) continue;
10435
12828
  const values = Array.isArray(v) ? v : [String(v)];
10436
- for (const one of values) parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(one)}`);
12829
+ for (const one2 of values) parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(one2)}`);
10437
12830
  }
10438
12831
  if (rawQuery) parts.push(rawQuery);
10439
12832
  return parts.length ? `?${parts.join("&")}` : "";
@@ -10487,7 +12880,7 @@ var BoxClient = class {
10487
12880
  get hasToken() {
10488
12881
  return this.token !== null;
10489
12882
  }
10490
- request(method, path22, opts = {}) {
12883
+ request(method, path33, opts = {}) {
10491
12884
  const headers = { ...this.baseHeaders(opts.auth !== false), ...opts.headers ?? {} };
10492
12885
  let payload = null;
10493
12886
  const body = opts.body;
@@ -10502,7 +12895,7 @@ var BoxClient = class {
10502
12895
  }
10503
12896
  if (Buffer.isBuffer(payload)) headers["content-length"] = String(payload.length);
10504
12897
  }
10505
- const target = `${path22}${encodeQuery(opts.query, opts.rawQuery)}`;
12898
+ const target = `${path33}${encodeQuery(opts.query, opts.rawQuery)}`;
10506
12899
  const lib = this.url.protocol === "https:" ? https : http3;
10507
12900
  return new Promise((resolve, reject) => {
10508
12901
  const req = lib.request(
@@ -10548,21 +12941,21 @@ var BoxClient = class {
10548
12941
  * A JSON call: the parsed answer on 2xx, else an {@link ApiError} with what
10549
12942
  * the box said. `what` names the action for the message ("listing /x").
10550
12943
  */
10551
- async json(method, path22, opts = {}) {
10552
- const res = await this.request(method, path22, { ...opts, headers: { accept: "application/json", ...opts.headers ?? {} } });
12944
+ async json(method, path33, opts = {}) {
12945
+ const res = await this.request(method, path33, { ...opts, headers: { accept: "application/json", ...opts.headers ?? {} } });
10553
12946
  const text = await res.text();
10554
- if (res.status < 200 || res.status >= 300) throw apiErrorFrom(res.status, text, opts.what ?? `${method} ${path22}`);
12947
+ if (res.status < 200 || res.status >= 300) throw apiErrorFrom(res.status, text, opts.what ?? `${method} ${path33}`);
10555
12948
  if (res.status === 204 || text === "") return void 0;
10556
12949
  try {
10557
12950
  return JSON.parse(text);
10558
12951
  } catch {
10559
- throw new CliError(`${opts.what ?? path22}: the box answered with something that is not JSON (is ${this.origin} an agentbox?)`);
12952
+ throw new CliError(`${opts.what ?? path33}: the box answered with something that is not JSON (is ${this.origin} an agentbox?)`);
10560
12953
  }
10561
12954
  }
10562
12955
  /** A request whose answer must be 2xx; the body is left to the caller. */
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}`);
12956
+ async ok(method, path33, opts = {}) {
12957
+ const res = await this.request(method, path33, opts);
12958
+ if (res.status < 200 || res.status >= 300) throw apiErrorFrom(res.status, await res.text(), opts.what ?? `${method} ${path33}`);
10566
12959
  return res;
10567
12960
  }
10568
12961
  close() {
@@ -10576,11 +12969,11 @@ async function gvfsFolder(root, port, secret, ms) {
10576
12969
  for (; ; ) {
10577
12970
  let names = [];
10578
12971
  try {
10579
- names = fs21.readdirSync(root);
12972
+ names = fs23.readdirSync(root);
10580
12973
  } catch {
10581
12974
  }
10582
12975
  const name = gvfsMountName(names, port, secret);
10583
- if (name) return path19.join(root, name);
12976
+ if (name) return path30.join(root, name);
10584
12977
  if (Date.now() > deadline) return null;
10585
12978
  await new Promise((r) => setTimeout(r, 100));
10586
12979
  }
@@ -10649,14 +13042,14 @@ password ${credentials.password}
10649
13042
  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.`);
10650
13043
  }
10651
13044
  let madeMountpoint = false;
10652
- if (plan.mountpoint && !fs21.existsSync(plan.mountpoint)) {
10653
- fs21.mkdirSync(plan.mountpoint, { recursive: true });
13045
+ if (plan.mountpoint && !fs23.existsSync(plan.mountpoint)) {
13046
+ fs23.mkdirSync(plan.mountpoint, { recursive: true });
10654
13047
  madeMountpoint = true;
10655
13048
  }
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`);
13049
+ if (plan.link && fs23.existsSync(plan.link)) throw new CliError(`${plan.link} already exists; name a new folder for the link, or leave it out`);
10657
13050
  const mounted = await runStep(plan.mount, helper, ctx.env);
10658
13051
  if (mounted.code !== 0) {
10659
- if (madeMountpoint) fs21.rmSync(plan.mountpoint, { recursive: false, force: true });
13052
+ if (madeMountpoint) fs23.rmSync(plan.mountpoint, { recursive: false, force: true });
10660
13053
  throw new CliError(`${plan.helper} could not mount it${mounted.output ? `: ${mounted.output}` : ""}`);
10661
13054
  }
10662
13055
  let where = plan.where;
@@ -10678,7 +13071,7 @@ password ${credentials.password}
10678
13071
  }
10679
13072
  let linked = false;
10680
13073
  if (plan.link && where) {
10681
- fs21.symlinkSync(where, plan.link, "dir");
13074
+ fs23.symlinkSync(where, plan.link, "dir");
10682
13075
  linked = true;
10683
13076
  }
10684
13077
  if (!gioOnly) {
@@ -10693,10 +13086,10 @@ password ${credentials.password}
10693
13086
  const done = await runStep(unmount, findOnPath(unmount.command, ctx.env, ctx.platform) ?? unmount.command, ctx.env);
10694
13087
  if (done.code !== 0) ctx.warn(`${unmount.command} ${unmount.args.join(" ")} failed${done.output ? `: ${done.output}` : ""}; unmount it yourself`);
10695
13088
  }
10696
- if (linked) fs21.rmSync(plan.link, { force: true });
13089
+ if (linked) fs23.rmSync(plan.link, { force: true });
10697
13090
  if (madeMountpoint) {
10698
13091
  try {
10699
- fs21.rmdirSync(plan.mountpoint);
13092
+ fs23.rmdirSync(plan.mountpoint);
10700
13093
  } catch {
10701
13094
  }
10702
13095
  }
@@ -10708,12 +13101,12 @@ password ${credentials.password}
10708
13101
  var MOUNT_COMMANDS = [mount];
10709
13102
 
10710
13103
  // src/commands/terminal.ts
10711
- import fs22 from "node:fs";
13104
+ import fs24 from "node:fs";
10712
13105
  import os13 from "node:os";
10713
- import path20 from "node:path";
13106
+ import path31 from "node:path";
10714
13107
 
10715
13108
  // src/ttyd.ts
10716
- import { EventEmitter as EventEmitter3 } from "node:events";
13109
+ import { EventEmitter as EventEmitter4 } from "node:events";
10717
13110
  var CLIENT = { INPUT: 48, RESIZE: 49, PAUSE: 50, RESUME: 51 };
10718
13111
  var SERVER = { OUTPUT: 48, TITLE: 49, PREFERENCES: 50 };
10719
13112
  function handshakeFrame(columns, rows, authToken = "") {
@@ -10748,7 +13141,7 @@ function parseServerFrame(frame) {
10748
13141
  return { kind: "unknown", command: command2 ?? -1 };
10749
13142
  }
10750
13143
  }
10751
- var TtydSession = class extends EventEmitter3 {
13144
+ var TtydSession = class extends EventEmitter4 {
10752
13145
  constructor(client, basePath) {
10753
13146
  super();
10754
13147
  this.client = client;
@@ -11083,13 +13476,13 @@ function runTerminal(opts) {
11083
13476
  opts.onCrash ?? defaultCrash(stderr)
11084
13477
  );
11085
13478
  const held = [];
11086
- const send = (data) => {
13479
+ const send2 = (data) => {
11087
13480
  if (started) session.input(data);
11088
13481
  else held.push(data);
11089
13482
  };
11090
13483
  const onInput = (chunk) => {
11091
13484
  const { forward: forward2, detach } = filter.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
11092
- if (forward2.length) send(forward2);
13485
+ if (forward2.length) send2(forward2);
11093
13486
  if (detach) session.detach();
11094
13487
  };
11095
13488
  const onResize = () => {
@@ -11243,7 +13636,7 @@ the network: faster, and no redraw glitches. Install it now with herdr's install
11243
13636
  return null;
11244
13637
  }
11245
13638
  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;
13639
+ return findOnPath("herdr", ctx.env, ctx.platform) ?? [path31.join(home2, ".local", "bin", "herdr")].find((f) => fs24.existsSync(f)) ?? null;
11247
13640
  }
11248
13641
  var attach = command({
11249
13642
  path: ["attach"],
@@ -11294,10 +13687,10 @@ var TERMINAL_COMMANDS = [attach, shell];
11294
13687
 
11295
13688
  // src/commands/update.ts
11296
13689
  import { spawnSync as spawnSync3 } from "node:child_process";
11297
- import { randomBytes as randomBytes9 } from "node:crypto";
11298
- import fs23 from "node:fs";
13690
+ import { randomBytes as randomBytes12 } from "node:crypto";
13691
+ import fs25 from "node:fs";
11299
13692
  import os14 from "node:os";
11300
- import path21 from "node:path";
13693
+ import path32 from "node:path";
11301
13694
  var MAX_BUNDLE = 16 * 1024 * 1024;
11302
13695
  function bundleVersion(bundle) {
11303
13696
  const head = (typeof bundle === "string" ? bundle : bundle.subarray(0, 512).toString("utf8")).split("\n", 3);
@@ -11310,17 +13703,17 @@ function selfInstall(argv1) {
11310
13703
  if (!argv1) return null;
11311
13704
  let file;
11312
13705
  try {
11313
- file = fs23.realpathSync(argv1);
13706
+ file = fs25.realpathSync(argv1);
11314
13707
  } catch {
11315
13708
  return null;
11316
13709
  }
11317
13710
  let checkout = false;
11318
13711
  let npm = false;
11319
13712
  try {
11320
- const root = path21.join(path21.dirname(file), "..");
11321
- const pkg = JSON.parse(fs23.readFileSync(path21.join(root, "package.json"), "utf8"));
13713
+ const root = path32.join(path32.dirname(file), "..");
13714
+ const pkg = JSON.parse(fs25.readFileSync(path32.join(root, "package.json"), "utf8"));
11322
13715
  if (pkg.name === NPM_PACKAGE) {
11323
- if (fs23.existsSync(path21.join(root, "src", "cli.ts"))) checkout = true;
13716
+ if (fs25.existsSync(path32.join(root, "src", "cli.ts"))) checkout = true;
11324
13717
  else npm = true;
11325
13718
  }
11326
13719
  } catch {
@@ -11334,13 +13727,13 @@ function npmVersionFor(boxVersion) {
11334
13727
  function nodeCredentials(env, platform = process.platform) {
11335
13728
  const candidates = [];
11336
13729
  if (platform === "win32") {
11337
- if (env.LOCALAPPDATA && path21.win32.isAbsolute(env.LOCALAPPDATA)) candidates.push(path21.join(env.LOCALAPPDATA, "agentbox", "node", "credentials.json"));
13730
+ if (env.LOCALAPPDATA && path32.win32.isAbsolute(env.LOCALAPPDATA)) candidates.push(path32.join(env.LOCALAPPDATA, "agentbox", "node", "credentials.json"));
11338
13731
  } else {
11339
13732
  const xdg = env.XDG_DATA_HOME;
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"));
13733
+ if (xdg && path32.isAbsolute(xdg)) candidates.push(path32.join(xdg, "agentbox", "node", "credentials.json"));
13734
+ candidates.push(path32.join(env.HOME || os14.homedir(), ".local", "share", "agentbox", "node", "credentials.json"));
11342
13735
  }
11343
- return candidates.find((f) => fs23.existsSync(f)) ?? null;
13736
+ return candidates.find((f) => fs25.existsSync(f)) ?? null;
11344
13737
  }
11345
13738
  function signedUpdatesOnly(env, platform = process.platform, standalone = STANDALONE) {
11346
13739
  const creds = nodeCredentials(env, platform);
@@ -11408,7 +13801,7 @@ var update = command({
11408
13801
  }
11409
13802
  return;
11410
13803
  }
11411
- const current = fs23.readFileSync(self.file);
13804
+ const current = fs25.readFileSync(self.file);
11412
13805
  if (bundle.equals(current)) {
11413
13806
  ctx.out(`Already up to date: agentbox ${VERSION}, the same build ${name} serves.
11414
13807
  `);
@@ -11419,24 +13812,24 @@ var update = command({
11419
13812
  `);
11420
13813
  return;
11421
13814
  }
11422
- const dir = path21.dirname(self.file);
11423
- const tmp = path21.join(dir, `.agentbox-update-${randomBytes9(4).toString("hex")}.mjs`);
13815
+ const dir = path32.dirname(self.file);
13816
+ const tmp = path32.join(dir, `.agentbox-update-${randomBytes12(4).toString("hex")}.mjs`);
11424
13817
  try {
11425
- fs23.writeFileSync(tmp, bundle, { mode: 493 });
11426
- fs23.chmodSync(tmp, 493);
13818
+ fs25.writeFileSync(tmp, bundle, { mode: 493 });
13819
+ fs25.chmodSync(tmp, 493);
11427
13820
  } catch (err) {
11428
- fs23.rmSync(tmp, { force: true });
13821
+ fs25.rmSync(tmp, { force: true });
11429
13822
  throw new CliError(`cannot write to ${dir} (${err.message}); reinstall with the box's install script`);
11430
13823
  }
11431
13824
  const check = spawnSync3(process.execPath, [tmp, "--version"], { encoding: "utf8", timeout: 3e4 });
11432
13825
  if (check.status !== 0) {
11433
- fs23.rmSync(tmp, { force: true });
13826
+ fs25.rmSync(tmp, { force: true });
11434
13827
  throw new CliError(`the downloaded build does not run here: ${(check.stderr || check.stdout || "").trim().slice(0, 300)}`);
11435
13828
  }
11436
13829
  try {
11437
- fs23.renameSync(tmp, self.file);
13830
+ fs25.renameSync(tmp, self.file);
11438
13831
  } catch (err) {
11439
- fs23.rmSync(tmp, { force: true });
13832
+ fs25.rmSync(tmp, { force: true });
11440
13833
  throw new CliError(`cannot replace ${self.file}: ${err.message}`);
11441
13834
  }
11442
13835
  ctx.out(`Updated agentbox ${VERSION} \u2192 ${safeText(version)} from ${name}.
@@ -11461,8 +13854,8 @@ var COMMANDS2 = [
11461
13854
  ...UPDATE_COMMANDS
11462
13855
  ];
11463
13856
  function optionLine(o) {
11464
- const flag = `${o.short ? `-${o.short}, ` : " "}--${o.name}${o.value ? ` <${o.value}>` : ""}`;
11465
- return ` ${flag.padEnd(26)} ${o.description}`;
13857
+ const flag2 = `${o.short ? `-${o.short}, ` : " "}--${o.name}${o.value ? ` <${o.value}>` : ""}`;
13858
+ return ` ${flag2.padEnd(26)} ${o.description}`;
11466
13859
  }
11467
13860
  function mainHelp() {
11468
13861
  const width2 = Math.max(...COMMANDS2.filter((c) => !c.hidden).map((c) => c.path.join(" ").length));