@securecoms/agent-bridge 0.30.0 → 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/dist/bin.js CHANGED
@@ -7556,7 +7556,7 @@ __export(import_bundle_exports, {
7556
7556
  importModelBundle: () => importModelBundle
7557
7557
  });
7558
7558
  import { createHash as createHash6, randomBytes as randomBytes12 } from "node:crypto";
7559
- import { readFileSync as readFileSync14, statSync as statSync6, writeFileSync as writeFileSync4 } from "node:fs";
7559
+ import { readFileSync as readFileSync15, statSync as statSync7, writeFileSync as writeFileSync4 } from "node:fs";
7560
7560
  import { join as join24 } from "node:path";
7561
7561
  async function importModelBundle(srcDir, root3, m) {
7562
7562
  const verified = [];
@@ -7564,7 +7564,7 @@ async function importModelBundle(srcDir, root3, m) {
7564
7564
  const src = join24(srcDir, f.name);
7565
7565
  let size;
7566
7566
  try {
7567
- const s = statSync6(src);
7567
+ const s = statSync7(src);
7568
7568
  if (!s.isFile()) throw new Error("not a file");
7569
7569
  size = s.size;
7570
7570
  } catch {
@@ -7574,7 +7574,7 @@ async function importModelBundle(srcDir, root3, m) {
7574
7574
  if (size !== f.size) throw mismatch;
7575
7575
  let bytes;
7576
7576
  try {
7577
- bytes = readFileSync14(src);
7577
+ bytes = readFileSync15(src);
7578
7578
  } catch {
7579
7579
  throw new ModelImportError(`${f.name} is missing from the import directory`);
7580
7580
  }
@@ -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}`));
@@ -24803,6 +24837,7 @@ function base64UrlNoPad(s) {
24803
24837
  }
24804
24838
  var BEARER_SUBPROTOCOL = "securecoms.bearer.v1";
24805
24839
  var WS_CLOSE_IDENTITY_REVOKED = 4006;
24840
+ var WS_CLOSE_SESSION_SUPERSEDED = 4409;
24806
24841
  function isIdentityRevokedClose(code, reason) {
24807
24842
  return code === WS_CLOSE_IDENTITY_REVOKED || reason === "identity_revoked";
24808
24843
  }
@@ -25018,7 +25053,7 @@ function openDeliveryPings(o) {
25018
25053
  end();
25019
25054
  };
25020
25055
  if (!hardened) {
25021
- if (code === 4001 || isIdentityRevokedClose(code, reason)) {
25056
+ if (code === 4001 || code === WS_CLOSE_SESSION_SUPERSEDED || isIdentityRevokedClose(code, reason)) {
25022
25057
  terminalClose();
25023
25058
  return;
25024
25059
  }
@@ -25027,7 +25062,7 @@ function openDeliveryPings(o) {
25027
25062
  }
25028
25063
  const tr = terminalReason;
25029
25064
  terminalReason = void 0;
25030
- if (code === 4006 || tr && code === 4001) {
25065
+ if (code === 4006 || code === WS_CLOSE_SESSION_SUPERSEDED || tr && code === 4001) {
25031
25066
  lifecycle("terminal", tr ?? `code ${code}`);
25032
25067
  terminalClose();
25033
25068
  return;
@@ -25184,10 +25219,14 @@ var RestTransport = class {
25184
25219
  f;
25185
25220
  ws;
25186
25221
  tokenProvider;
25187
- /** The most recent device-wide WS send handle, captured on subscribe and
25188
- * cleared on its close. Control frames (status keepalive) route through it;
25189
- * a no-op when no subscription is active. */
25222
+ /** The shared delivery-ping socket's send, set while that socket is open and
25223
+ * cleared when it closes (last subscriber gone, or terminal). Control frames
25224
+ * (status keepalive) route through it; a no-op when no subscription is active. */
25190
25225
  _controlSend = null;
25226
+ /** P-5: the one delivery-ping stream this transport holds, shared by every subscriber. */
25227
+ pingHub = null;
25228
+ /** Set once the shared stream ends on a terminal close; no socket is ever reopened. */
25229
+ pingsTerminal = false;
25191
25230
  constructor(opts) {
25192
25231
  this.opts = opts;
25193
25232
  this.f = opts.fetchImpl ?? fetch;
@@ -25289,25 +25328,52 @@ var RestTransport = class {
25289
25328
  idempotentReplay: j.idempotent_replay === true
25290
25329
  };
25291
25330
  }
25331
+ /** One device-wide delivery-ping socket per transport (P-5): the backend
25332
+ * supersedes a second socket for the same device, so every subscriber shares
25333
+ * one `openDeliveryPings` handle. Each gets its own bounded queue; its close()
25334
+ * detaches only itself; the socket closes with the last subscriber and a later
25335
+ * subscribe opens a fresh one. After a terminal close the transport is
25336
+ * terminal: later calls open no socket and get an already-ended iterator. */
25292
25337
  subscribeDeliveryPings(_channelId) {
25338
+ if (this.pingsTerminal) {
25339
+ const ended = new PingSubscriber();
25340
+ ended.end();
25341
+ return { pings: ended.pings, close: () => {
25342
+ }, send: () => {
25343
+ } };
25344
+ }
25345
+ const hub = this.pingHub ??= this.openPingHub();
25346
+ const sub = hub.attach();
25347
+ return { pings: sub.pings, close: () => hub.detach(sub), send: hub.send };
25348
+ }
25349
+ openPingHub() {
25293
25350
  let wsUrl = this.opts.baseUrl.replace(/^http/, "ws").replace(/\/$/, "") + "/api/v1/ws";
25294
25351
  if (this.opts.connectIdentityId) {
25295
25352
  wsUrl += `?connect_identity_id=${encodeURIComponent(this.opts.connectIdentityId)}`;
25296
25353
  }
25297
- const handle = openDeliveryPings({
25354
+ const hub = new PingHub(openDeliveryPings({
25298
25355
  url: wsUrl,
25299
25356
  tokenProvider: this.tokenProvider,
25300
25357
  wsImpl: this.ws,
25301
25358
  hardened: this.opts.hardenedReconnect ?? false,
25302
- onLifecycle: this.opts.onWsLifecycle
25303
- });
25304
- this._controlSend = handle.send;
25305
- const close = () => {
25306
- if (this._controlSend === handle.send)
25307
- this._controlSend = null;
25308
- handle.close();
25309
- };
25310
- return { pings: handle.pings, close, send: handle.send };
25359
+ onLifecycle: this.opts.onWsLifecycle,
25360
+ // Any terminal close (4001, 4006, 4409, hardened war-cap) of the shared
25361
+ // socket makes this transport terminal. The shared iterator ends right
25362
+ // after this call, so the pump exit tears down every subscriber.
25363
+ onTerminalClose: (close) => {
25364
+ this.pingsTerminal = true;
25365
+ this.opts.onTerminalClose?.(close);
25366
+ }
25367
+ }), () => this.dropPingHub(hub));
25368
+ this._controlSend = hub.send;
25369
+ return hub;
25370
+ }
25371
+ dropPingHub(hub) {
25372
+ if (this.pingHub === hub) {
25373
+ this.pingHub = null;
25374
+ this._controlSend = null;
25375
+ }
25376
+ hub.shutdown();
25311
25377
  }
25312
25378
  sendControlFrame(_channelId, frame) {
25313
25379
  try {
@@ -25335,6 +25401,90 @@ var RestTransport = class {
25335
25401
  }));
25336
25402
  }
25337
25403
  };
25404
+ var SUBSCRIBER_QUEUE_MAX = 8;
25405
+ var PingSubscriber = class {
25406
+ queue = [];
25407
+ waiter;
25408
+ ended = false;
25409
+ started = false;
25410
+ pings = {
25411
+ [Symbol.asyncIterator]: () => {
25412
+ if (this.started)
25413
+ throw new Error("subscribeDeliveryPings: single-use async iterable");
25414
+ this.started = true;
25415
+ return {
25416
+ next: () => {
25417
+ if (this.ended)
25418
+ return Promise.resolve({ value: void 0, done: true });
25419
+ if (this.queue.length)
25420
+ return Promise.resolve({ value: this.queue.shift(), done: false });
25421
+ return new Promise((resolve4) => {
25422
+ this.waiter = resolve4;
25423
+ });
25424
+ }
25425
+ };
25426
+ }
25427
+ };
25428
+ push(p) {
25429
+ if (this.ended)
25430
+ return;
25431
+ if (this.waiter) {
25432
+ const w = this.waiter;
25433
+ this.waiter = void 0;
25434
+ w({ value: p, done: false });
25435
+ } else if (this.queue.length < SUBSCRIBER_QUEUE_MAX) {
25436
+ this.queue.push(p);
25437
+ }
25438
+ }
25439
+ end() {
25440
+ this.ended = true;
25441
+ this.queue.length = 0;
25442
+ if (this.waiter) {
25443
+ const w = this.waiter;
25444
+ this.waiter = void 0;
25445
+ w({ value: void 0, done: true });
25446
+ }
25447
+ }
25448
+ };
25449
+ var PingHub = class {
25450
+ handle;
25451
+ onGone;
25452
+ subs = /* @__PURE__ */ new Set();
25453
+ send;
25454
+ constructor(handle, onGone) {
25455
+ this.handle = handle;
25456
+ this.onGone = onGone;
25457
+ this.send = handle.send;
25458
+ void this.pump();
25459
+ }
25460
+ /** `openDeliveryPings` iterables never reject and push() never throws, so
25461
+ * this loop only ends when the shared stream ends (our close, or terminal). */
25462
+ async pump() {
25463
+ for await (const p of this.handle.pings)
25464
+ for (const s of this.subs)
25465
+ s.push(p);
25466
+ this.onGone();
25467
+ }
25468
+ attach() {
25469
+ const s = new PingSubscriber();
25470
+ this.subs.add(s);
25471
+ return s;
25472
+ }
25473
+ /** Idempotent: detaches and ends only `s`; the last one out closes the socket. */
25474
+ detach(s) {
25475
+ this.subs.delete(s);
25476
+ s.end();
25477
+ if (this.subs.size === 0)
25478
+ this.onGone();
25479
+ }
25480
+ /** Idempotent: `openDeliveryPings().close()` is, and the set is empty after. */
25481
+ shutdown() {
25482
+ for (const s of this.subs)
25483
+ s.end();
25484
+ this.subs.clear();
25485
+ this.handle.close();
25486
+ }
25487
+ };
25338
25488
 
25339
25489
  // ../connect-sdk-ts/dist/mls/rest-backend.js
25340
25490
  function describeType(v) {
@@ -35208,6 +35358,7 @@ var ROOM_DEFAULT_READ_GRANTS = Object.freeze([
35208
35358
  Object.freeze({ tool: "current_time", mode: "read" })
35209
35359
  ]);
35210
35360
  var NO_TOOL_GRANTS = Object.freeze([]);
35361
+ var WS_CLOSE_SESSION_SUPERSEDED2 = 4409;
35211
35362
  var OWNER_REPLAY_PRUNE_INTERVAL_MS = 5 * 60 * 1e3;
35212
35363
  var OWNER_TURN_UNCHECKED = Object.freeze({ kind: "unchecked" });
35213
35364
  function ownerDmDefaultGrantsEnabled(env = process.env) {
@@ -35482,6 +35633,12 @@ function rosterFreshAt(entry, heldEpoch, heldGroupId) {
35482
35633
  var RoomGrantEpochMovedError = class extends Error {
35483
35634
  };
35484
35635
  var ROOM_GRANT_TRACK_MAX = 4096;
35636
+ var SessionSupersededError = class extends Error {
35637
+ constructor() {
35638
+ super("session_superseded: another copy of this agent took its session during start()");
35639
+ this.name = "SessionSupersededError";
35640
+ }
35641
+ };
35485
35642
  var GroupSupersededSendError = class extends Error {
35486
35643
  constructor(channelId, activeGroupId) {
35487
35644
  super(`send refused for channel ${channelId}: the channel moved to a new secure group; not sent`);
@@ -35521,9 +35678,18 @@ var BridgeAgent = class _BridgeAgent {
35521
35678
  const seed = hexToBytes4(cfg.signingPrivateKey);
35522
35679
  this.custody = SoftwareKeyCustody.fromPrivateKey("ed25519", seed);
35523
35680
  this.replyIdKey = deriveReplyIdKey(seed);
35681
+ const base = deps.fetchImpl ?? fetch;
35682
+ this.fetchImpl = (input, init) => this.sessionSuperseded ? Promise.reject(new Error("session_superseded")) : base(input, init);
35524
35683
  this.transport = deps.transport ?? new RestTransport({
35525
35684
  baseUrl: cfg.apiBase,
35526
35685
  bearerToken: cfg.apiKey,
35686
+ fetchImpl: this.fetchImpl,
35687
+ wsImpl: deps.wsImpl,
35688
+ // A 4409 close means another copy of this agent took the session.
35689
+ // Every other terminal close keeps today's behaviour.
35690
+ onTerminalClose: (close) => {
35691
+ if (close.code === WS_CLOSE_SESSION_SUPERSEDED2) this.onSessionSuperseded();
35692
+ },
35527
35693
  connectIdentityId: cfg.identityId,
35528
35694
  // P1b: hardened delivery-ping reconnect (verify-before-terminal +
35529
35695
  // half-open + wake + auth war-cap). On by default for the daemon;
@@ -35537,7 +35703,7 @@ var BridgeAgent = class _BridgeAgent {
35537
35703
  else writeServiceLog("warn", "transport.delivery_ping_lifecycle_unknown");
35538
35704
  }
35539
35705
  });
35540
- this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
35706
+ this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey, fetchImpl: this.fetchImpl });
35541
35707
  this.store = new SqliteMlsStateStore({
35542
35708
  dbPath: cfg.storePath,
35543
35709
  identityId: cfg.identityId,
@@ -35608,7 +35774,6 @@ var BridgeAgent = class _BridgeAgent {
35608
35774
  // throws (mirrors the divergence-heal signal path).
35609
35775
  onSupersedeUnopenable: (channelId, groupId) => this.healSupersedeUnopenable(channelId, groupId, this.drainExemptionAtCallIn())
35610
35776
  });
35611
- this.fetchImpl = deps.fetchImpl ?? fetch;
35612
35777
  this.home = deps.home ?? homedir4();
35613
35778
  this.grantWriters = {
35614
35779
  writeManifestPolicy: deps.grantWriters?.writeManifestPolicy ?? writeManifestPolicy,
@@ -35740,6 +35905,13 @@ var BridgeAgent = class _BridgeAgent {
35740
35905
  // stored for peek-then-join in ingestOnce
35741
35906
  custody;
35742
35907
  fetchImpl;
35908
+ /** P-1: set once a 4409 close says another copy took this agent's session.
35909
+ * The gated fetch built in the constructor refuses every request after it. */
35910
+ sessionSuperseded = false;
35911
+ /** C9: who hears `session_superseded` (runMode's quiesce). */
35912
+ sessionSupersededListeners = /* @__PURE__ */ new Set();
35913
+ /** C9 (spec §4.1.6, low 3): the one close() run every caller shares. */
35914
+ closing = null;
35743
35915
  /** Round 5: home for the managed runtime-config directory (test seam). */
35744
35916
  home;
35745
35917
  grantWriters;
@@ -36215,6 +36387,12 @@ var BridgeAgent = class _BridgeAgent {
36215
36387
  action;
36216
36388
  waiters = [];
36217
36389
  pingHandle;
36390
+ /** C10 (P-15): the delivery-ping subscription start() opens at its top,
36391
+ * held unconsumed until startReceivePump() takes it. */
36392
+ earlyPings = null;
36393
+ /** C10 fix round 1: settles (never rejects) when start()'s body does; close()
36394
+ * awaits it, bounded, so the store never closes under a start() step. */
36395
+ startInFlight = null;
36218
36396
  safetyTimer;
36219
36397
  /** The ingest cycle now running, if any (see ingestOnce: single-flight). */
36220
36398
  ingestInFlight = null;
@@ -36792,7 +36970,50 @@ var BridgeAgent = class _BridgeAgent {
36792
36970
  this.droppedMessages += overflow;
36793
36971
  }
36794
36972
  }
36973
+ /**
36974
+ * C10 (spec P-15, OQ-8 (b)): subscribe early, consume late. The
36975
+ * delivery-ping socket opens here, before anything else, so this copy claims
36976
+ * the device's session (and another copy gets its 4409) BEFORE this copy's
36977
+ * boot drain runs. The pings it carries are NOT consumed until
36978
+ * startReceivePump(), the same place as before: starting the pump first is
36979
+ * still forbidden (DRAIN BEFORE ROTATE, startBody).
36980
+ *
36981
+ * A 4409 during start() (audit H-3): the gated fetch is already set (C9 step
36982
+ * 0). start() then arms nothing more (supersededDuringStart before each
36983
+ * timer and before the pump), runs the memoized close() quiesce, and rejects
36984
+ * with SessionSupersededError. A step that FAILED because of the supersede
36985
+ * (a refused fetch, a store the quiesce closed) is the same outcome.
36986
+ */
36795
36987
  async start() {
36988
+ const early = this.transport.subscribeDeliveryPings("");
36989
+ this.earlyPings = early;
36990
+ this.pingHandle = { close: early.close };
36991
+ const body = this.startBody();
36992
+ this.startInFlight = body.then(() => {
36993
+ }, () => {
36994
+ });
36995
+ try {
36996
+ await body;
36997
+ } catch (e) {
36998
+ if (!this.sessionSuperseded) {
36999
+ if (this.earlyPings) {
37000
+ this.earlyPings.close();
37001
+ this.earlyPings = null;
37002
+ this.pingHandle = void 0;
37003
+ }
37004
+ throw e;
37005
+ }
37006
+ if (!(e instanceof SessionSupersededError)) writeServiceLog("warn", "startup.superseded_start_error", {}, e);
37007
+ await this.close().catch(() => {
37008
+ });
37009
+ throw new SessionSupersededError();
37010
+ }
37011
+ }
37012
+ /** C10: true once a 4409 landed; checked before each arming step of start(). */
37013
+ supersededDuringStart() {
37014
+ return this.sessionSuperseded;
37015
+ }
37016
+ async startBody() {
36796
37017
  this.startedAt = Date.now();
36797
37018
  await this.restoreRuntimeInbox();
36798
37019
  await this.loadToolGrantState();
@@ -36852,10 +37073,12 @@ var BridgeAgent = class _BridgeAgent {
36852
37073
  await this.warmRestoredNotes();
36853
37074
  await this.reindexRestoredTurns();
36854
37075
  if (this.unifiedOn) await this.reindexUnifiedRestored();
37076
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
36855
37077
  this.runtimeConfigTimer = setInterval(() => {
36856
37078
  void this.refetchRuntimeConfig();
36857
37079
  }, 6 * 60 * 60 * 1e3);
36858
37080
  this.runtimeConfigTimer.unref?.();
37081
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
36859
37082
  this.membershipSweepTimer = setInterval(() => {
36860
37083
  if (this.closingDown || this.membershipSweepWork) return;
36861
37084
  this.membershipSweepWork = this.runMembershipSweeps().finally(() => {
@@ -36863,6 +37086,7 @@ var BridgeAgent = class _BridgeAgent {
36863
37086
  });
36864
37087
  }, PURGE_SWEEP_INTERVAL_MS);
36865
37088
  this.membershipSweepTimer.unref?.();
37089
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
36866
37090
  this.recallPruneTimer = setInterval(() => {
36867
37091
  if (this.closingDown || this.recallPruneWork) return;
36868
37092
  this.recallPruneWork = this.runRecallPruneTick().finally(() => {
@@ -36870,6 +37094,7 @@ var BridgeAgent = class _BridgeAgent {
36870
37094
  });
36871
37095
  }, 60 * 60 * 1e3);
36872
37096
  this.recallPruneTimer.unref?.();
37097
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
36873
37098
  if (this.machinePaths) this.recallMaintenanceTimer = setInterval(() => {
36874
37099
  if (this.closingDown) return;
36875
37100
  const n = ++this.maintenanceTickN;
@@ -36880,6 +37105,7 @@ var BridgeAgent = class _BridgeAgent {
36880
37105
  });
36881
37106
  }, 1e3);
36882
37107
  this.recallMaintenanceTimer?.unref?.();
37108
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
36883
37109
  this.startReceivePump();
36884
37110
  if (drainedInTime) {
36885
37111
  this.armSelfHealSweeps();
@@ -41143,7 +41369,8 @@ var BridgeAgent = class _BridgeAgent {
41143
41369
  }
41144
41370
  }
41145
41371
  startReceivePump() {
41146
- const sub = this.transport.subscribeDeliveryPings("");
41372
+ const sub = this.earlyPings ?? this.transport.subscribeDeliveryPings("");
41373
+ this.earlyPings = null;
41147
41374
  this.pingHandle = { close: sub.close };
41148
41375
  void (async () => {
41149
41376
  try {
@@ -42728,7 +42955,42 @@ ${channelId}`;
42728
42955
  build: BRIDGE_VERSION
42729
42956
  };
42730
42957
  }
42731
- async close() {
42958
+ /** C9: `session_superseded` once a 4409 said another copy took this
42959
+ * agent's session (the gated fetch already refuses every request). The
42960
+ * local HTTP routes read it: every route but `/healthz` answers 503. */
42961
+ get sessionState() {
42962
+ return this.sessionSuperseded ? "session_superseded" : "live";
42963
+ }
42964
+ /** C9: subscribe to `session_superseded`. runMode subscribes right after
42965
+ * construction, before start(), so a 4409 during start() is heard too. */
42966
+ on(event, listener) {
42967
+ this.sessionSupersededListeners.add(listener);
42968
+ return () => this.sessionSupersededListeners.delete(listener);
42969
+ }
42970
+ /** P-1 step 0, then the event (spec §4.1.6): another copy of this agent took
42971
+ * the session (4409). The gated-fetch switch is set FIRST, so no HTTP
42972
+ * request leaves the process after this, whatever a listener does. */
42973
+ onSessionSuperseded() {
42974
+ this.sessionSuperseded = true;
42975
+ for (const listener of [...this.sessionSupersededListeners]) {
42976
+ try {
42977
+ listener();
42978
+ } catch (e) {
42979
+ writeServiceLog("error", "transport.session_superseded_listener_failed", {}, e);
42980
+ }
42981
+ }
42982
+ }
42983
+ /**
42984
+ * Idempotent (C9, spec §4.1.6 low 3): every call returns the FIRST call's
42985
+ * promise, so a SIGTERM during or after the supersede quiesce never runs
42986
+ * store.close() twice. A rejected close stays rejected: a retry would close
42987
+ * the store a second time.
42988
+ */
42989
+ close() {
42990
+ this.closing ??= this.closeOnce();
42991
+ return this.closing;
42992
+ }
42993
+ async closeOnce() {
42732
42994
  this.closingDown = true;
42733
42995
  this.statusGate.close();
42734
42996
  for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
@@ -42741,6 +43003,28 @@ ${channelId}`;
42741
43003
  if (this.safetyTimer) clearInterval(this.safetyTimer);
42742
43004
  if (this.runtimeConfigTimer) clearInterval(this.runtimeConfigTimer);
42743
43005
  if (this.membershipSweepTimer) clearInterval(this.membershipSweepTimer);
43006
+ const ingest = this.ingestInFlight;
43007
+ if (ingest) {
43008
+ let timer;
43009
+ await Promise.race([
43010
+ ingest.released,
43011
+ new Promise((resolve4) => {
43012
+ timer = setTimeout(resolve4, _BridgeAgent.GRACEFUL_DRAIN_MS);
43013
+ })
43014
+ ]);
43015
+ if (timer) clearTimeout(timer);
43016
+ }
43017
+ const starting = this.startInFlight;
43018
+ if (starting) {
43019
+ let timer;
43020
+ await Promise.race([
43021
+ starting,
43022
+ new Promise((resolve4) => {
43023
+ timer = setTimeout(resolve4, _BridgeAgent.GRACEFUL_DRAIN_MS);
43024
+ })
43025
+ ]);
43026
+ if (timer) clearTimeout(timer);
43027
+ }
42744
43028
  if (this.membershipSweepWork) {
42745
43029
  let timer;
42746
43030
  await Promise.race([
@@ -42899,6 +43183,9 @@ var BRIDGE_ERRORS = {
42899
43183
  // longer holds the shared bytes or key. Terminal for that link: never fall
42900
43184
  // back to the current version. `detail.pin` names the refused pin.
42901
43185
  vault_ref_changed: { http: 422, message: "this file changed in Files after it was shared; not opened" },
43186
+ // C9 (spec §4.1.6 step 1): another copy of this agent took its SecureComs
43187
+ // session (WS 4409), so this copy stopped. Every local route but /healthz.
43188
+ session_superseded: { http: 503, message: "another copy of this agent took its session; this copy stopped" },
42902
43189
  internal: { http: 500, message: "internal error" }
42903
43190
  };
42904
43191
  function errorBody(code, detail) {
@@ -43129,6 +43416,10 @@ function createHttpServer(agent, opts) {
43129
43416
  const url = new URL(req.url ?? "/", "http://localhost");
43130
43417
  const path = url.pathname;
43131
43418
  const method = req.method ?? "GET";
43419
+ if (agent.sessionState === "session_superseded") {
43420
+ if (method === "GET" && path === "/healthz") return json(res, 200, { state: "session_superseded" });
43421
+ return jsonError(res, "session_superseded");
43422
+ }
43132
43423
  if (method === "GET" && path === "/healthz") {
43133
43424
  return json(res, 200, { ...agent.health(), capabilities: capabilities(opts) });
43134
43425
  }
@@ -43340,6 +43631,7 @@ var PING_INTERVAL_MS = 3e4;
43340
43631
  var INFLIGHT_STALE_SWEEP_MS = 6e4;
43341
43632
  var INFLIGHT_STALE_AFTER_MS = 15 * 6e4;
43342
43633
  var HELLO_WAIT_MS = 2e3;
43634
+ var WS_CLOSE_SESSION_SUPERSEDED3 = 4409;
43343
43635
  var MEMORY_NOTES_FEATURE = "memory_notes";
43344
43636
  var MEMORY_RECALL_FEATURE = "memory_recall";
43345
43637
  var MEMORY_UNIFIED_FEATURE = "memory_unified";
@@ -43360,7 +43652,12 @@ function authedUpgrade(req, opts) {
43360
43652
  return true;
43361
43653
  }
43362
43654
  function attachAgentWebSocket(server, agent, opts) {
43363
- const wss = new WebSocketServer({ server, path: "/v1/ws" });
43655
+ let sessionSuperseded = false;
43656
+ const wss = new WebSocketServer({
43657
+ server,
43658
+ path: "/v1/ws",
43659
+ verifyClient: (_info, done) => sessionSuperseded ? done(false, 503, "session_superseded") : done(true)
43660
+ });
43364
43661
  const keepalive = setInterval(() => {
43365
43662
  for (const client of wss.clients) {
43366
43663
  const sock = client;
@@ -43500,6 +43797,7 @@ function attachAgentWebSocket(server, agent, opts) {
43500
43797
  if (helloTimer) clearTimeout(helloTimer);
43501
43798
  agent.noteSessionClose();
43502
43799
  unsubscribe();
43800
+ if (sessionSuperseded) inflight.clear();
43503
43801
  if (inflight.size) {
43504
43802
  agent.requeueFailedRuntimeDeliveries(
43505
43803
  [...inflight.values()].filter((m) => !agent.isRuntimeMessageRetired(m.message_id))
@@ -43521,7 +43819,10 @@ function attachAgentWebSocket(server, agent, opts) {
43521
43819
  sock.on("close", cleanup);
43522
43820
  sock.on("error", cleanup);
43523
43821
  sock.on("message", (data) => {
43524
- void handleInbound(sock, agent, data, inflight, flush, session);
43822
+ if (sessionSuperseded) return;
43823
+ handleInbound(sock, agent, data, inflight, flush, session).catch((e) => {
43824
+ writeServiceLog("error", "ws.inbound_failed", {}, e);
43825
+ });
43525
43826
  });
43526
43827
  flush();
43527
43828
  });
@@ -43536,6 +43837,13 @@ function attachAgentWebSocket(server, agent, opts) {
43536
43837
  }
43537
43838
  }
43538
43839
  wss.close();
43840
+ },
43841
+ /** C9 (spec §4.1.6 step 1, OQ-9): close every open socket with 4409 /
43842
+ * `session_superseded`, and answer every later upgrade with 503. The
43843
+ * runtime's reconnect backoff does the rest; idempotent. */
43844
+ enterSessionSuperseded() {
43845
+ sessionSuperseded = true;
43846
+ for (const client of wss.clients) client.close(WS_CLOSE_SESSION_SUPERSEDED3, "session_superseded");
43539
43847
  }
43540
43848
  };
43541
43849
  }
@@ -43837,11 +44145,12 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
43837
44145
  var LOWER_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
43838
44146
 
43839
44147
  // src/bin.ts
43840
- import { readFileSync as readFileSync15, mkdirSync as mkdirSync7, writeFileSync as writeFileSync5, rmSync as rmSync8, existsSync as existsSync7, realpathSync as realpathSync3, readdirSync as readdirSync4, statSync as statSync7 } from "node:fs";
43841
- import { join as join25, dirname as dirname12, isAbsolute as isAbsolute3, resolve as resolve3 } from "node:path";
44148
+ import { readFileSync as readFileSync16, mkdirSync as mkdirSync8, writeFileSync as writeFileSync5, rmSync as rmSync8, existsSync as existsSync7, realpathSync as realpathSync3, readdirSync as readdirSync4, statSync as statSync8, chmodSync as chmodSync6 } from "node:fs";
44149
+ import { join as join25, dirname as dirname13, isAbsolute as isAbsolute3, resolve as resolve3 } from "node:path";
43842
44150
  import { fileURLToPath as fileURLToPath4 } from "node:url";
43843
44151
  import { homedir as homedir5, userInfo } from "node:os";
43844
44152
  import { spawnSync } from "node:child_process";
44153
+ import { createServer as netCreateServer } from "node:net";
43845
44154
 
43846
44155
  // src/service/install.ts
43847
44156
  import { dirname as dirname8 } from "node:path";
@@ -43922,7 +44231,7 @@ function slugify(s) {
43922
44231
  function deriveServiceConfig(input) {
43923
44232
  if (isVersionEmbeddedNodePath(input.execPath)) {
43924
44233
  throw new Error(
43925
- `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.`
43926
44235
  );
43927
44236
  }
43928
44237
  const stem = input.label ?? basename5(input.credentialPath, extname(input.credentialPath));
@@ -43962,7 +44271,10 @@ ${programArgs}
43962
44271
  <key>RunAtLoad</key>
43963
44272
  <true/>
43964
44273
  <key>KeepAlive</key>
43965
- <true/>
44274
+ <dict>
44275
+ <key>SuccessfulExit</key>
44276
+ <false/>
44277
+ </dict>
43966
44278
  <key>StandardOutPath</key>
43967
44279
  <string>${escapeXml(cfg.logPath)}</string>
43968
44280
  <key>StandardErrorPath</key>
@@ -43989,7 +44301,7 @@ Wants=network-online.target
43989
44301
  [Service]
43990
44302
  Type=simple
43991
44303
  ExecStart=${execStart}
43992
- Restart=always
44304
+ Restart=on-failure
43993
44305
  RestartSec=5
43994
44306
 
43995
44307
  [Install]
@@ -44000,6 +44312,26 @@ WantedBy=default.target
44000
44312
  // src/service/install.ts
44001
44313
  var ServiceUnsupportedError = class extends Error {
44002
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
+ }
44003
44335
  function tokenHint(input) {
44004
44336
  return `${dirname8(input.credentialPath)}/bridge-token`;
44005
44337
  }
@@ -44012,7 +44344,10 @@ function installService(input, deps) {
44012
44344
  deps.mkdirp(dirname8(cfg.plistPath));
44013
44345
  deps.mkdirp(dirname8(cfg.logPath));
44014
44346
  deps.writeFile(cfg.plistPath, renderLaunchdPlist(cfg));
44015
- 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);
44016
44351
  requireOk(deps.run("launchctl", ["bootstrap", `gui/${cfg.uid}`, cfg.plistPath]), "launchctl bootstrap");
44017
44352
  deps.run("launchctl", ["enable", `gui/${cfg.uid}/${cfg.label}`]);
44018
44353
  requireOk(deps.run("launchctl", ["kickstart", "-k", `gui/${cfg.uid}/${cfg.label}`]), "launchctl kickstart");
@@ -44462,6 +44797,10 @@ ${customFacts(input)}`, wired: false, error: false };
44462
44797
  }
44463
44798
 
44464
44799
  // src/service/install-prep.ts
44800
+ function isDaemonRouted(h) {
44801
+ return h != null && h.state !== "session_superseded";
44802
+ }
44803
+ var VERIFY_BUDGET_MS = BOOT_DRAIN_WAIT_MS_DEFAULT + 15e3;
44465
44804
  function assertReadableCredential(absPath, deps) {
44466
44805
  let raw;
44467
44806
  try {
@@ -44489,6 +44828,44 @@ function durableDir(home, slug) {
44489
44828
  function durableCredentialPath(home, slug) {
44490
44829
  return join21(durableDir(home, slug), `${slug}.json`);
44491
44830
  }
44831
+ var DURABLE_PREFIX = "securecoms-agent-";
44832
+ var InstallRefusedError = class extends Error {
44833
+ };
44834
+ function existingIdentityError(dir, label) {
44835
+ return new InstallRefusedError(
44836
+ `This agent is already installed in ${dir} with a different key. Uninstall it first: agent-bridge uninstall --label ${label}`
44837
+ );
44838
+ }
44839
+ function refuseExistingIdentity(args2, deps) {
44840
+ const destName = `${DURABLE_PREFIX}${args2.slug}`;
44841
+ let names;
44842
+ try {
44843
+ names = deps.listDir(args2.home);
44844
+ } catch {
44845
+ names = [];
44846
+ }
44847
+ const others = names.filter((n) => n.startsWith(DURABLE_PREFIX) && n !== destName).sort();
44848
+ for (const name of [destName, ...others]) {
44849
+ const slug = name.slice(DURABLE_PREFIX.length);
44850
+ const dir = join21(args2.home, name);
44851
+ const credPath = join21(dir, `${slug}.json`);
44852
+ if (!deps.fileExists(credPath)) continue;
44853
+ let existing = null;
44854
+ try {
44855
+ const j = JSON.parse(deps.readFile(credPath));
44856
+ if (j && typeof j === "object") existing = j;
44857
+ } catch {
44858
+ }
44859
+ if (!existing || typeof existing.identity_id !== "string" || !existing.identity_id) {
44860
+ if (deps.fileExists(join21(dir, "bridge-state.db"))) throw existingIdentityError(dir, slug);
44861
+ continue;
44862
+ }
44863
+ if (existing.identity_id !== args2.parsed.identityId) continue;
44864
+ if (name !== destName || existing.device_id !== args2.parsed.deviceId) {
44865
+ throw existingIdentityError(dir, slug);
44866
+ }
44867
+ }
44868
+ }
44492
44869
  function takeCustody(args2, deps) {
44493
44870
  const dir = durableDir(args2.home, args2.slug);
44494
44871
  const dest = durableCredentialPath(args2.home, args2.slug);
@@ -44496,10 +44873,11 @@ function takeCustody(args2, deps) {
44496
44873
  if (deps.fileExists(dest) && deps.realpath(args2.sourceAbsPath) === deps.realpath(dest)) return dest;
44497
44874
  } catch {
44498
44875
  }
44876
+ refuseExistingIdentity(args2, deps);
44499
44877
  if (deps.fileExists(dest)) {
44500
44878
  try {
44501
44879
  const existing = JSON.parse(deps.readFile(dest));
44502
- if (existing.device_id && existing.device_id !== args2.parsed.deviceId) {
44880
+ if (existing.identity_id && existing.identity_id !== args2.parsed.identityId) {
44503
44881
  throw new Error(
44504
44882
  `install: ${dir} already belongs to a different agent (device ${existing.device_id}). Pass a distinct --label to install this agent in its own directory.`
44505
44883
  );
@@ -44554,21 +44932,19 @@ function selectPort(args2, deps) {
44554
44932
  throw new Error(`install: no free port in ${PORT_MIN}-${PORT_MAX} \u2014 stop an unused bridge or pass --port`);
44555
44933
  }
44556
44934
  async function verifyDaemonUp(args2, deps) {
44557
- const attempts = args2.attempts ?? 20;
44558
44935
  const delayMs = args2.delayMs ?? 250;
44559
- for (let i = 0; i < attempts; i++) {
44560
- if (deps.probeConnect(args2.port)) return true;
44561
- try {
44562
- if (deps.readFile(args2.logPath).includes("[bridge] listening on")) return true;
44563
- } catch {
44564
- }
44936
+ const deadline = deps.now() + VERIFY_BUDGET_MS;
44937
+ const maxAttempts = args2.attempts ?? Number.POSITIVE_INFINITY;
44938
+ for (let i = 0; i < maxAttempts; i++) {
44939
+ if (isDaemonRouted(deps.getHealth(args2.port))) return true;
44940
+ if (deps.now() >= deadline) return false;
44565
44941
  await deps.sleep(delayMs);
44566
44942
  }
44567
44943
  return false;
44568
44944
  }
44569
44945
  async function orchestrateInstall(ctx) {
44570
44946
  const parsed = assertReadableCredential(ctx.credentialAbsPath, ctx.prepDeps);
44571
- const stemForSlug = ctx.label ?? ctx.credentialAbsPath.replace(/^.*[\\/]/, "").replace(/\.[^.]*$/, "");
44947
+ const stemForSlug = ctx.label ?? ctx.credentialAbsPath.replace(/^.*[\\/]/, "").replace(/\.[^.]*$/, "").replace(/ \(\d+\)$/, "");
44572
44948
  const slug = slugify(stemForSlug);
44573
44949
  const durablePath = takeCustody(
44574
44950
  { home: ctx.home, slug, sourceAbsPath: ctx.credentialAbsPath, parsed },
@@ -44692,13 +45068,13 @@ function readFleet(deps) {
44692
45068
  // src/service/status.ts
44693
45069
  function readFleetStatus(deps) {
44694
45070
  return deps.readFleet().map((e) => {
44695
- const up = e.port != null && deps.probeConnect(e.port);
45071
+ const h = e.port != null ? deps.getHealth(e.port) : null;
45072
+ const up = isDaemonRouted(h);
44696
45073
  let harness = "-";
44697
45074
  let evictions = 0;
44698
- if (up && e.port != null) {
44699
- const h = deps.getHealth(e.port);
44700
- harness = (h?.agent_sessions ?? 0) >= 1 ? "attached" : "no brain";
44701
- evictions = h?.evictions ?? 0;
45075
+ if (up) {
45076
+ harness = (h.agent_sessions ?? 0) >= 1 ? "attached" : "no brain";
45077
+ evictions = h.evictions ?? 0;
44702
45078
  }
44703
45079
  return {
44704
45080
  slug: e.slug,
@@ -44790,20 +45166,339 @@ function removeConnectionFileIfOwned(home, identityId, instanceId, deps = REAL_F
44790
45166
  }
44791
45167
  }
44792
45168
 
45169
+ // src/service/instance-lock.ts
45170
+ import { execFileSync } from "node:child_process";
45171
+ import { closeSync as closeSync4, fsyncSync, mkdirSync as mkdirSync7, openSync as openSync4, readFileSync as readFileSync13, statSync as statSync6, unlinkSync as unlinkSync3, utimesSync, writeSync as writeSync2 } from "node:fs";
45172
+ import { hostname, uptime as uptime2 } from "node:os";
45173
+ import { dirname as dirname11 } from "node:path";
45174
+ var LOCK_PARTIAL_WRITE_GRACE_MS = 1e4;
45175
+ var LOCK_TAKEOVER_GUARD_STALE_MS = 3e4;
45176
+ var BOOT_TOLERANCE_SEC = 120;
45177
+ var LOCK_HEARTBEAT_MS = 6e4;
45178
+ var LOCK_FOREIGN_STALE_MS = 10 * LOCK_HEARTBEAT_MS;
45179
+ function errno(error) {
45180
+ const code = error?.code;
45181
+ return typeof code === "string" ? code : void 0;
45182
+ }
45183
+ function readTrimmed(path) {
45184
+ try {
45185
+ const v = readFileSync13(path, "utf8").trim();
45186
+ return v === "" ? void 0 : v;
45187
+ } catch {
45188
+ return void 0;
45189
+ }
45190
+ }
45191
+ function procStartFromStat(stat) {
45192
+ const close = stat.lastIndexOf(")");
45193
+ if (close < 0) return void 0;
45194
+ const rest = stat.slice(close + 1).trim().split(/\s+/);
45195
+ const v = rest[19];
45196
+ return v !== void 0 && /^\d+$/.test(v) ? v : void 0;
45197
+ }
45198
+ function pidAliveByKill(pid) {
45199
+ try {
45200
+ process.kill(pid, 0);
45201
+ return true;
45202
+ } catch (error) {
45203
+ return errno(error) === "EPERM";
45204
+ }
45205
+ }
45206
+ var bootIdCache;
45207
+ function readBootId() {
45208
+ if (bootIdCache) return bootIdCache.v;
45209
+ let v = readTrimmed("/proc/sys/kernel/random/boot_id");
45210
+ if (v === void 0 && process.platform === "darwin") {
45211
+ try {
45212
+ const out = execFileSync("/usr/sbin/sysctl", ["-n", "kern.bootsessionuuid"], {
45213
+ encoding: "utf8",
45214
+ timeout: 2e3,
45215
+ stdio: ["ignore", "pipe", "ignore"]
45216
+ }).trim();
45217
+ v = /^[0-9A-Fa-f-]{16,64}$/.test(out) ? out : void 0;
45218
+ } catch {
45219
+ v = void 0;
45220
+ }
45221
+ }
45222
+ bootIdCache = { v };
45223
+ return v;
45224
+ }
45225
+ var realLockEnv = {
45226
+ pid: process.pid,
45227
+ hostname: () => hostname(),
45228
+ bootId: readBootId,
45229
+ bootEpochSec: () => Math.round(Date.now() / 1e3 - uptime2()),
45230
+ procStart: (pid) => {
45231
+ const stat = readTrimmed(`/proc/${pid}/stat`);
45232
+ return stat ? procStartFromStat(stat) : void 0;
45233
+ },
45234
+ pidAlive: pidAliveByKill
45235
+ };
45236
+ function parseBody(raw) {
45237
+ try {
45238
+ const b = JSON.parse(raw);
45239
+ if (b && b.v === 2 && Number.isInteger(b.pid) && b.pid > 0 && typeof b.host === "string" && Number.isFinite(b.boot)) {
45240
+ return b;
45241
+ }
45242
+ } catch {
45243
+ }
45244
+ return void 0;
45245
+ }
45246
+ var InstanceLock = class {
45247
+ constructor(path, env = realLockEnv, log = () => {
45248
+ }, heartbeatMs = LOCK_HEARTBEAT_MS) {
45249
+ this.path = path;
45250
+ this.env = env;
45251
+ this.log = log;
45252
+ this.heartbeatMs = heartbeatMs;
45253
+ }
45254
+ held = false;
45255
+ /** The exact body this process wrote (#808 N1): release and the ownership
45256
+ * check compare the file against THIS, never against live machine facts
45257
+ * (a hostname can change under a running process). */
45258
+ written;
45259
+ heartbeat;
45260
+ /** The error behind the last `unreadable` / `failed`, for the caller's one
45261
+ * log line (the lock itself never logs a refusal: it is retried per call). */
45262
+ lastError;
45263
+ /** PID read from the lock body of the live holder behind the last `held`. */
45264
+ holderPid;
45265
+ body() {
45266
+ const bootId = this.env.bootId();
45267
+ const start = this.env.procStart(this.env.pid);
45268
+ const b = {
45269
+ v: 2,
45270
+ pid: this.env.pid,
45271
+ host: this.env.hostname(),
45272
+ ...bootId !== void 0 ? { boot_id: bootId } : {},
45273
+ boot: this.env.bootEpochSec(),
45274
+ ...start !== void 0 ? { start } : {}
45275
+ };
45276
+ return JSON.stringify(b);
45277
+ }
45278
+ /** Try to hold the lock. Never throws (#808 L-e: machine reads included). */
45279
+ acquire() {
45280
+ this.holderPid = void 0;
45281
+ let result;
45282
+ try {
45283
+ result = this.acquireUnguarded();
45284
+ } catch (error) {
45285
+ this.lastError = error;
45286
+ result = "failed";
45287
+ }
45288
+ return result === "held" && this.holderPid !== void 0 ? { result, holderPid: this.holderPid } : { result };
45289
+ }
45290
+ /**
45291
+ * True while the file is still exactly the body this process wrote. Read
45292
+ * before every reserve (one small read), so a holder whose lock was taken
45293
+ * while it was frozen stops before it spends (#808 N1). A lost lock is
45294
+ * dropped: the caller then refuses, and later calls try to re-acquire.
45295
+ */
45296
+ stillHeld() {
45297
+ if (!this.held) return false;
45298
+ let current;
45299
+ try {
45300
+ current = readFileSync13(this.path, "utf8");
45301
+ } catch {
45302
+ current = void 0;
45303
+ }
45304
+ if (current !== void 0 && current === this.written) return true;
45305
+ this.drop();
45306
+ this.log("warn", "bridge.instance_lock_lost", {});
45307
+ return false;
45308
+ }
45309
+ acquireUnguarded() {
45310
+ if (this.held) return "acquired";
45311
+ const created = this.create();
45312
+ if (created === "created") return this.took();
45313
+ if (created === "failed") return "failed";
45314
+ let raw;
45315
+ let ageMs;
45316
+ this.env.beforeLockRead?.();
45317
+ try {
45318
+ raw = readFileSync13(this.path, "utf8");
45319
+ ageMs = Date.now() - statSync6(this.path).mtimeMs;
45320
+ } catch (error) {
45321
+ if (errno(error) === "ENOENT") {
45322
+ const again = this.create();
45323
+ return again === "created" ? this.took() : again === "failed" ? "failed" : "busy";
45324
+ }
45325
+ this.lastError = error;
45326
+ return "unreadable";
45327
+ }
45328
+ const b = parseBody(raw);
45329
+ if (!b) {
45330
+ if (ageMs < LOCK_PARTIAL_WRITE_GRACE_MS) return "busy";
45331
+ return this.takeOver(raw, void 0);
45332
+ }
45333
+ if (b.host !== this.env.hostname()) {
45334
+ const bootId = this.env.bootId();
45335
+ const sameKernel = b.boot_id !== void 0 && bootId !== void 0 && b.boot_id === bootId;
45336
+ if (sameKernel && ageMs > LOCK_FOREIGN_STALE_MS) return this.takeOver(raw, b.pid);
45337
+ return "foreign_host";
45338
+ }
45339
+ if (!this.sameBoot(b)) return this.takeOver(raw, b.pid);
45340
+ const liveStart = this.env.procStart(b.pid);
45341
+ const reused = b.start !== void 0 && liveStart !== void 0 && liveStart !== b.start;
45342
+ if (b.pid === this.env.pid) {
45343
+ if (!reused) return this.took();
45344
+ return this.takeOver(raw, b.pid);
45345
+ }
45346
+ if (this.env.pidAlive(b.pid) && !reused) {
45347
+ this.holderPid = b.pid;
45348
+ return "held";
45349
+ }
45350
+ return this.takeOver(raw, b.pid);
45351
+ }
45352
+ /** Remove the lock only while it is still exactly the body this process
45353
+ * wrote (#808 N1: not the live hostname, which can change under us). */
45354
+ release() {
45355
+ if (!this.held) return;
45356
+ const written = this.written;
45357
+ this.drop();
45358
+ try {
45359
+ if (written !== void 0 && readFileSync13(this.path, "utf8") === written) unlinkSync3(this.path);
45360
+ } catch {
45361
+ }
45362
+ }
45363
+ drop() {
45364
+ this.held = false;
45365
+ if (this.heartbeat) clearInterval(this.heartbeat);
45366
+ this.heartbeat = void 0;
45367
+ }
45368
+ /** We hold it: remember what we wrote (the file as it is now, ours) and
45369
+ * start the heartbeat. */
45370
+ took() {
45371
+ this.held = true;
45372
+ try {
45373
+ this.written = readFileSync13(this.path, "utf8");
45374
+ } catch {
45375
+ this.written = void 0;
45376
+ }
45377
+ if (!this.heartbeat && this.heartbeatMs > 0) {
45378
+ this.heartbeat = setInterval(() => this.beat(), this.heartbeatMs);
45379
+ this.heartbeat.unref?.();
45380
+ }
45381
+ return "acquired";
45382
+ }
45383
+ /** Refresh the mtime while the file is still ours; stop if it is not. */
45384
+ beat() {
45385
+ if (!this.stillHeld()) return;
45386
+ try {
45387
+ const now = /* @__PURE__ */ new Date();
45388
+ utimesSync(this.path, now, now);
45389
+ } catch {
45390
+ }
45391
+ }
45392
+ sameBoot(b) {
45393
+ const bootId = this.env.bootId();
45394
+ if (b.boot_id !== void 0 && bootId !== void 0) return b.boot_id === bootId;
45395
+ return Math.abs(b.boot - this.env.bootEpochSec()) <= BOOT_TOLERANCE_SEC;
45396
+ }
45397
+ /** O_EXCL create with our body. A write failure removes the half-written
45398
+ * file (it is ours: we created it) and reports `failed`, never throws. */
45399
+ create() {
45400
+ let fd;
45401
+ try {
45402
+ mkdirSync7(dirname11(this.path), { recursive: true, mode: 448 });
45403
+ fd = openSync4(this.path, "wx", 384);
45404
+ } catch (error) {
45405
+ if (errno(error) === "EEXIST") return "exists";
45406
+ this.lastError = error;
45407
+ return "failed";
45408
+ }
45409
+ try {
45410
+ writeSync2(fd, this.body());
45411
+ fsyncSync(fd);
45412
+ } catch (error) {
45413
+ try {
45414
+ closeSync4(fd);
45415
+ } catch {
45416
+ }
45417
+ try {
45418
+ unlinkSync3(this.path);
45419
+ } catch {
45420
+ }
45421
+ this.lastError = error;
45422
+ return "failed";
45423
+ }
45424
+ closeSync4(fd);
45425
+ return "created";
45426
+ }
45427
+ /**
45428
+ * Replace a stale lock. Only the holder of the O_EXCL takeover guard may
45429
+ * remove a lock, and only after re-reading it UNCHANGED, so two runtimes
45430
+ * that both saw the same stale lock cannot each delete the other's new one.
45431
+ * A fresh guard means a takeover is in progress: busy this time.
45432
+ */
45433
+ takeOver(staleRaw, stalePid, retryGuard = true) {
45434
+ const guard = `${this.path}.takeover`;
45435
+ let fd;
45436
+ try {
45437
+ fd = openSync4(guard, "wx", 384);
45438
+ } catch (error) {
45439
+ if (errno(error) !== "EEXIST") return "failed";
45440
+ let crashed = false;
45441
+ try {
45442
+ crashed = Date.now() - statSync6(guard).mtimeMs > LOCK_TAKEOVER_GUARD_STALE_MS;
45443
+ if (crashed) unlinkSync3(guard);
45444
+ } catch {
45445
+ crashed = false;
45446
+ }
45447
+ if (crashed && retryGuard) return this.takeOver(staleRaw, stalePid, false);
45448
+ return "busy";
45449
+ }
45450
+ closeSync4(fd);
45451
+ try {
45452
+ this.env.beforeTakeoverReread?.();
45453
+ let current;
45454
+ try {
45455
+ current = readFileSync13(this.path, "utf8");
45456
+ } catch {
45457
+ current = void 0;
45458
+ }
45459
+ if (current !== void 0 && current !== staleRaw) {
45460
+ this.holderPid = parseBody(current)?.pid;
45461
+ return "held";
45462
+ }
45463
+ if (current !== void 0) unlinkSync3(this.path);
45464
+ this.env.beforeTakeoverCreate?.();
45465
+ const created = this.create();
45466
+ if (created !== "created") {
45467
+ if (created === "failed") return "failed";
45468
+ try {
45469
+ this.holderPid = parseBody(readFileSync13(this.path, "utf8"))?.pid;
45470
+ } catch {
45471
+ }
45472
+ return "held";
45473
+ }
45474
+ this.log("warn", "bridge.instance_lock_stale_taken_over", stalePid !== void 0 ? { stale_pid: stalePid } : {});
45475
+ return this.took();
45476
+ } catch (error) {
45477
+ this.lastError = error;
45478
+ return "failed";
45479
+ } finally {
45480
+ try {
45481
+ unlinkSync3(guard);
45482
+ } catch {
45483
+ }
45484
+ }
45485
+ }
45486
+ };
45487
+
44793
45488
  // src/grants/pin-command.ts
44794
45489
  import {
44795
- closeSync as closeSync4,
45490
+ closeSync as closeSync5,
44796
45491
  fchmodSync,
44797
- fsyncSync,
45492
+ fsyncSync as fsyncSync2,
44798
45493
  lstatSync as lstatSync8,
44799
- openSync as openSync4,
44800
- readFileSync as readFileSync13,
45494
+ openSync as openSync5,
45495
+ readFileSync as readFileSync14,
44801
45496
  realpathSync as realpathSync2,
44802
45497
  renameSync as renameSync5,
44803
- unlinkSync as unlinkSync3,
44804
- writeSync as writeSync2
45498
+ unlinkSync as unlinkSync4,
45499
+ writeSync as writeSync3
44805
45500
  } from "node:fs";
44806
- import { dirname as dirname11, join as join23, resolve as resolve2 } from "node:path";
45501
+ import { dirname as dirname12, join as join23, resolve as resolve2 } from "node:path";
44807
45502
  var PIN_COPY = Object.freeze({
44808
45503
  saved: "pin saved. Restart the Connector to use it.",
44809
45504
  // [COPY, approved by Eric 2026-10-12]
@@ -44834,27 +45529,27 @@ function isSymlink(path) {
44834
45529
  }
44835
45530
  }
44836
45531
  function readJson(path) {
44837
- return JSON.parse(readFileSync13(path, "utf8"));
45532
+ return JSON.parse(readFileSync14(path, "utf8"));
44838
45533
  }
44839
45534
  function publicKeyOf(v) {
44840
45535
  const r = parsePinObject(v);
44841
45536
  return r.ok ? r.pin.publicKeyB64 : null;
44842
45537
  }
44843
45538
  function writeTemp(path, bytes) {
44844
- const fd = openSync4(path, "wx", 384);
45539
+ const fd = openSync5(path, "wx", 384);
44845
45540
  try {
44846
45541
  fchmodSync(fd, 384);
44847
- writeSync2(fd, bytes);
44848
- fsyncSync(fd);
45542
+ writeSync3(fd, bytes);
45543
+ fsyncSync2(fd);
44849
45544
  } finally {
44850
- closeSync4(fd);
45545
+ closeSync5(fd);
44851
45546
  }
44852
45547
  }
44853
45548
  function runPinCommand(opts) {
44854
- const credDir = dirname11(resolve2(opts.credentialPath));
45549
+ const credDir = dirname12(resolve2(opts.credentialPath));
44855
45550
  const target = join23(credDir, PIN_FILE_NAME);
44856
45551
  try {
44857
- if (realpathSync2(dirname11(resolve2(opts.pinSourcePath))) !== realpathSync2(credDir)) return refuse("directory_mismatch");
45552
+ if (realpathSync2(dirname12(resolve2(opts.pinSourcePath))) !== realpathSync2(credDir)) return refuse("directory_mismatch");
44858
45553
  } catch {
44859
45554
  return refuse("io_error");
44860
45555
  }
@@ -44901,7 +45596,7 @@ function runPinCommand(opts) {
44901
45596
  const fail = () => {
44902
45597
  for (const p of created) {
44903
45598
  try {
44904
- unlinkSync3(p);
45599
+ unlinkSync4(p);
44905
45600
  } catch {
44906
45601
  }
44907
45602
  }
@@ -44937,11 +45632,11 @@ function runPinCommand(opts) {
44937
45632
  created.splice(created.indexOf(credTmp), 1);
44938
45633
  step("cred_renamed");
44939
45634
  try {
44940
- const dfd = openSync4(credDir, "r");
45635
+ const dfd = openSync5(credDir, "r");
44941
45636
  try {
44942
- fsyncSync(dfd);
45637
+ fsyncSync2(dfd);
44943
45638
  } finally {
44944
- closeSync4(dfd);
45639
+ closeSync5(dfd);
44945
45640
  }
44946
45641
  } catch {
44947
45642
  }
@@ -44979,6 +45674,20 @@ function runPinCli(argv, io) {
44979
45674
  return 1;
44980
45675
  }
44981
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
+
44982
45691
  // src/bin.ts
44983
45692
  function nodeMajorOk(version) {
44984
45693
  return Number(version.split(".")[0]) >= 24;
@@ -44997,7 +45706,7 @@ function parseVerb(argv) {
44997
45706
  return v === "install" || v === "uninstall" || v === "status" || v === "wire" || v === "unwire" || v === "models" || v === "pin" ? v : null;
44998
45707
  }
44999
45708
  function slugFromCredentialPath(credentialPath) {
45000
- const dir = dirname12(credentialPath);
45709
+ const dir = dirname13(credentialPath);
45001
45710
  const base = dir.split(/[\\/]/).pop() ?? "";
45002
45711
  const m = base.match(/^securecoms-agent-(.+)$/);
45003
45712
  if (m) return m[1];
@@ -45013,17 +45722,24 @@ function realServiceDeps() {
45013
45722
  },
45014
45723
  writeFile: (p, data) => writeFileSync5(p, data, { mode: 420 }),
45015
45724
  removeFile: (p) => rmSync8(p, { force: true }),
45016
- mkdirp: (p) => mkdirSync7(p, { recursive: true }),
45725
+ mkdirp: (p) => mkdirSync8(p, { recursive: true }),
45017
45726
  fileExists: (p) => existsSync7(p),
45018
- 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()
45019
45734
  };
45020
45735
  }
45021
45736
  function chooseInstallExecPath(runningExecPath, override, deps) {
45022
45737
  if (override) return override;
45023
45738
  const r = resolveStableNodePath(runningExecPath, deps);
45024
45739
  if (!r.stable) {
45740
+ const list = STABLE_LOCATIONS.slice(0, -1).join(", ") + " or " + STABLE_LOCATIONS[STABLE_LOCATIONS.length - 1];
45025
45741
  throw new Error(
45026
- "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."
45027
45743
  );
45028
45744
  }
45029
45745
  return r.path;
@@ -45057,10 +45773,10 @@ function realResolveDeps() {
45057
45773
  }
45058
45774
  function realPrepDeps() {
45059
45775
  return {
45060
- readFile: (p) => readFileSync15(p, "utf8"),
45776
+ readFile: (p) => readFileSync16(p, "utf8"),
45061
45777
  writeFileMode: (p, data, mode) => writeFileSync5(p, data, { mode }),
45062
45778
  mkdirp: (p, mode) => {
45063
- mkdirSync7(p, { recursive: true, mode: mode ?? 493 });
45779
+ mkdirSync8(p, { recursive: true, mode: mode ?? 493 });
45064
45780
  },
45065
45781
  fileExists: (p) => existsSync7(p),
45066
45782
  realpath: (p) => realpathSync3(p),
@@ -45080,25 +45796,22 @@ function realPrepDeps() {
45080
45796
  ]);
45081
45797
  return r.status === 0;
45082
45798
  },
45083
- probeConnect: (port) => {
45084
- const r = spawnSync(process.execPath, [
45085
- "-e",
45086
- `const s=require('net').connect(${port},'127.0.0.1');s.once('connect',()=>{s.destroy();process.exit(0)});s.once('error',()=>process.exit(1));setTimeout(()=>process.exit(1),500);`
45087
- ]);
45088
- return r.status === 0;
45089
- },
45799
+ getHealth: realGetHealth,
45090
45800
  // No unref: an unref'd timer would let the process exit mid-verification.
45091
- sleep: (ms) => new Promise((res) => setTimeout(res, ms))
45801
+ sleep: (ms) => new Promise((res) => setTimeout(res, ms)),
45802
+ now: () => Date.now()
45092
45803
  };
45093
45804
  }
45094
45805
  function realGetHealth(port) {
45095
45806
  const r = spawnSync(process.execPath, [
45096
45807
  "-e",
45097
- `const http=require('http');const q=http.get({host:'127.0.0.1',port:${port},path:'/healthz',timeout:800},res=>{let d='';res.on('data',c=>d+=c);res.on('end',()=>{process.stdout.write(d);process.exit(0)})});q.on('error',()=>process.exit(1));q.on('timeout',()=>process.exit(1));`
45098
- ], { encoding: "utf8" });
45808
+ `const http=require('http');const q=http.get({host:'127.0.0.1',port:${port},path:'/healthz',timeout:800},res=>{let d='';res.on('data',c=>d+=c);res.on('end',()=>{process.stdout.write(res.statusCode+'\\n'+d);process.exit(0)})});q.on('error',()=>process.exit(1));q.on('timeout',()=>process.exit(1));`
45809
+ ], { encoding: "utf8", timeout: 2e3 });
45099
45810
  if (r.status !== 0) return null;
45811
+ const nl = r.stdout.indexOf("\n");
45812
+ if (nl < 0 || r.stdout.slice(0, nl) !== "200") return null;
45100
45813
  try {
45101
- return JSON.parse(r.stdout);
45814
+ return JSON.parse(r.stdout.slice(nl + 1));
45102
45815
  } catch {
45103
45816
  return null;
45104
45817
  }
@@ -45113,19 +45826,12 @@ function realReadFleet() {
45113
45826
  return [];
45114
45827
  }
45115
45828
  },
45116
- readFile: (p) => readFileSync15(p, "utf8")
45829
+ readFile: (p) => readFileSync16(p, "utf8")
45117
45830
  });
45118
45831
  }
45119
45832
  function runStatus() {
45120
45833
  const deps = {
45121
45834
  readFleet: realReadFleet,
45122
- probeConnect: (port) => {
45123
- const r = spawnSync(process.execPath, [
45124
- "-e",
45125
- `const s=require('net').connect(${port},'127.0.0.1');s.once('connect',()=>{s.destroy();process.exit(0)});s.once('error',()=>process.exit(1));setTimeout(()=>process.exit(1),500);`
45126
- ]);
45127
- return r.status === 0;
45128
- },
45129
45835
  getHealth: realGetHealth
45130
45836
  };
45131
45837
  const rows = readFleetStatus(deps);
@@ -45135,26 +45841,26 @@ function runStatus() {
45135
45841
  }
45136
45842
  function realCopyConnector(srcDir, dstDir, harness) {
45137
45843
  const allowedExt = CONNECTOR_FILE_EXTENSIONS[harness];
45138
- mkdirSync7(dstDir, { recursive: true });
45844
+ mkdirSync8(dstDir, { recursive: true });
45139
45845
  for (const name of readdirSync4(srcDir)) {
45140
45846
  const srcPath = join25(srcDir, name);
45141
- if (statSync7(srcPath).isDirectory()) {
45847
+ if (statSync8(srcPath).isDirectory()) {
45142
45848
  if (CONNECTOR_SKIP_DIRS.has(name)) continue;
45143
45849
  realCopyConnector(srcPath, join25(dstDir, name), harness);
45144
45850
  continue;
45145
45851
  }
45146
45852
  if (name.includes(".test.")) continue;
45147
45853
  if (!allowedExt.some((ext) => name.endsWith(ext))) continue;
45148
- writeFileSync5(join25(dstDir, name), readFileSync15(srcPath));
45854
+ writeFileSync5(join25(dstDir, name), readFileSync16(srcPath));
45149
45855
  }
45150
45856
  }
45151
45857
  function realWriteBinding(dstDir, identityId) {
45152
- mkdirSync7(dstDir, { recursive: true });
45858
+ mkdirSync8(dstDir, { recursive: true });
45153
45859
  writeFileSync5(join25(dstDir, "securecoms.binding.json"), JSON.stringify({ identity_id: identityId }) + "\n", { mode: 384 });
45154
45860
  }
45155
45861
  function realReadBinding(dstDir) {
45156
45862
  try {
45157
- const parsed = JSON.parse(readFileSync15(join25(dstDir, "securecoms.binding.json"), "utf8"));
45863
+ const parsed = JSON.parse(readFileSync16(join25(dstDir, "securecoms.binding.json"), "utf8"));
45158
45864
  return parsed.identity_id ?? null;
45159
45865
  } catch {
45160
45866
  return null;
@@ -45165,14 +45871,14 @@ function realRemoveBinding(dstDir) {
45165
45871
  }
45166
45872
  function realStatDir(p) {
45167
45873
  try {
45168
- return statSync7(p).isDirectory();
45874
+ return statSync8(p).isDirectory();
45169
45875
  } catch {
45170
45876
  return false;
45171
45877
  }
45172
45878
  }
45173
45879
  function realReadHermesConfig(p) {
45174
45880
  try {
45175
- return readFileSync15(p, "utf8");
45881
+ return readFileSync16(p, "utf8");
45176
45882
  } catch {
45177
45883
  return null;
45178
45884
  }
@@ -45213,9 +45919,17 @@ function runWireVerb(verb) {
45213
45919
  process.exit(verb === "wire" ? runWire(realWireDeps()) : runUnwire(realWireDeps()));
45214
45920
  }
45215
45921
  async function runServiceVerb(verb) {
45216
- 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
+ }
45217
45929
  const label = argOrEnv("--label", "SECURECOMS_LABEL");
45218
45930
  if (verb === "install" ? !credentialPath : !credentialPath && !label) {
45931
+ if (verb === "install") process.stderr.write(`${noCredentialLine("install")}
45932
+ `);
45219
45933
  const execPathUsage = verb === "install" ? " [--exec-path <path>]" : "";
45220
45934
  process.stderr.write(`usage: agent-bridge ${verb} --credential <path> [--label <name>] [--store <path>] [--port N] [--harness openclaw|hermes|universal]${execPathUsage}
45221
45935
  `);
@@ -45293,12 +46007,15 @@ async function runServiceVerb(verb) {
45293
46007
  }
45294
46008
  process.exit(0);
45295
46009
  } catch (e) {
45296
- const raw = e instanceof ServiceUnsupportedError || e instanceof GlobalInstallPermissionError;
45297
- process.stderr.write(`${raw ? e.message : `service ${verb} failed: ${e.message ?? e}`}
45298
- `);
46010
+ process.stderr.write(formatServiceError(verb, e));
45299
46011
  process.exit(1);
45300
46012
  }
45301
46013
  }
46014
+ function formatServiceError(verb, e) {
46015
+ const raw = e instanceof ServiceUnsupportedError || e instanceof GlobalInstallPermissionError || e instanceof InstallRefusedError;
46016
+ return `${raw ? e.message : `service ${verb} failed: ${e.message ?? e}`}
46017
+ `;
46018
+ }
45302
46019
  async function startThenServe(agent, opts) {
45303
46020
  await agent.start();
45304
46021
  const tls = opts.readTls();
@@ -45330,55 +46047,237 @@ async function runModelsVerb() {
45330
46047
  function runPinVerb() {
45331
46048
  process.exit(runPinCli(process.argv, { out: (l) => process.stdout.write(l + "\n"), err: (l) => process.stderr.write(l + "\n") }));
45332
46049
  }
45333
- async function main() {
45334
- if (!nodeMajorOk(process.versions.node)) {
45335
- writeServiceLog("error", "startup.node_unsupported");
45336
- process.exit(1);
45337
- }
45338
- const verb = parseVerb(process.argv);
45339
- if (verb === "pin") {
45340
- runPinVerb();
45341
- return;
45342
- }
45343
- if (verb === "status") {
45344
- runStatus();
45345
- return;
45346
- }
45347
- if (verb === "wire" || verb === "unwire") {
45348
- runWireVerb(verb);
45349
- return;
45350
- }
45351
- if (verb === "models") {
45352
- await runModelsVerb();
45353
- return;
45354
- }
45355
- if (verb) {
45356
- await runServiceVerb(verb);
45357
- return;
45358
- }
45359
- const credentialPath = argOrEnv("--credential", "SECURECOMS_CREDENTIAL");
45360
- if (!credentialPath) {
45361
- writeServiceLog("error", "startup.credential_required.use_--credential");
45362
- process.exit(2);
46050
+ var CANONICAL_UUID2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
46051
+ var HELD_POLL_TOTAL_MS = 1e4;
46052
+ var HELD_POLL_INTERVAL_MS = 500;
46053
+ var HELD_NO_PID_REACQUIRES = 3;
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;
45363
46073
  }
45364
- const storePath = argOrEnv("--store", "SECURECOMS_STORE");
46074
+ }
46075
+ function realRunModeDeps(credentialPath, storePath) {
46076
+ return {
46077
+ credentialPath,
46078
+ storePath,
46079
+ argv: process.argv,
46080
+ env: process.env,
46081
+ home: homedir5(),
46082
+ newLock: (path) => new InstanceLock(path, void 0, writeServiceLog),
46083
+ // 7b Task 19 fix M2: the production construction owns the machine paths (heartbeat, model marker and GC).
46084
+ newAgent: (cfg) => new BridgeAgent(cfg, { hostMachine: true }),
46085
+ net: { createServer: netCreateServer },
46086
+ serve: startThenServe,
46087
+ now: () => Date.now(),
46088
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
46089
+ setHardExitTimer: (cb, ms) => {
46090
+ const t = setTimeout(cb, ms);
46091
+ t.unref?.();
46092
+ return () => clearTimeout(t);
46093
+ },
46094
+ onSignal: (signal, handler) => {
46095
+ process.on(signal, handler);
46096
+ },
46097
+ onExit: (handler) => {
46098
+ process.once("exit", handler);
46099
+ },
46100
+ exit: (code) => process.exit(code),
46101
+ print: (line) => {
46102
+ process.stderr.write(`${line}
46103
+ `);
46104
+ },
46105
+ isTTY: process.stderr.isTTY === true,
46106
+ log: writeServiceLog
46107
+ };
46108
+ }
46109
+ async function runMode(deps) {
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
+ };
45365
46138
  let cfg, daemon;
45366
46139
  try {
45367
46140
  cfg = loadConfig({ credentialPath, storePath });
45368
- daemon = parseDaemonArgs(process.argv, process.env);
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 {
45369
46148
  validateBind(daemon.bind, daemon.tls);
45370
46149
  } catch (e) {
45371
- writeServiceLog("error", "startup.config_invalid", {}, e);
45372
- process.exit(1);
46150
+ refuse2("bind_refused", {}, e);
46151
+ return deps.exit(1);
46152
+ }
46153
+ if (!CANONICAL_UUID2.test(cfg.identityId)) {
46154
+ refuse2("identity_id_invalid", { field: "identity_id" });
46155
+ return deps.exit(1);
45373
46156
  }
45374
- const tokenFile = daemon.tokenFile ?? join25(dirname12(credentialPath), "bridge-token");
46157
+ const lockDir = join25(deps.home, ".securecoms", "locks");
46158
+ const lockPath = join25(lockDir, `${cfg.identityId.toLowerCase()}.lock`);
46159
+ try {
46160
+ mkdirSync8(lockDir, { recursive: true, mode: 448 });
46161
+ chmodSync6(lockDir, 448);
46162
+ } catch (e) {
46163
+ log("error", "startup.instance_lock_failed", {}, e);
46164
+ return deps.exit(1);
46165
+ }
46166
+ const lock = deps.newLock(lockPath);
46167
+ let got = lock.acquire();
46168
+ const pollStart = deps.now();
46169
+ while (got.result === "held" && deps.now() - pollStart < HELD_POLL_TOTAL_MS) {
46170
+ await deps.sleep(HELD_POLL_INTERVAL_MS);
46171
+ got = lock.acquire();
46172
+ }
46173
+ for (let i = 0; i < HELD_NO_PID_REACQUIRES && got.result === "held" && got.holderPid === void 0; i++) {
46174
+ await deps.sleep(HELD_POLL_INTERVAL_MS);
46175
+ got = lock.acquire();
46176
+ }
46177
+ switch (got.result) {
46178
+ case "acquired":
46179
+ break;
46180
+ case "held":
46181
+ if (got.holderPid === void 0) {
46182
+ log("error", "startup.instance_lock_busy");
46183
+ return deps.exit(1);
46184
+ }
46185
+ log("info", "startup.already_running");
46186
+ deps.print(`This agent is already running on this computer (process ${got.holderPid}). Stop that copy first, or run: agent-bridge status`);
46187
+ return deps.exit(0);
46188
+ case "foreign_host":
46189
+ log("error", "startup.instance_lock_foreign_host", {
46190
+ lock_path: ServiceLogEnum.of("home_securecoms_locks_identity", ["home_securecoms_locks_identity"]),
46191
+ remedy: ServiceLogEnum.of("delete_lock_if_no_other_host", ["delete_lock_if_no_other_host"])
46192
+ });
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
+ );
46196
+ return deps.exit(1);
46197
+ case "unreadable":
46198
+ log("error", "startup.instance_lock_unreadable", {}, lock.lastError);
46199
+ return deps.exit(1);
46200
+ case "busy":
46201
+ log("error", "startup.instance_lock_busy");
46202
+ return deps.exit(1);
46203
+ case "failed":
46204
+ log("error", "startup.instance_lock_failed", {}, lock.lastError);
46205
+ return deps.exit(1);
46206
+ }
46207
+ deps.onExit(() => lock.release());
46208
+ const tokenFile = daemon.tokenFile ?? join25(dirname13(credentialPath), "bridge-token");
45375
46209
  const token = resolveToken({ tokenFile, token: daemon.token });
45376
- const agent = new BridgeAgent(cfg, { hostMachine: true });
45377
- const { server, closeAllSseStreams, agentWs } = await startThenServe(agent, {
45378
- token,
45379
- allowOrigins: daemon.allowOrigins,
45380
- readTls: () => daemon.tls ? { cert: readFileSync15(daemon.tlsCert, "utf8"), key: readFileSync15(daemon.tlsKey, "utf8") } : void 0
46210
+ const preBound = deps.net.createServer((sock) => sock.destroy());
46211
+ const bindError = await new Promise((resolveBind) => {
46212
+ const onError = (e) => resolveBind(e);
46213
+ preBound.once("error", onError);
46214
+ preBound.listen(daemon.port, daemon.bind, () => {
46215
+ preBound.removeListener("error", onError);
46216
+ resolveBind(void 0);
46217
+ });
46218
+ });
46219
+ if (bindError) {
46220
+ if (bindError.code === "EADDRINUSE") {
46221
+ log("error", "startup.port_in_use", { port: daemon.port });
46222
+ deps.print(`Port ${daemon.port} is already in use by another program on this computer, so the agent did not start.`);
46223
+ } else {
46224
+ log("error", "startup.listen_failed", {}, bindError);
46225
+ }
46226
+ lock.release();
46227
+ return deps.exit(1);
46228
+ }
46229
+ const agent = deps.newAgent(cfg);
46230
+ const instanceId = newInstanceId();
46231
+ const identityId = cfg.identityId;
46232
+ let served = null;
46233
+ let superseded = false;
46234
+ const stopIngress = (s) => {
46235
+ s.agentWs.enterSessionSuperseded();
46236
+ s.closeAllSseStreams();
46237
+ };
46238
+ agent.on("session_superseded", () => {
46239
+ if (superseded) return;
46240
+ superseded = true;
46241
+ if (served) stopIngress(served);
46242
+ log("warn", "transport.session_superseded");
46243
+ deps.print("This agent connected from another place, so this copy stopped. Stop the other copy, then restart this one.");
46244
+ void (async () => {
46245
+ try {
46246
+ await agent.close();
46247
+ } catch (e) {
46248
+ log("error", "transport.session_superseded_close_failed", {}, e);
46249
+ }
46250
+ removeConnectionFileIfOwned(deps.home, identityId, instanceId);
46251
+ })();
45381
46252
  });
46253
+ let exiting = false;
46254
+ const closeReleaseExit = () => {
46255
+ if (exiting) return;
46256
+ exiting = true;
46257
+ const cancelHard = deps.setHardExitTimer(() => {
46258
+ lock.release();
46259
+ deps.exit(0);
46260
+ }, SHUTDOWN_HARD_EXIT_MS);
46261
+ void agent.close().finally(() => {
46262
+ cancelHard();
46263
+ lock.release();
46264
+ deps.exit(0);
46265
+ });
46266
+ };
46267
+ try {
46268
+ served = await deps.serve(agent, {
46269
+ token,
46270
+ allowOrigins: daemon.allowOrigins,
46271
+ readTls: () => daemon.tls ? { cert: readFileSync16(daemon.tlsCert, "utf8"), key: readFileSync16(daemon.tlsKey, "utf8") } : void 0
46272
+ });
46273
+ } catch (e) {
46274
+ if (!(e instanceof SessionSupersededError)) throw e;
46275
+ deps.onSignal("SIGINT", closeReleaseExit);
46276
+ deps.onSignal("SIGTERM", closeReleaseExit);
46277
+ return;
46278
+ }
46279
+ if (superseded) stopIngress(served);
46280
+ const { server, closeAllSseStreams, agentWs } = served;
45382
46281
  let down = false;
45383
46282
  const shutdown = () => {
45384
46283
  if (down) return;
@@ -45386,26 +46285,20 @@ async function main() {
45386
46285
  agentWs.close();
45387
46286
  server.close();
45388
46287
  closeAllSseStreams();
45389
- const hard = setTimeout(() => process.exit(0), 8e3);
45390
- hard.unref?.();
45391
- void agent.close().finally(() => {
45392
- clearTimeout(hard);
45393
- process.exit(0);
45394
- });
46288
+ closeReleaseExit();
45395
46289
  };
45396
- process.on("SIGINT", shutdown);
45397
- process.on("SIGTERM", shutdown);
45398
- const instanceId = newInstanceId();
45399
- const identityId = agent.health().identity_id;
46290
+ deps.onSignal("SIGINT", shutdown);
46291
+ deps.onSignal("SIGTERM", shutdown);
45400
46292
  const slug = slugFromCredentialPath(credentialPath);
45401
- process.once("exit", () => removeConnectionFileIfOwned(homedir5(), identityId, instanceId));
45402
- server.listen(daemon.port, daemon.bind, () => {
45403
- writeServiceLog("info", "startup.listening", { port: daemon.port, tls: !!daemon.tls });
45404
- writeServiceLog("info", "startup.token_loaded");
46293
+ deps.onExit(() => removeConnectionFileIfOwned(deps.home, identityId, instanceId));
46294
+ server.listen(preBound, () => {
46295
+ log("info", "startup.listening", { port: daemon.port, tls: !!daemon.tls });
46296
+ log("info", "startup.token_loaded");
45405
46297
  const addr = server.address();
45406
46298
  const boundPort = typeof addr === "object" && addr ? addr.port : daemon.port;
46299
+ if (superseded) return;
45407
46300
  try {
45408
- writeConnectionFile(homedir5(), buildConnectionFile({
46301
+ writeConnectionFile(deps.home, buildConnectionFile({
45409
46302
  identityId,
45410
46303
  port: boundPort,
45411
46304
  tokenFile,
@@ -45414,12 +46307,52 @@ async function main() {
45414
46307
  instanceId,
45415
46308
  now: (/* @__PURE__ */ new Date()).toISOString()
45416
46309
  }));
45417
- writeServiceLog("info", "startup.connection_file_written");
46310
+ log("info", "startup.connection_file_written");
45418
46311
  } catch (e) {
45419
- writeServiceLog("warn", "startup.connection_file_write_failed", {}, e);
46312
+ log("warn", "startup.connection_file_write_failed", {}, e);
45420
46313
  }
45421
46314
  });
45422
46315
  }
46316
+ function printNoCredentialRunLine() {
46317
+ process.stderr.write(`${noCredentialLine("run")}
46318
+ `);
46319
+ }
46320
+ async function main() {
46321
+ if (!nodeMajorOk(process.versions.node)) {
46322
+ writeServiceLog("error", "startup.node_unsupported");
46323
+ process.exit(1);
46324
+ }
46325
+ const verb = parseVerb(process.argv);
46326
+ if (verb === "pin") {
46327
+ runPinVerb();
46328
+ return;
46329
+ }
46330
+ if (verb === "status") {
46331
+ runStatus();
46332
+ return;
46333
+ }
46334
+ if (verb === "wire" || verb === "unwire") {
46335
+ runWireVerb(verb);
46336
+ return;
46337
+ }
46338
+ if (verb === "models") {
46339
+ await runModelsVerb();
46340
+ return;
46341
+ }
46342
+ if (verb) {
46343
+ await runServiceVerb(verb);
46344
+ return;
46345
+ }
46346
+ const cred = readCredentialArg(process.argv, process.env);
46347
+ if (cred.kind !== "path") {
46348
+ printNoCredentialRunLine();
46349
+ writeServiceLog("error", "startup.credential_required.use_--credential");
46350
+ process.exit(2);
46351
+ }
46352
+ const credentialPath = cred.path;
46353
+ const storePath = argOrEnv("--store", "SECURECOMS_STORE");
46354
+ await runMode(realRunModeDeps(credentialPath, storePath));
46355
+ }
45423
46356
  var here = fileURLToPath4(import.meta.url);
45424
46357
  var isMain = !!process.argv[1] && refersToSameFile(here, process.argv[1], realpathSync3);
45425
46358
  if (isMain) {
@@ -45429,10 +46362,18 @@ if (isMain) {
45429
46362
  });
45430
46363
  }
45431
46364
  export {
46365
+ HELD_NO_PID_REACQUIRES,
46366
+ HELD_POLL_INTERVAL_MS,
46367
+ HELD_POLL_TOTAL_MS,
46368
+ SHUTDOWN_HARD_EXIT_MS,
45432
46369
  chooseInstallExecPath,
46370
+ configErrorLine,
46371
+ formatServiceError,
45433
46372
  nodeMajorOk,
45434
46373
  parseVerb,
45435
46374
  realCopyConnector,
46375
+ realGetHealth,
46376
+ runMode,
45436
46377
  slugFromCredentialPath,
45437
46378
  startThenServe,
45438
46379
  tokenFingerprint