@securecoms/agent-bridge 0.30.1 → 0.30.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,13 +35,9 @@ The server still sees ciphertext only. The trust boundary is the machine running
35
35
  - **Node.js 24 LTS or newer.** The daemon requires >= 24 (node:sqlite stable API).
36
36
  - A valid SecureComs agent credential (`agent.json`, `chmod 600`)
37
37
 
38
- You don't have to check your Node version yourself. The installer self-checks before anything else runs: on an older Node it prints a clear diagnosis instead of crashing, and on macOS it offers to install Node 24 LTS for you. The install is consent-gated and names its source before asking. If Homebrew is present it runs `brew install node` (user-space, no sudo); otherwise it downloads the official installer from nodejs.org over TLS and verifies the published SHA-256 checksum (an integrity check on the download) before running it.
38
+ You don't have to check your Node version yourself. The installer checks it before anything else runs. On an older Node it says so plainly, and on macOS it offers to install Node 24 from nodejs.org after asking you. That step needs your computer's administrator password. It checks the download's published SHA-256 checksum before running it.
39
39
 
40
- To install Node 24 manually instead:
41
-
42
- 1. Official installer (recommended): download the Node 24 LTS installer from [nodejs.org](https://nodejs.org) and run it.
43
- 2. Homebrew (macOS): `brew install node`
44
- 3. nvm: `nvm install 24 && nvm use 24`
40
+ To install Node 24 yourself, download the Node 24 LTS installer from [nodejs.org](https://nodejs.org) and run it. Then run the install command again.
45
41
 
46
42
  Always re-run the install command after upgrading Node — the background service pins the Node path it was installed with.
47
43
 
@@ -935,6 +931,44 @@ copied after the owner's next typed message.
935
931
 
936
932
  ---
937
933
 
934
+ ## Release notes: 0.30.2
935
+
936
+ <!-- [COPY, approved by Eric 2026-10-15] -->
937
+
938
+ 0.30.2 fixes the guided Node upgrade, makes reinstall safe while the agent is
939
+ running, and prints plain messages when the agent cannot start.
940
+
941
+ - **The Node upgrade works again on macOS.** When this computer has an older
942
+ Node, install offers to install Node 24 from nodejs.org. Before 0.30.2 this
943
+ step could not find the installer and stopped without saying why. It now
944
+ finds it, checks its published SHA-256 checksum, and asks first:
945
+ `SecureComs needs Node 24. Install it now from nodejs.org? This needs your
946
+ computer's administrator password. [y/N]`. If the step fails after you say
947
+ yes, it prints: `Couldn't install Node 24 from nodejs.org.`
948
+ - **Homebrew is no longer used.** The guided upgrade always uses the
949
+ nodejs.org installer.
950
+ - **Reinstall while the agent runs.** Install now waits up to 10 seconds for
951
+ the running Connector to stop before it starts the new one. Before, the new
952
+ copy could start too early, find the old copy still running, and stay
953
+ stopped. If the old copy does not stop in time, install starts nothing and
954
+ tells you to run install again.
955
+ - **Plain messages when the agent cannot start.** A missing, unreadable or
956
+ invalid key file, or a bad setting in it, now each print one short line
957
+ that says what to do. File paths are printed only in a terminal, not in the
958
+ service log.
959
+ - **No key file found.** If the copied install command finds no key file, it
960
+ prints: `No key file was found in your Downloads folder. Download the key
961
+ file again from SecureComs, or add --credential <path>.` An empty
962
+ `--credential` no longer falls back to the `SECURECOMS_CREDENTIAL`
963
+ environment variable.
964
+ - **Node 24 or newer.** The package now declares `node >= 24`, which the
965
+ Connector already required to run.
966
+
967
+ You do not need to change anything after the upgrade. Run the install command
968
+ again only if you want the new install behavior now.
969
+
970
+ ---
971
+
938
972
  ## Release notes: 0.30.1
939
973
 
940
974
  <!-- [COPY, approved by Eric 2026-10-14] -->
package/dist/bin.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * token, its fingerprint, or its path. This prevents the former
8
8
  * `BRIDGE_TOKEN=<value>` leak into journald/launchd logs.
9
9
  */
10
- import { type BridgeConfig } from "./config.js";
10
+ import { type BridgeConfig, type ConfigErrorReason } from "./config.js";
11
11
  import { BridgeAgent } from "./bridge-agent.js";
12
12
  import { createHttpServer } from "./http-server.js";
13
13
  import { attachAgentWebSocket } from "./ws-server.js";
@@ -98,8 +98,27 @@ export interface RunModeDeps {
98
98
  exit(code: number): void;
99
99
  /** One line of approved user-facing copy (spec §8). */
100
100
  print(line: string): void;
101
+ /**
102
+ * Whether stderr is a terminal. Under launchd and systemd stderr IS the
103
+ * service log, so a printed line may carry a path only when this is true
104
+ * (spec 2026-10-15 §5.3 path rule).
105
+ */
106
+ isTTY: boolean;
101
107
  log: ServiceLog;
102
108
  }
109
+ export type StartupConfigReason = ConfigErrorReason | "identity_id_invalid" | "bind_refused" | "unknown";
110
+ /**
111
+ * The one human line for a run-mode config refusal (spec 2026-10-15 §5.3,
112
+ * E1-E7 approved; E5 with source=env is the BT-5 draft). The path is shown
113
+ * only on a TTY; `field` is one of the six fixed REQUIRED names. `unknown`
114
+ * has no line. Never built from an error message.
115
+ */
116
+ export declare function configErrorLine(reason: StartupConfigReason, o: {
117
+ path: string;
118
+ isTTY: boolean;
119
+ field?: string;
120
+ source?: "file" | "env";
121
+ }): string | undefined;
103
122
  /**
104
123
  * The daemon's run mode (spec §4.1.1). Order, each step gated on the last:
105
124
  * 1. loadConfig (+ daemon args, bind check);
package/dist/bin.js CHANGED
@@ -8001,8 +8001,30 @@ function ackOnlyGreeting(agentName, callerName) {
8001
8001
 
8002
8002
  // src/config.ts
8003
8003
  var REQUIRED = ["api_key", "identity_id", "device_id", "signing_private_key", "enclave_id", "api_base"];
8004
+ var CONFIG_ERROR_MESSAGE = {
8005
+ credential_not_found: "credential file not found",
8006
+ credential_unreadable: "credential file could not be read",
8007
+ credential_not_json: "credential file is not valid JSON",
8008
+ credential_missing_field: "credential file missing required field",
8009
+ credential_voice_invalid: "credential file voice config invalid"
8010
+ };
8011
+ var ConfigError = class extends Error {
8012
+ constructor(reason, field, source) {
8013
+ super(CONFIG_ERROR_MESSAGE[reason] + (field ? `: ${field}` : ""));
8014
+ this.reason = reason;
8015
+ this.field = field;
8016
+ this.source = source;
8017
+ this.name = "ConfigError";
8018
+ }
8019
+ };
8004
8020
  function loadConfig(opts) {
8005
- const raw = readFileSync2(opts.credentialPath, "utf8");
8021
+ let raw;
8022
+ try {
8023
+ raw = readFileSync2(opts.credentialPath, "utf8");
8024
+ } catch (e) {
8025
+ const code = e?.code;
8026
+ throw new ConfigError(code === "ENOENT" ? "credential_not_found" : "credential_unreadable");
8027
+ }
8006
8028
  try {
8007
8029
  const mode = statSync(opts.credentialPath).mode & 511;
8008
8030
  if (mode & 63) writeServiceLog("warn", "config.credential_permissions_unsafe");
@@ -8012,9 +8034,9 @@ function loadConfig(opts) {
8012
8034
  try {
8013
8035
  json2 = JSON.parse(raw);
8014
8036
  } catch {
8015
- throw new Error("credential file is not valid JSON");
8037
+ throw new ConfigError("credential_not_json");
8016
8038
  }
8017
- for (const k of REQUIRED) if (typeof json2[k] !== "string" || !json2[k]) throw new Error(`credential file missing required field: ${k}`);
8039
+ for (const k of REQUIRED) if (typeof json2[k] !== "string" || !json2[k]) throw new ConfigError("credential_missing_field", k);
8018
8040
  const storePath = opts.storePath ?? join2(dirname2(opts.credentialPath), "bridge-state.db");
8019
8041
  const ownerGrantPin = loadOwnerGrantPin({ credentialPath: opts.credentialPath, credentialJson: json2 });
8020
8042
  writeServiceLog(ownerGrantPin.state === "ok" || ownerGrantPin.state === "no_pin" ? "info" : "warn", "authz.pin", {
@@ -8052,8 +8074,14 @@ function loadConfig(opts) {
8052
8074
  let voice;
8053
8075
  try {
8054
8076
  voice = parseVoiceConfig(json2.voice, process.env);
8055
- } catch (e) {
8056
- throw new Error(`credential file voice config invalid: ${e.message}`);
8077
+ } catch {
8078
+ let source = "env";
8079
+ try {
8080
+ parseVoiceConfig(json2.voice, {});
8081
+ } catch {
8082
+ source = "file";
8083
+ }
8084
+ throw new ConfigError("credential_voice_invalid", void 0, source);
8057
8085
  }
8058
8086
  const modelSource = json2.memory?.model_source === "import_only" ? "import_only" : "download";
8059
8087
  const enabledRaw = json2.memory?.enabled;
@@ -18169,7 +18197,13 @@ var CODES = [
18169
18197
  // lookup missed and before any write, so a retry can never land: the
18170
18198
  // pending commit is dropped instead of being resent forever. The same pair
18171
18199
  // answers an application send into a deleted channel (#325 F5).
18172
- { status: 409, code: "channel_unavailable" }
18200
+ { status: 409, code: "channel_unavailable" },
18201
+ // ADR-0023 B.1: an Add or Welcome into a humans-only enclave's group naming
18202
+ // a device that is not provably a person's. Refused after the replay lookup
18203
+ // missed and before any write, so nothing landed. Definite: the committer's
18204
+ // work cannot name an agent in a humans-only enclave (admission refuses
18205
+ // one), so dropping the commit starts no resend loop.
18206
+ { status: 403, code: "humans_only_enclave" }
18173
18207
  ];
18174
18208
  var DISTRIBUTE_DEFINITE_CODES = Object.freeze(CODES.map((c) => Object.freeze({ ...c })));
18175
18209
  var DEFINITE_KEYS = new Set(DISTRIBUTE_DEFINITE_CODES.map((c) => `${c.status} ${c.code}`));
@@ -44197,7 +44231,7 @@ function slugify(s) {
44197
44231
  function deriveServiceConfig(input) {
44198
44232
  if (isVersionEmbeddedNodePath(input.execPath)) {
44199
44233
  throw new Error(
44200
- `install: refusing to bake a version-embedded Node path into the service definition: ${input.execPath}. Re-run install under a system/Homebrew Node, or pass --exec-path <stable-path>.`
44234
+ `The Node at ${input.execPath} moves when it is upgraded, so the background service can't use it. Install Node ${REQUIRED_NODE_MAJOR} from https://nodejs.org, then run the install command again.`
44201
44235
  );
44202
44236
  }
44203
44237
  const stem = input.label ?? basename5(input.credentialPath, extname(input.credentialPath));
@@ -44278,6 +44312,26 @@ WantedBy=default.target
44278
44312
  // src/service/install.ts
44279
44313
  var ServiceUnsupportedError = class extends Error {
44280
44314
  };
44315
+ var PREVIOUS_EXIT_WAIT_MS = 1e4;
44316
+ var PREVIOUS_EXIT_POLL_MS = 100;
44317
+ function launchdPid(deps, target) {
44318
+ const r = deps.run("launchctl", ["print", target]);
44319
+ if (r.status !== 0) return void 0;
44320
+ const m = /^\s*pid = (\d+)\s*$/m.exec(r.stdout);
44321
+ const pid = m ? Number(m[1]) : NaN;
44322
+ return Number.isInteger(pid) && pid > 0 ? pid : void 0;
44323
+ }
44324
+ function waitForPreviousExit(deps, pid) {
44325
+ const deadline = deps.now() + PREVIOUS_EXIT_WAIT_MS;
44326
+ while (deps.pidAlive(pid)) {
44327
+ if (deps.now() >= deadline) {
44328
+ throw new Error(
44329
+ `the previous bridge (pid ${pid}) did not exit within ${PREVIOUS_EXIT_WAIT_MS / 1e3} s of launchctl bootout, so a new copy would find the instance lock held and stay down. Wait for that process to exit (check with: ps -p ${pid}), then run install again.`
44330
+ );
44331
+ }
44332
+ deps.sleepMs(PREVIOUS_EXIT_POLL_MS);
44333
+ }
44334
+ }
44281
44335
  function tokenHint(input) {
44282
44336
  return `${dirname8(input.credentialPath)}/bridge-token`;
44283
44337
  }
@@ -44290,7 +44344,10 @@ function installService(input, deps) {
44290
44344
  deps.mkdirp(dirname8(cfg.plistPath));
44291
44345
  deps.mkdirp(dirname8(cfg.logPath));
44292
44346
  deps.writeFile(cfg.plistPath, renderLaunchdPlist(cfg));
44293
- deps.run("launchctl", ["bootout", `gui/${cfg.uid}/${cfg.label}`]);
44347
+ const target = `gui/${cfg.uid}/${cfg.label}`;
44348
+ const previousPid = launchdPid(deps, target);
44349
+ deps.run("launchctl", ["bootout", target]);
44350
+ if (previousPid !== void 0) waitForPreviousExit(deps, previousPid);
44294
44351
  requireOk(deps.run("launchctl", ["bootstrap", `gui/${cfg.uid}`, cfg.plistPath]), "launchctl bootstrap");
44295
44352
  deps.run("launchctl", ["enable", `gui/${cfg.uid}/${cfg.label}`]);
44296
44353
  requireOk(deps.run("launchctl", ["kickstart", "-k", `gui/${cfg.uid}/${cfg.label}`]), "launchctl kickstart");
@@ -45617,6 +45674,20 @@ function runPinCli(argv, io) {
45617
45674
  return 1;
45618
45675
  }
45619
45676
 
45677
+ // src/service/credential-arg.ts
45678
+ function readCredentialArg(argv, env) {
45679
+ const i = argv.indexOf("--credential");
45680
+ if (i >= 0) {
45681
+ const v = argv[i + 1];
45682
+ return v ? { kind: "path", path: v } : { kind: "empty_flag" };
45683
+ }
45684
+ const fromEnv = env.SECURECOMS_CREDENTIAL;
45685
+ return fromEnv ? { kind: "path", path: fromEnv } : { kind: "absent" };
45686
+ }
45687
+ function noCredentialLine(verb) {
45688
+ return verb === "run" ? "No key file was given. If you used the copied run command, no key file for this agent was found in your Downloads folder." : "No key file was found in your Downloads folder. Download the key file again from SecureComs, or add --credential <path>.";
45689
+ }
45690
+
45620
45691
  // src/bin.ts
45621
45692
  function nodeMajorOk(version) {
45622
45693
  return Number(version.split(".")[0]) >= 24;
@@ -45653,15 +45724,22 @@ function realServiceDeps() {
45653
45724
  removeFile: (p) => rmSync8(p, { force: true }),
45654
45725
  mkdirp: (p) => mkdirSync8(p, { recursive: true }),
45655
45726
  fileExists: (p) => existsSync7(p),
45656
- user: userInfo().username
45727
+ user: userInfo().username,
45728
+ pidAlive: pidAliveByKill,
45729
+ // installService is synchronous; Atomics.wait is a real blocking sleep.
45730
+ sleepMs: (ms) => {
45731
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
45732
+ },
45733
+ now: () => Date.now()
45657
45734
  };
45658
45735
  }
45659
45736
  function chooseInstallExecPath(runningExecPath, override, deps) {
45660
45737
  if (override) return override;
45661
45738
  const r = resolveStableNodePath(runningExecPath, deps);
45662
45739
  if (!r.stable) {
45740
+ const list = STABLE_LOCATIONS.slice(0, -1).join(", ") + " or " + STABLE_LOCATIONS[STABLE_LOCATIONS.length - 1];
45663
45741
  throw new Error(
45664
- "install: no reboot-stable Node found. Version-manager Nodes (nvm/asdf/fnm/volta/n) are removed on upgrade and will break the daemon after a reboot. Install a system Node (nodejs.org pkg \u2192 /usr/local/bin/node) or pass --exec-path <stable-path> to override."
45742
+ "The agent's background service needs Node " + REQUIRED_NODE_MAJOR + " or newer at a fixed location (" + list + "), and none was found. Node from nvm, fnm and similar tools moves when it is upgraded, so it is not used. Install Node " + REQUIRED_NODE_MAJOR + " from https://nodejs.org, then run the install command again."
45665
45743
  );
45666
45744
  }
45667
45745
  return r.path;
@@ -45841,9 +45919,17 @@ function runWireVerb(verb) {
45841
45919
  process.exit(verb === "wire" ? runWire(realWireDeps()) : runUnwire(realWireDeps()));
45842
45920
  }
45843
45921
  async function runServiceVerb(verb) {
45844
- const credentialPath = argOrEnv("--credential", "SECURECOMS_CREDENTIAL");
45922
+ let credentialPath;
45923
+ if (verb === "install") {
45924
+ const cred = readCredentialArg(process.argv, process.env);
45925
+ credentialPath = cred.kind === "path" ? cred.path : void 0;
45926
+ } else {
45927
+ credentialPath = argOrEnv("--credential", "SECURECOMS_CREDENTIAL");
45928
+ }
45845
45929
  const label = argOrEnv("--label", "SECURECOMS_LABEL");
45846
45930
  if (verb === "install" ? !credentialPath : !credentialPath && !label) {
45931
+ if (verb === "install") process.stderr.write(`${noCredentialLine("install")}
45932
+ `);
45847
45933
  const execPathUsage = verb === "install" ? " [--exec-path <path>]" : "";
45848
45934
  process.stderr.write(`usage: agent-bridge ${verb} --credential <path> [--label <name>] [--store <path>] [--port N] [--harness openclaw|hermes|universal]${execPathUsage}
45849
45935
  `);
@@ -45966,6 +46052,26 @@ var HELD_POLL_TOTAL_MS = 1e4;
45966
46052
  var HELD_POLL_INTERVAL_MS = 500;
45967
46053
  var HELD_NO_PID_REACQUIRES = 3;
45968
46054
  var SHUTDOWN_HARD_EXIT_MS = 8e3;
46055
+ function configErrorLine(reason, o) {
46056
+ const at = o.isTTY ? ` at ${o.path}` : "";
46057
+ switch (reason) {
46058
+ case "credential_not_found":
46059
+ return `Can't find the key file${at}. Check the path after --credential.`;
46060
+ case "credential_unreadable":
46061
+ return `Can't open the key file${at}. Check that it exists and that your user can read it.`;
46062
+ case "credential_not_json":
46063
+ case "identity_id_invalid":
46064
+ return `The key file${at} is damaged. Get a new key file from the agent's page in SecureComs.`;
46065
+ case "credential_missing_field":
46066
+ return `The key file${at} is incomplete (no ${o.field}). Get a new key file from the agent's page in SecureComs.`;
46067
+ case "credential_voice_invalid":
46068
+ return o.source === "env" ? "The voice settings in this computer's environment are not valid." : `The voice settings in the key file${at} are not valid.`;
46069
+ case "bind_refused":
46070
+ return "The Connector only accepts connections from this computer unless TLS is set. Remove --bind, or add --tls-cert and --tls-key.";
46071
+ case "unknown":
46072
+ return void 0;
46073
+ }
46074
+ }
45969
46075
  function realRunModeDeps(credentialPath, storePath) {
45970
46076
  return {
45971
46077
  credentialPath,
@@ -45996,22 +46102,56 @@ function realRunModeDeps(credentialPath, storePath) {
45996
46102
  process.stderr.write(`${line}
45997
46103
  `);
45998
46104
  },
46105
+ isTTY: process.stderr.isTTY === true,
45999
46106
  log: writeServiceLog
46000
46107
  };
46001
46108
  }
46002
46109
  async function runMode(deps) {
46003
46110
  const { credentialPath, storePath, log } = deps;
46111
+ const refuse2 = (reason, extra, e) => {
46112
+ const fields = {
46113
+ reason: ServiceLogEnum.of(reason, [
46114
+ "credential_not_found",
46115
+ "credential_unreadable",
46116
+ "credential_not_json",
46117
+ "credential_missing_field",
46118
+ "credential_voice_invalid",
46119
+ "identity_id_invalid",
46120
+ "bind_refused",
46121
+ "unknown"
46122
+ ])
46123
+ };
46124
+ if (extra.field) {
46125
+ fields.field = ServiceLogEnum.of(extra.field, [
46126
+ "api_key",
46127
+ "identity_id",
46128
+ "device_id",
46129
+ "signing_private_key",
46130
+ "enclave_id",
46131
+ "api_base"
46132
+ ]);
46133
+ }
46134
+ log("error", "startup.config_invalid", fields, e);
46135
+ const line = configErrorLine(reason, { path: credentialPath, isTTY: deps.isTTY, ...extra });
46136
+ if (line !== void 0) deps.print(line);
46137
+ };
46004
46138
  let cfg, daemon;
46005
46139
  try {
46006
46140
  cfg = loadConfig({ credentialPath, storePath });
46007
46141
  daemon = parseDaemonArgs(deps.argv, deps.env);
46142
+ } catch (e) {
46143
+ if (e instanceof ConfigError) refuse2(e.reason, { field: e.field, source: e.source }, e);
46144
+ else refuse2("unknown", {}, e);
46145
+ return deps.exit(1);
46146
+ }
46147
+ try {
46008
46148
  validateBind(daemon.bind, daemon.tls);
46009
46149
  } catch (e) {
46010
- log("error", "startup.config_invalid", {}, e);
46150
+ refuse2("bind_refused", {}, e);
46011
46151
  return deps.exit(1);
46012
46152
  }
46013
46153
  if (!CANONICAL_UUID2.test(cfg.identityId)) {
46014
- log("error", "startup.config_invalid", { field: ServiceLogEnum.of("identity_id", ["identity_id"]) });
46154
+ refuse2("identity_id_invalid", { field: "identity_id" });
46015
46155
  return deps.exit(1);
46016
46156
  }
46017
46157
  const lockDir = join25(deps.home, ".securecoms", "locks");
@@ -46050,7 +46190,9 @@ async function runMode(deps) {
46050
46190
  lock_path: ServiceLogEnum.of("home_securecoms_locks_identity", ["home_securecoms_locks_identity"]),
46051
46191
  remedy: ServiceLogEnum.of("delete_lock_if_no_other_host", ["delete_lock_if_no_other_host"])
46052
46192
  });
46053
- deps.print(`This agent's lock file was written under a different computer name: ${lockPath}. If no other computer runs this agent, delete that file and start the agent again.`);
46193
+ deps.print(
46194
+ deps.isTTY ? `This agent's lock file was written under a different computer name: ${lockPath}. If no other computer runs this agent, delete that file and start the agent again.` : "This agent's lock file was written under a different computer name. If no other computer runs this agent, delete that file and start the agent again."
46195
+ );
46054
46196
  return deps.exit(1);
46055
46197
  case "unreadable":
46056
46198
  log("error", "startup.instance_lock_unreadable", {}, lock.lastError);
@@ -46171,6 +46313,10 @@ async function runMode(deps) {
46171
46313
  }
46172
46314
  });
46173
46315
  }
46316
+ function printNoCredentialRunLine() {
46317
+ process.stderr.write(`${noCredentialLine("run")}
46318
+ `);
46319
+ }
46174
46320
  async function main() {
46175
46321
  if (!nodeMajorOk(process.versions.node)) {
46176
46322
  writeServiceLog("error", "startup.node_unsupported");
@@ -46197,11 +46343,13 @@ async function main() {
46197
46343
  await runServiceVerb(verb);
46198
46344
  return;
46199
46345
  }
46200
- const credentialPath = argOrEnv("--credential", "SECURECOMS_CREDENTIAL");
46201
- if (!credentialPath) {
46346
+ const cred = readCredentialArg(process.argv, process.env);
46347
+ if (cred.kind !== "path") {
46348
+ printNoCredentialRunLine();
46202
46349
  writeServiceLog("error", "startup.credential_required.use_--credential");
46203
46350
  process.exit(2);
46204
46351
  }
46352
+ const credentialPath = cred.path;
46205
46353
  const storePath = argOrEnv("--store", "SECURECOMS_STORE");
46206
46354
  await runMode(realRunModeDeps(credentialPath, storePath));
46207
46355
  }
@@ -46219,6 +46367,7 @@ export {
46219
46367
  HELD_POLL_TOTAL_MS,
46220
46368
  SHUTDOWN_HARD_EXIT_MS,
46221
46369
  chooseInstallExecPath,
46370
+ configErrorLine,
46222
46371
  formatServiceError,
46223
46372
  nodeMajorOk,
46224
46373
  parseVerb,
package/dist/config.d.ts CHANGED
@@ -56,6 +56,19 @@ export interface BridgeConfig {
56
56
  ownerGrantPin?: OwnerGrantPinResult;
57
57
  }
58
58
  export declare const REQUIRED: readonly ["api_key", "identity_id", "device_id", "signing_private_key", "enclave_id", "api_base"];
59
+ export type ConfigErrorReason = "credential_not_found" | "credential_unreadable" | "credential_not_json" | "credential_missing_field" | "credential_voice_invalid";
60
+ /**
61
+ * A key-file load failure with a fixed reason code, set by the step that
62
+ * failed (spec 2026-10-15 §5.2). Callers map the code to a log field and a
63
+ * fixed human line; nothing reads `message`, which carries no path, no
64
+ * identifier and no upstream error text.
65
+ */
66
+ export declare class ConfigError extends Error {
67
+ readonly reason: ConfigErrorReason;
68
+ readonly field?: (typeof REQUIRED)[number] | undefined;
69
+ readonly source?: "file" | "env" | undefined;
70
+ constructor(reason: ConfigErrorReason, field?: (typeof REQUIRED)[number] | undefined, source?: "file" | "env" | undefined);
71
+ }
59
72
  export declare function loadConfig(opts: {
60
73
  credentialPath: string;
61
74
  storePath?: string;
package/dist/index.js CHANGED
@@ -17457,7 +17457,13 @@ var CODES = [
17457
17457
  // lookup missed and before any write, so a retry can never land: the
17458
17458
  // pending commit is dropped instead of being resent forever. The same pair
17459
17459
  // answers an application send into a deleted channel (#325 F5).
17460
- { status: 409, code: "channel_unavailable" }
17460
+ { status: 409, code: "channel_unavailable" },
17461
+ // ADR-0023 B.1: an Add or Welcome into a humans-only enclave's group naming
17462
+ // a device that is not provably a person's. Refused after the replay lookup
17463
+ // missed and before any write, so nothing landed. Definite: the committer's
17464
+ // work cannot name an agent in a humans-only enclave (admission refuses
17465
+ // one), so dropping the commit starts no resend loop.
17466
+ { status: 403, code: "humans_only_enclave" }
17461
17467
  ];
17462
17468
  var DISTRIBUTE_DEFINITE_CODES = Object.freeze(CODES.map((c) => Object.freeze({ ...c })));
17463
17469
  var DEFINITE_KEYS = new Set(DISTRIBUTE_DEFINITE_CODES.map((c) => `${c.status} ${c.code}`));
@@ -42821,8 +42827,30 @@ ${channelId}`;
42821
42827
  import { readFileSync as readFileSync10, statSync as statSync5 } from "node:fs";
42822
42828
  import { dirname as dirname6, join as join18 } from "node:path";
42823
42829
  var REQUIRED = ["api_key", "identity_id", "device_id", "signing_private_key", "enclave_id", "api_base"];
42830
+ var CONFIG_ERROR_MESSAGE = {
42831
+ credential_not_found: "credential file not found",
42832
+ credential_unreadable: "credential file could not be read",
42833
+ credential_not_json: "credential file is not valid JSON",
42834
+ credential_missing_field: "credential file missing required field",
42835
+ credential_voice_invalid: "credential file voice config invalid"
42836
+ };
42837
+ var ConfigError = class extends Error {
42838
+ constructor(reason, field, source) {
42839
+ super(CONFIG_ERROR_MESSAGE[reason] + (field ? `: ${field}` : ""));
42840
+ this.reason = reason;
42841
+ this.field = field;
42842
+ this.source = source;
42843
+ this.name = "ConfigError";
42844
+ }
42845
+ };
42824
42846
  function loadConfig(opts) {
42825
- const raw = readFileSync10(opts.credentialPath, "utf8");
42847
+ let raw;
42848
+ try {
42849
+ raw = readFileSync10(opts.credentialPath, "utf8");
42850
+ } catch (e) {
42851
+ const code = e?.code;
42852
+ throw new ConfigError(code === "ENOENT" ? "credential_not_found" : "credential_unreadable");
42853
+ }
42826
42854
  try {
42827
42855
  const mode = statSync5(opts.credentialPath).mode & 511;
42828
42856
  if (mode & 63) writeServiceLog2("warn", "config.credential_permissions_unsafe");
@@ -42832,9 +42860,9 @@ function loadConfig(opts) {
42832
42860
  try {
42833
42861
  json2 = JSON.parse(raw);
42834
42862
  } catch {
42835
- throw new Error("credential file is not valid JSON");
42863
+ throw new ConfigError("credential_not_json");
42836
42864
  }
42837
- for (const k of REQUIRED) if (typeof json2[k] !== "string" || !json2[k]) throw new Error(`credential file missing required field: ${k}`);
42865
+ for (const k of REQUIRED) if (typeof json2[k] !== "string" || !json2[k]) throw new ConfigError("credential_missing_field", k);
42838
42866
  const storePath = opts.storePath ?? join18(dirname6(opts.credentialPath), "bridge-state.db");
42839
42867
  const ownerGrantPin = loadOwnerGrantPin({ credentialPath: opts.credentialPath, credentialJson: json2 });
42840
42868
  writeServiceLog2(ownerGrantPin.state === "ok" || ownerGrantPin.state === "no_pin" ? "info" : "warn", "authz.pin", {
@@ -42872,8 +42900,14 @@ function loadConfig(opts) {
42872
42900
  let voice;
42873
42901
  try {
42874
42902
  voice = parseVoiceConfig(json2.voice, process.env);
42875
- } catch (e) {
42876
- throw new Error(`credential file voice config invalid: ${e.message}`);
42903
+ } catch {
42904
+ let source = "env";
42905
+ try {
42906
+ parseVoiceConfig(json2.voice, {});
42907
+ } catch {
42908
+ source = "file";
42909
+ }
42910
+ throw new ConfigError("credential_voice_invalid", void 0, source);
42877
42911
  }
42878
42912
  const modelSource = json2.memory?.model_source === "import_only" ? "import_only" : "download";
42879
42913
  const enabledRaw = json2.memory?.enabled;
@@ -14,8 +14,6 @@ export interface UpgradeDeps {
14
14
  isTTY: boolean;
15
15
  stderr: (s: string) => void;
16
16
  ask: (q: string) => Promise<string>;
17
- hasBrew: () => boolean;
18
- runBrew: () => number;
19
17
  fetchText: (url: string) => Promise<string>;
20
18
  fetchBinary: (url: string, destPath: string) => Promise<void>;
21
19
  fileSha256: (p: string) => string;
@@ -41,11 +41,10 @@ __export(preflight_upgrade_exports, {
41
41
  sha256Hex: () => sha256Hex
42
42
  });
43
43
  function parseShasums(text, arch) {
44
- const want = "-darwin-" + arch + ".pkg";
45
44
  const lines = String(text).split("\n");
46
45
  for (let i = 0; i < lines.length; i++) {
47
46
  const m = lines[i].trim().match(/^([0-9a-f]{6,64})\s+(\S+)$/);
48
- if (m && m[2].indexOf(want) !== -1 && m[2].indexOf("node-v24.") === 0) {
47
+ if (m && PKG_NAME.test(m[2])) {
49
48
  return { filename: m[2], sha256: m[1] };
50
49
  }
51
50
  }
@@ -62,48 +61,47 @@ async function promptYesNo(question, deps) {
62
61
  }
63
62
  async function guidedUpgrade(deps) {
64
63
  if (deps.platform !== "darwin") return false;
64
+ let consented = false;
65
+ const failed = () => {
66
+ if (consented) deps.stderr("[bridge] Couldn't install Node 24 from nodejs.org.\n");
67
+ return false;
68
+ };
65
69
  try {
66
70
  const existing = deps.existingCompatibleNode();
67
71
  if (existing) {
68
72
  deps.stderr(
69
- "[bridge] Node " + deps.activeVersion + " is running, but a compatible Node (>= " + REQUIRED_NODE_MAJOR + ") is already installed at " + existing + " \u2014 it is shadowed earlier on your PATH. Continuing under it (no install needed).\n"
73
+ "[bridge] Using Node " + REQUIRED_NODE_MAJOR + " or newer found at " + existing + ". (The node command on this computer is an older version.)\n"
70
74
  );
71
75
  return deps.reexec(existing);
72
76
  }
73
- const viaBrew = deps.hasBrew();
74
- const source = viaBrew ? "Homebrew (brew install node \u2014 user-space, no sudo)" : "the official installer from " + DIST_BASE + " (needs your admin password)";
75
77
  const ok = await promptYesNo(
76
- "[bridge] Node 24 LTS is required. Install it now via " + source + "? [y/N] ",
78
+ "[bridge] SecureComs needs Node 24. Install it now from nodejs.org? This needs your computer's administrator password. [y/N] ",
77
79
  deps
78
80
  );
79
81
  if (!ok) return false;
80
- if (viaBrew) {
81
- deps.stderr("[bridge] running: brew install node\n");
82
- if (deps.runBrew() !== 0) return false;
83
- } else {
84
- const shasums = await deps.fetchText(DIST_BASE + "/SHASUMS256.txt");
85
- const entry = parseShasums(shasums, deps.arch);
86
- if (!entry) return false;
87
- const dest = deps.tmpdir() + "/" + entry.filename;
88
- deps.stderr("[bridge] downloading " + DIST_BASE + "/" + entry.filename + "\n");
89
- try {
90
- await deps.fetchBinary(DIST_BASE + "/" + entry.filename, dest);
91
- if (deps.fileSha256(dest) !== entry.sha256) {
92
- deps.stderr("[bridge] checksum mismatch \u2014 aborting install (file discarded)\n");
93
- return false;
94
- }
95
- deps.stderr("[bridge] running: sudo installer -pkg " + entry.filename + " -target /\n");
96
- if (deps.runSudoInstaller(dest) !== 0) return false;
97
- } finally {
98
- deps.cleanup(dest);
82
+ consented = true;
83
+ const shasums = await deps.fetchText(DIST_BASE + "/SHASUMS256.txt");
84
+ const entry = parseShasums(shasums, deps.arch);
85
+ if (!entry) return failed();
86
+ const dest = deps.tmpdir() + "/" + entry.filename;
87
+ deps.stderr("[bridge] downloading " + DIST_BASE + "/" + entry.filename + "\n");
88
+ try {
89
+ await deps.fetchBinary(DIST_BASE + "/" + entry.filename, dest);
90
+ if (deps.fileSha256(dest) !== entry.sha256) {
91
+ deps.stderr("[bridge] checksum mismatch \u2014 aborting install (file discarded)\n");
92
+ return failed();
99
93
  }
94
+ deps.stderr("[bridge] running: sudo installer -pkg " + entry.filename + " -target /\n");
95
+ if (deps.runSudoInstaller(dest) !== 0) return failed();
96
+ } finally {
97
+ deps.cleanup(dest);
100
98
  }
101
99
  const fresh = deps.resolveNewNode();
102
- if (!fresh) return false;
100
+ if (!fresh) return failed();
103
101
  deps.stderr("[bridge] Node 24 installed \u2014 continuing under " + fresh + "\n");
104
102
  return deps.reexec(fresh);
105
103
  } catch {
106
- return false;
104
+ return failed();
107
105
  }
108
106
  }
109
107
  function resolveNewNodeFrom(probe, candidates) {
@@ -152,11 +150,6 @@ function realUpgradeDeps(originalArgv) {
152
150
  resolve(a);
153
151
  });
154
152
  }),
155
- hasBrew: () => spawnSync("brew", ["--version"], { stdio: "ignore" }).status === 0,
156
- runBrew: () => {
157
- var _a;
158
- return (_a = spawnSync("brew", ["install", "node"], { stdio: "inherit" }).status) != null ? _a : 1;
159
- },
160
153
  fetchText: async (url) => {
161
154
  const res = await fetch(url, 3);
162
155
  let out = "";
@@ -197,12 +190,13 @@ function realUpgradeDeps(originalArgv) {
197
190
  }
198
191
  };
199
192
  }
200
- var DIST_BASE, NODE_CANDIDATES;
193
+ var DIST_BASE, PKG_NAME, NODE_CANDIDATES;
201
194
  var init_preflight_upgrade = __esm({
202
195
  "src/preflight-upgrade.cts"() {
203
196
  "use strict";
204
197
  init_resolve_node();
205
198
  DIST_BASE = "https://nodejs.org/dist/latest-v24.x";
199
+ PKG_NAME = /^node-v24\.\d+\.\d+\.pkg$/;
206
200
  NODE_CANDIDATES = [
207
201
  "/usr/local/bin/node",
208
202
  // nodejs.org pkg (stable across upgrades)
@@ -216,6 +210,7 @@ var init_preflight_upgrade = __esm({
216
210
  // src/preflight.cts
217
211
  var preflight_exports = {};
218
212
  __export(preflight_exports, {
213
+ NODE_FLOOR: () => NODE_FLOOR,
219
214
  effectiveVersion: () => effectiveVersion,
220
215
  instructionsBlock: () => instructionsBlock,
221
216
  meetsFloor: () => meetsFloor,
@@ -224,7 +219,6 @@ __export(preflight_exports, {
224
219
  });
225
220
  module.exports = __toCommonJS(preflight_exports);
226
221
  var NODE_FLOOR = 24;
227
- var FLOOR_REASON = "requires >= 24 (node:sqlite stable API)";
228
222
  function parseNodeMajor(version) {
229
223
  const n = Number(String(version).split(".")[0]);
230
224
  return Number.isFinite(n) && n > 0 ? n : 0;
@@ -239,23 +233,30 @@ function effectiveVersion(real, faked) {
239
233
  return real;
240
234
  }
241
235
  function instructionsBlock(platform, current) {
242
- const lines = [
243
- "[bridge] Node " + current + " is too old \u2014 SecureComs " + FLOOR_REASON + ".",
244
- "",
245
- "How to get Node 24 LTS:",
246
- " 1. Official installer (recommended): https://nodejs.org \u2014 download the",
247
- " Node 24 LTS installer for your platform and run it."
248
- ];
236
+ const n = String(NODE_FLOOR);
237
+ const head = "[bridge] This computer has Node " + current + ". SecureComs needs Node " + n + " or newer.";
238
+ let lines;
249
239
  if (platform === "darwin") {
250
- lines.push(" 2. Homebrew: brew install node");
240
+ lines = [
241
+ head,
242
+ "",
243
+ "To get Node " + n + ":",
244
+ " 1. Download the Node " + n + " LTS installer for macOS from https://nodejs.org and run it.",
245
+ " 2. Run the SecureComs install command again."
246
+ ];
247
+ } else if (platform === "win32") {
248
+ lines = [
249
+ head,
250
+ "",
251
+ "Install Node " + n + " from https://nodejs.org, then run the SecureComs install command again."
252
+ ];
253
+ } else {
254
+ lines = [
255
+ head,
256
+ "",
257
+ "Install Node " + n + " so that it is at /usr/local/bin/node or /usr/bin/node, then run the SecureComs install command again. See https://nodejs.org for options."
258
+ ];
251
259
  }
252
- lines.push(" " + (platform === "darwin" ? "3" : "2") + ". nvm: nvm install 24 && nvm use 24");
253
- lines.push(
254
- "",
255
- "Then re-run the install command. (Always re-run the install command after",
256
- "upgrading Node \u2014 the background service pins the Node path it was",
257
- "installed with.)"
258
- );
259
260
  return lines.join("\n") + "\n";
260
261
  }
261
262
  async function runPreflight(deps) {
@@ -292,6 +293,7 @@ if (require.main === module) {
292
293
  }
293
294
  // Annotate the CommonJS export names for ESM import in node:
294
295
  0 && (module.exports = {
296
+ NODE_FLOOR,
295
297
  effectiveVersion,
296
298
  instructionsBlock,
297
299
  meetsFloor,
@@ -1,4 +1,18 @@
1
1
  #!/usr/bin/env node
2
+ /**
3
+ * CommonJS preflight shim — the package's bin entry. CJS on purpose: any
4
+ * Node back to v10 can parse it, so a machine with stale Node always gets
5
+ * the friendly diagnosis instead of an ESM/builtin crash (static imports in
6
+ * the ESM bundle hoist — node:sqlite would detonate before any guard ran).
7
+ * Built with esbuild format=cjs target=node10 to dist/preflight.cjs.
8
+ *
9
+ * AUDIT C1: this file must contain ZERO dynamic `import()` — esbuild's
10
+ * node10 downlevel would turn it into require() (cannot load ESM), and
11
+ * keeping the syntax breaks node10 parse. The real import lives in
12
+ * preflight-modern.cts, which is only require()d AFTER the gate passes.
13
+ * An artifact test greps the emitted file to pin this.
14
+ */
15
+ export declare const NODE_FLOOR = 24;
2
16
  export declare function parseNodeMajor(version: string): number;
3
17
  export declare function meetsFloor(version: string): boolean;
4
18
  export declare function effectiveVersion(real: string, faked: string | undefined): string;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * How `--credential` is read for `install` and run mode (spec 2026-10-15
3
+ * §5.3, OQ-18 (b)). A flag that is present with an empty value means "no key
4
+ * file": it never falls back to SECURECOMS_CREDENTIAL, so a copied command
5
+ * whose Downloads lookup found nothing cannot silently pick up a different
6
+ * agent's key file from the environment. The variable is used only when the
7
+ * flag is absent. Every other flag keeps argOrEnv's behaviour.
8
+ */
9
+ export type CredentialArg = {
10
+ kind: "path";
11
+ path: string;
12
+ } | {
13
+ kind: "empty_flag";
14
+ } | {
15
+ kind: "absent";
16
+ };
17
+ export declare function readCredentialArg(argv: string[], env: NodeJS.ProcessEnv): CredentialArg;
18
+ /**
19
+ * The one line printed when no key file was given: E8 for run mode
20
+ * (approved), E9 for install (DRAFT: the approved E9 names the agent, which
21
+ * the bridge cannot know when no key file was found).
22
+ */
23
+ export declare function noCredentialLine(verb: "run" | "install"): string;
@@ -13,7 +13,14 @@ export interface ServiceDeps {
13
13
  mkdirp: (path: string) => void;
14
14
  fileExists: (path: string) => boolean;
15
15
  user: string;
16
+ /** kill(pid, 0): true while the process runs (EPERM counts as running). */
17
+ pidAlive: (pid: number) => boolean;
18
+ /** Blocking sleep: installService is synchronous end to end. */
19
+ sleepMs: (ms: number) => void;
20
+ now: () => number;
16
21
  }
22
+ /** How long install waits for the previous bridge to exit after bootout. */
23
+ export declare const PREVIOUS_EXIT_WAIT_MS = 10000;
17
24
  export interface InstallResult {
18
25
  label: string;
19
26
  filePath: string;
@@ -7,6 +7,7 @@ export interface ResolveNodeDeps {
7
7
  arch: () => string;
8
8
  archOf: (p: string) => string | null;
9
9
  }
10
+ export declare const STABLE_LOCATIONS: readonly string[];
10
11
  export declare function resolveStableNodePath(runningExecPath: string, deps: ResolveNodeDeps): {
11
12
  path: string;
12
13
  stable: boolean;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const BRIDGE_VERSION = "0.30.1";
1
+ export declare const BRIDGE_VERSION = "0.30.2";
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@securecoms/agent-bridge",
3
- "version": "0.30.1",
3
+ "version": "0.30.2",
4
4
  "type": "module",
5
5
  "engines": {
6
- "node": ">=22.5.0"
6
+ "node": ">=24"
7
7
  },
8
8
  "main": "dist/index.js",
9
9
  "types": "dist/index.d.ts",