@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/README.md +77 -6
- package/dist/bin.d.ts +101 -0
- package/dist/bin.js +1102 -161
- package/dist/bridge-agent.d.ts +60 -0
- package/dist/config.d.ts +13 -0
- package/dist/errors.d.ts +1 -1
- package/dist/index.js +335 -27
- package/dist/preflight-upgrade.d.cts +0 -2
- package/dist/preflight.cjs +51 -49
- package/dist/preflight.d.cts +14 -0
- package/dist/service/credential-arg.d.ts +23 -0
- package/dist/service/install-prep.d.ts +21 -1
- package/dist/service/install.d.ts +7 -0
- package/dist/service/instance-lock.d.ts +88 -0
- package/dist/service/launchd.d.ts +1 -1
- package/dist/service/resolve-node.d.ts +1 -0
- package/dist/service/status.d.ts +3 -5
- package/dist/service/systemd.d.ts +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/ws-server.d.ts +4 -0
- package/package.json +2 -2
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}`));
|
|
@@ -24091,6 +24097,7 @@ function base64UrlNoPad(s) {
|
|
|
24091
24097
|
}
|
|
24092
24098
|
var BEARER_SUBPROTOCOL = "securecoms.bearer.v1";
|
|
24093
24099
|
var WS_CLOSE_IDENTITY_REVOKED = 4006;
|
|
24100
|
+
var WS_CLOSE_SESSION_SUPERSEDED = 4409;
|
|
24094
24101
|
function isIdentityRevokedClose(code, reason) {
|
|
24095
24102
|
return code === WS_CLOSE_IDENTITY_REVOKED || reason === "identity_revoked";
|
|
24096
24103
|
}
|
|
@@ -24306,7 +24313,7 @@ function openDeliveryPings(o) {
|
|
|
24306
24313
|
end();
|
|
24307
24314
|
};
|
|
24308
24315
|
if (!hardened) {
|
|
24309
|
-
if (code === 4001 || isIdentityRevokedClose(code, reason)) {
|
|
24316
|
+
if (code === 4001 || code === WS_CLOSE_SESSION_SUPERSEDED || isIdentityRevokedClose(code, reason)) {
|
|
24310
24317
|
terminalClose();
|
|
24311
24318
|
return;
|
|
24312
24319
|
}
|
|
@@ -24315,7 +24322,7 @@ function openDeliveryPings(o) {
|
|
|
24315
24322
|
}
|
|
24316
24323
|
const tr = terminalReason;
|
|
24317
24324
|
terminalReason = void 0;
|
|
24318
|
-
if (code === 4006 || tr && code === 4001) {
|
|
24325
|
+
if (code === 4006 || code === WS_CLOSE_SESSION_SUPERSEDED || tr && code === 4001) {
|
|
24319
24326
|
lifecycle("terminal", tr ?? `code ${code}`);
|
|
24320
24327
|
terminalClose();
|
|
24321
24328
|
return;
|
|
@@ -24472,10 +24479,14 @@ var RestTransport = class {
|
|
|
24472
24479
|
f;
|
|
24473
24480
|
ws;
|
|
24474
24481
|
tokenProvider;
|
|
24475
|
-
/** The
|
|
24476
|
-
* cleared
|
|
24477
|
-
* a no-op when no subscription is active. */
|
|
24482
|
+
/** The shared delivery-ping socket's send, set while that socket is open and
|
|
24483
|
+
* cleared when it closes (last subscriber gone, or terminal). Control frames
|
|
24484
|
+
* (status keepalive) route through it; a no-op when no subscription is active. */
|
|
24478
24485
|
_controlSend = null;
|
|
24486
|
+
/** P-5: the one delivery-ping stream this transport holds, shared by every subscriber. */
|
|
24487
|
+
pingHub = null;
|
|
24488
|
+
/** Set once the shared stream ends on a terminal close; no socket is ever reopened. */
|
|
24489
|
+
pingsTerminal = false;
|
|
24479
24490
|
constructor(opts) {
|
|
24480
24491
|
this.opts = opts;
|
|
24481
24492
|
this.f = opts.fetchImpl ?? fetch;
|
|
@@ -24577,25 +24588,52 @@ var RestTransport = class {
|
|
|
24577
24588
|
idempotentReplay: j.idempotent_replay === true
|
|
24578
24589
|
};
|
|
24579
24590
|
}
|
|
24591
|
+
/** One device-wide delivery-ping socket per transport (P-5): the backend
|
|
24592
|
+
* supersedes a second socket for the same device, so every subscriber shares
|
|
24593
|
+
* one `openDeliveryPings` handle. Each gets its own bounded queue; its close()
|
|
24594
|
+
* detaches only itself; the socket closes with the last subscriber and a later
|
|
24595
|
+
* subscribe opens a fresh one. After a terminal close the transport is
|
|
24596
|
+
* terminal: later calls open no socket and get an already-ended iterator. */
|
|
24580
24597
|
subscribeDeliveryPings(_channelId) {
|
|
24598
|
+
if (this.pingsTerminal) {
|
|
24599
|
+
const ended = new PingSubscriber();
|
|
24600
|
+
ended.end();
|
|
24601
|
+
return { pings: ended.pings, close: () => {
|
|
24602
|
+
}, send: () => {
|
|
24603
|
+
} };
|
|
24604
|
+
}
|
|
24605
|
+
const hub = this.pingHub ??= this.openPingHub();
|
|
24606
|
+
const sub = hub.attach();
|
|
24607
|
+
return { pings: sub.pings, close: () => hub.detach(sub), send: hub.send };
|
|
24608
|
+
}
|
|
24609
|
+
openPingHub() {
|
|
24581
24610
|
let wsUrl = this.opts.baseUrl.replace(/^http/, "ws").replace(/\/$/, "") + "/api/v1/ws";
|
|
24582
24611
|
if (this.opts.connectIdentityId) {
|
|
24583
24612
|
wsUrl += `?connect_identity_id=${encodeURIComponent(this.opts.connectIdentityId)}`;
|
|
24584
24613
|
}
|
|
24585
|
-
const
|
|
24614
|
+
const hub = new PingHub(openDeliveryPings({
|
|
24586
24615
|
url: wsUrl,
|
|
24587
24616
|
tokenProvider: this.tokenProvider,
|
|
24588
24617
|
wsImpl: this.ws,
|
|
24589
24618
|
hardened: this.opts.hardenedReconnect ?? false,
|
|
24590
|
-
onLifecycle: this.opts.onWsLifecycle
|
|
24591
|
-
|
|
24592
|
-
|
|
24593
|
-
|
|
24594
|
-
|
|
24595
|
-
this.
|
|
24596
|
-
|
|
24597
|
-
|
|
24598
|
-
|
|
24619
|
+
onLifecycle: this.opts.onWsLifecycle,
|
|
24620
|
+
// Any terminal close (4001, 4006, 4409, hardened war-cap) of the shared
|
|
24621
|
+
// socket makes this transport terminal. The shared iterator ends right
|
|
24622
|
+
// after this call, so the pump exit tears down every subscriber.
|
|
24623
|
+
onTerminalClose: (close) => {
|
|
24624
|
+
this.pingsTerminal = true;
|
|
24625
|
+
this.opts.onTerminalClose?.(close);
|
|
24626
|
+
}
|
|
24627
|
+
}), () => this.dropPingHub(hub));
|
|
24628
|
+
this._controlSend = hub.send;
|
|
24629
|
+
return hub;
|
|
24630
|
+
}
|
|
24631
|
+
dropPingHub(hub) {
|
|
24632
|
+
if (this.pingHub === hub) {
|
|
24633
|
+
this.pingHub = null;
|
|
24634
|
+
this._controlSend = null;
|
|
24635
|
+
}
|
|
24636
|
+
hub.shutdown();
|
|
24599
24637
|
}
|
|
24600
24638
|
sendControlFrame(_channelId, frame) {
|
|
24601
24639
|
try {
|
|
@@ -24623,6 +24661,90 @@ var RestTransport = class {
|
|
|
24623
24661
|
}));
|
|
24624
24662
|
}
|
|
24625
24663
|
};
|
|
24664
|
+
var SUBSCRIBER_QUEUE_MAX = 8;
|
|
24665
|
+
var PingSubscriber = class {
|
|
24666
|
+
queue = [];
|
|
24667
|
+
waiter;
|
|
24668
|
+
ended = false;
|
|
24669
|
+
started = false;
|
|
24670
|
+
pings = {
|
|
24671
|
+
[Symbol.asyncIterator]: () => {
|
|
24672
|
+
if (this.started)
|
|
24673
|
+
throw new Error("subscribeDeliveryPings: single-use async iterable");
|
|
24674
|
+
this.started = true;
|
|
24675
|
+
return {
|
|
24676
|
+
next: () => {
|
|
24677
|
+
if (this.ended)
|
|
24678
|
+
return Promise.resolve({ value: void 0, done: true });
|
|
24679
|
+
if (this.queue.length)
|
|
24680
|
+
return Promise.resolve({ value: this.queue.shift(), done: false });
|
|
24681
|
+
return new Promise((resolve) => {
|
|
24682
|
+
this.waiter = resolve;
|
|
24683
|
+
});
|
|
24684
|
+
}
|
|
24685
|
+
};
|
|
24686
|
+
}
|
|
24687
|
+
};
|
|
24688
|
+
push(p) {
|
|
24689
|
+
if (this.ended)
|
|
24690
|
+
return;
|
|
24691
|
+
if (this.waiter) {
|
|
24692
|
+
const w = this.waiter;
|
|
24693
|
+
this.waiter = void 0;
|
|
24694
|
+
w({ value: p, done: false });
|
|
24695
|
+
} else if (this.queue.length < SUBSCRIBER_QUEUE_MAX) {
|
|
24696
|
+
this.queue.push(p);
|
|
24697
|
+
}
|
|
24698
|
+
}
|
|
24699
|
+
end() {
|
|
24700
|
+
this.ended = true;
|
|
24701
|
+
this.queue.length = 0;
|
|
24702
|
+
if (this.waiter) {
|
|
24703
|
+
const w = this.waiter;
|
|
24704
|
+
this.waiter = void 0;
|
|
24705
|
+
w({ value: void 0, done: true });
|
|
24706
|
+
}
|
|
24707
|
+
}
|
|
24708
|
+
};
|
|
24709
|
+
var PingHub = class {
|
|
24710
|
+
handle;
|
|
24711
|
+
onGone;
|
|
24712
|
+
subs = /* @__PURE__ */ new Set();
|
|
24713
|
+
send;
|
|
24714
|
+
constructor(handle, onGone) {
|
|
24715
|
+
this.handle = handle;
|
|
24716
|
+
this.onGone = onGone;
|
|
24717
|
+
this.send = handle.send;
|
|
24718
|
+
void this.pump();
|
|
24719
|
+
}
|
|
24720
|
+
/** `openDeliveryPings` iterables never reject and push() never throws, so
|
|
24721
|
+
* this loop only ends when the shared stream ends (our close, or terminal). */
|
|
24722
|
+
async pump() {
|
|
24723
|
+
for await (const p of this.handle.pings)
|
|
24724
|
+
for (const s of this.subs)
|
|
24725
|
+
s.push(p);
|
|
24726
|
+
this.onGone();
|
|
24727
|
+
}
|
|
24728
|
+
attach() {
|
|
24729
|
+
const s = new PingSubscriber();
|
|
24730
|
+
this.subs.add(s);
|
|
24731
|
+
return s;
|
|
24732
|
+
}
|
|
24733
|
+
/** Idempotent: detaches and ends only `s`; the last one out closes the socket. */
|
|
24734
|
+
detach(s) {
|
|
24735
|
+
this.subs.delete(s);
|
|
24736
|
+
s.end();
|
|
24737
|
+
if (this.subs.size === 0)
|
|
24738
|
+
this.onGone();
|
|
24739
|
+
}
|
|
24740
|
+
/** Idempotent: `openDeliveryPings().close()` is, and the set is empty after. */
|
|
24741
|
+
shutdown() {
|
|
24742
|
+
for (const s of this.subs)
|
|
24743
|
+
s.end();
|
|
24744
|
+
this.subs.clear();
|
|
24745
|
+
this.handle.close();
|
|
24746
|
+
}
|
|
24747
|
+
};
|
|
24626
24748
|
|
|
24627
24749
|
// ../connect-sdk-ts/dist/mls/rest-backend.js
|
|
24628
24750
|
function describeType(v) {
|
|
@@ -34952,6 +35074,7 @@ var ROOM_DEFAULT_READ_GRANTS = Object.freeze([
|
|
|
34952
35074
|
Object.freeze({ tool: "current_time", mode: "read" })
|
|
34953
35075
|
]);
|
|
34954
35076
|
var NO_TOOL_GRANTS = Object.freeze([]);
|
|
35077
|
+
var WS_CLOSE_SESSION_SUPERSEDED2 = 4409;
|
|
34955
35078
|
var OWNER_REPLAY_PRUNE_INTERVAL_MS = 5 * 60 * 1e3;
|
|
34956
35079
|
var OWNER_TURN_UNCHECKED = Object.freeze({ kind: "unchecked" });
|
|
34957
35080
|
function ownerDmDefaultGrantsEnabled(env = process.env) {
|
|
@@ -35226,6 +35349,12 @@ function rosterFreshAt(entry, heldEpoch, heldGroupId) {
|
|
|
35226
35349
|
var RoomGrantEpochMovedError = class extends Error {
|
|
35227
35350
|
};
|
|
35228
35351
|
var ROOM_GRANT_TRACK_MAX = 4096;
|
|
35352
|
+
var SessionSupersededError = class extends Error {
|
|
35353
|
+
constructor() {
|
|
35354
|
+
super("session_superseded: another copy of this agent took its session during start()");
|
|
35355
|
+
this.name = "SessionSupersededError";
|
|
35356
|
+
}
|
|
35357
|
+
};
|
|
35229
35358
|
var GroupSupersededSendError = class extends Error {
|
|
35230
35359
|
constructor(channelId, activeGroupId) {
|
|
35231
35360
|
super(`send refused for channel ${channelId}: the channel moved to a new secure group; not sent`);
|
|
@@ -35265,9 +35394,18 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
35265
35394
|
const seed = hexToBytes4(cfg.signingPrivateKey);
|
|
35266
35395
|
this.custody = SoftwareKeyCustody.fromPrivateKey("ed25519", seed);
|
|
35267
35396
|
this.replyIdKey = deriveReplyIdKey(seed);
|
|
35397
|
+
const base = deps.fetchImpl ?? fetch;
|
|
35398
|
+
this.fetchImpl = (input, init) => this.sessionSuperseded ? Promise.reject(new Error("session_superseded")) : base(input, init);
|
|
35268
35399
|
this.transport = deps.transport ?? new RestTransport({
|
|
35269
35400
|
baseUrl: cfg.apiBase,
|
|
35270
35401
|
bearerToken: cfg.apiKey,
|
|
35402
|
+
fetchImpl: this.fetchImpl,
|
|
35403
|
+
wsImpl: deps.wsImpl,
|
|
35404
|
+
// A 4409 close means another copy of this agent took the session.
|
|
35405
|
+
// Every other terminal close keeps today's behaviour.
|
|
35406
|
+
onTerminalClose: (close) => {
|
|
35407
|
+
if (close.code === WS_CLOSE_SESSION_SUPERSEDED2) this.onSessionSuperseded();
|
|
35408
|
+
},
|
|
35271
35409
|
connectIdentityId: cfg.identityId,
|
|
35272
35410
|
// P1b: hardened delivery-ping reconnect (verify-before-terminal +
|
|
35273
35411
|
// half-open + wake + auth war-cap). On by default for the daemon;
|
|
@@ -35281,7 +35419,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
35281
35419
|
else writeServiceLog2("warn", "transport.delivery_ping_lifecycle_unknown");
|
|
35282
35420
|
}
|
|
35283
35421
|
});
|
|
35284
|
-
this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
|
|
35422
|
+
this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey, fetchImpl: this.fetchImpl });
|
|
35285
35423
|
this.store = new SqliteMlsStateStore({
|
|
35286
35424
|
dbPath: cfg.storePath,
|
|
35287
35425
|
identityId: cfg.identityId,
|
|
@@ -35352,7 +35490,6 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
35352
35490
|
// throws (mirrors the divergence-heal signal path).
|
|
35353
35491
|
onSupersedeUnopenable: (channelId, groupId) => this.healSupersedeUnopenable(channelId, groupId, this.drainExemptionAtCallIn())
|
|
35354
35492
|
});
|
|
35355
|
-
this.fetchImpl = deps.fetchImpl ?? fetch;
|
|
35356
35493
|
this.home = deps.home ?? homedir4();
|
|
35357
35494
|
this.grantWriters = {
|
|
35358
35495
|
writeManifestPolicy: deps.grantWriters?.writeManifestPolicy ?? writeManifestPolicy,
|
|
@@ -35484,6 +35621,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
35484
35621
|
// stored for peek-then-join in ingestOnce
|
|
35485
35622
|
custody;
|
|
35486
35623
|
fetchImpl;
|
|
35624
|
+
/** P-1: set once a 4409 close says another copy took this agent's session.
|
|
35625
|
+
* The gated fetch built in the constructor refuses every request after it. */
|
|
35626
|
+
sessionSuperseded = false;
|
|
35627
|
+
/** C9: who hears `session_superseded` (runMode's quiesce). */
|
|
35628
|
+
sessionSupersededListeners = /* @__PURE__ */ new Set();
|
|
35629
|
+
/** C9 (spec §4.1.6, low 3): the one close() run every caller shares. */
|
|
35630
|
+
closing = null;
|
|
35487
35631
|
/** Round 5: home for the managed runtime-config directory (test seam). */
|
|
35488
35632
|
home;
|
|
35489
35633
|
grantWriters;
|
|
@@ -35959,6 +36103,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
35959
36103
|
action;
|
|
35960
36104
|
waiters = [];
|
|
35961
36105
|
pingHandle;
|
|
36106
|
+
/** C10 (P-15): the delivery-ping subscription start() opens at its top,
|
|
36107
|
+
* held unconsumed until startReceivePump() takes it. */
|
|
36108
|
+
earlyPings = null;
|
|
36109
|
+
/** C10 fix round 1: settles (never rejects) when start()'s body does; close()
|
|
36110
|
+
* awaits it, bounded, so the store never closes under a start() step. */
|
|
36111
|
+
startInFlight = null;
|
|
35962
36112
|
safetyTimer;
|
|
35963
36113
|
/** The ingest cycle now running, if any (see ingestOnce: single-flight). */
|
|
35964
36114
|
ingestInFlight = null;
|
|
@@ -36536,7 +36686,50 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
36536
36686
|
this.droppedMessages += overflow;
|
|
36537
36687
|
}
|
|
36538
36688
|
}
|
|
36689
|
+
/**
|
|
36690
|
+
* C10 (spec P-15, OQ-8 (b)): subscribe early, consume late. The
|
|
36691
|
+
* delivery-ping socket opens here, before anything else, so this copy claims
|
|
36692
|
+
* the device's session (and another copy gets its 4409) BEFORE this copy's
|
|
36693
|
+
* boot drain runs. The pings it carries are NOT consumed until
|
|
36694
|
+
* startReceivePump(), the same place as before: starting the pump first is
|
|
36695
|
+
* still forbidden (DRAIN BEFORE ROTATE, startBody).
|
|
36696
|
+
*
|
|
36697
|
+
* A 4409 during start() (audit H-3): the gated fetch is already set (C9 step
|
|
36698
|
+
* 0). start() then arms nothing more (supersededDuringStart before each
|
|
36699
|
+
* timer and before the pump), runs the memoized close() quiesce, and rejects
|
|
36700
|
+
* with SessionSupersededError. A step that FAILED because of the supersede
|
|
36701
|
+
* (a refused fetch, a store the quiesce closed) is the same outcome.
|
|
36702
|
+
*/
|
|
36539
36703
|
async start() {
|
|
36704
|
+
const early = this.transport.subscribeDeliveryPings("");
|
|
36705
|
+
this.earlyPings = early;
|
|
36706
|
+
this.pingHandle = { close: early.close };
|
|
36707
|
+
const body = this.startBody();
|
|
36708
|
+
this.startInFlight = body.then(() => {
|
|
36709
|
+
}, () => {
|
|
36710
|
+
});
|
|
36711
|
+
try {
|
|
36712
|
+
await body;
|
|
36713
|
+
} catch (e) {
|
|
36714
|
+
if (!this.sessionSuperseded) {
|
|
36715
|
+
if (this.earlyPings) {
|
|
36716
|
+
this.earlyPings.close();
|
|
36717
|
+
this.earlyPings = null;
|
|
36718
|
+
this.pingHandle = void 0;
|
|
36719
|
+
}
|
|
36720
|
+
throw e;
|
|
36721
|
+
}
|
|
36722
|
+
if (!(e instanceof SessionSupersededError)) writeServiceLog2("warn", "startup.superseded_start_error", {}, e);
|
|
36723
|
+
await this.close().catch(() => {
|
|
36724
|
+
});
|
|
36725
|
+
throw new SessionSupersededError();
|
|
36726
|
+
}
|
|
36727
|
+
}
|
|
36728
|
+
/** C10: true once a 4409 landed; checked before each arming step of start(). */
|
|
36729
|
+
supersededDuringStart() {
|
|
36730
|
+
return this.sessionSuperseded;
|
|
36731
|
+
}
|
|
36732
|
+
async startBody() {
|
|
36540
36733
|
this.startedAt = Date.now();
|
|
36541
36734
|
await this.restoreRuntimeInbox();
|
|
36542
36735
|
await this.loadToolGrantState();
|
|
@@ -36596,10 +36789,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
36596
36789
|
await this.warmRestoredNotes();
|
|
36597
36790
|
await this.reindexRestoredTurns();
|
|
36598
36791
|
if (this.unifiedOn) await this.reindexUnifiedRestored();
|
|
36792
|
+
if (this.supersededDuringStart()) throw new SessionSupersededError();
|
|
36599
36793
|
this.runtimeConfigTimer = setInterval(() => {
|
|
36600
36794
|
void this.refetchRuntimeConfig();
|
|
36601
36795
|
}, 6 * 60 * 60 * 1e3);
|
|
36602
36796
|
this.runtimeConfigTimer.unref?.();
|
|
36797
|
+
if (this.supersededDuringStart()) throw new SessionSupersededError();
|
|
36603
36798
|
this.membershipSweepTimer = setInterval(() => {
|
|
36604
36799
|
if (this.closingDown || this.membershipSweepWork) return;
|
|
36605
36800
|
this.membershipSweepWork = this.runMembershipSweeps().finally(() => {
|
|
@@ -36607,6 +36802,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
36607
36802
|
});
|
|
36608
36803
|
}, PURGE_SWEEP_INTERVAL_MS);
|
|
36609
36804
|
this.membershipSweepTimer.unref?.();
|
|
36805
|
+
if (this.supersededDuringStart()) throw new SessionSupersededError();
|
|
36610
36806
|
this.recallPruneTimer = setInterval(() => {
|
|
36611
36807
|
if (this.closingDown || this.recallPruneWork) return;
|
|
36612
36808
|
this.recallPruneWork = this.runRecallPruneTick().finally(() => {
|
|
@@ -36614,6 +36810,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
36614
36810
|
});
|
|
36615
36811
|
}, 60 * 60 * 1e3);
|
|
36616
36812
|
this.recallPruneTimer.unref?.();
|
|
36813
|
+
if (this.supersededDuringStart()) throw new SessionSupersededError();
|
|
36617
36814
|
if (this.machinePaths) this.recallMaintenanceTimer = setInterval(() => {
|
|
36618
36815
|
if (this.closingDown) return;
|
|
36619
36816
|
const n = ++this.maintenanceTickN;
|
|
@@ -36624,6 +36821,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
36624
36821
|
});
|
|
36625
36822
|
}, 1e3);
|
|
36626
36823
|
this.recallMaintenanceTimer?.unref?.();
|
|
36824
|
+
if (this.supersededDuringStart()) throw new SessionSupersededError();
|
|
36627
36825
|
this.startReceivePump();
|
|
36628
36826
|
if (drainedInTime) {
|
|
36629
36827
|
this.armSelfHealSweeps();
|
|
@@ -40887,7 +41085,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40887
41085
|
}
|
|
40888
41086
|
}
|
|
40889
41087
|
startReceivePump() {
|
|
40890
|
-
const sub = this.transport.subscribeDeliveryPings("");
|
|
41088
|
+
const sub = this.earlyPings ?? this.transport.subscribeDeliveryPings("");
|
|
41089
|
+
this.earlyPings = null;
|
|
40891
41090
|
this.pingHandle = { close: sub.close };
|
|
40892
41091
|
void (async () => {
|
|
40893
41092
|
try {
|
|
@@ -42472,7 +42671,42 @@ ${channelId}`;
|
|
|
42472
42671
|
build: BRIDGE_VERSION
|
|
42473
42672
|
};
|
|
42474
42673
|
}
|
|
42475
|
-
|
|
42674
|
+
/** C9: `session_superseded` once a 4409 said another copy took this
|
|
42675
|
+
* agent's session (the gated fetch already refuses every request). The
|
|
42676
|
+
* local HTTP routes read it: every route but `/healthz` answers 503. */
|
|
42677
|
+
get sessionState() {
|
|
42678
|
+
return this.sessionSuperseded ? "session_superseded" : "live";
|
|
42679
|
+
}
|
|
42680
|
+
/** C9: subscribe to `session_superseded`. runMode subscribes right after
|
|
42681
|
+
* construction, before start(), so a 4409 during start() is heard too. */
|
|
42682
|
+
on(event, listener) {
|
|
42683
|
+
this.sessionSupersededListeners.add(listener);
|
|
42684
|
+
return () => this.sessionSupersededListeners.delete(listener);
|
|
42685
|
+
}
|
|
42686
|
+
/** P-1 step 0, then the event (spec §4.1.6): another copy of this agent took
|
|
42687
|
+
* the session (4409). The gated-fetch switch is set FIRST, so no HTTP
|
|
42688
|
+
* request leaves the process after this, whatever a listener does. */
|
|
42689
|
+
onSessionSuperseded() {
|
|
42690
|
+
this.sessionSuperseded = true;
|
|
42691
|
+
for (const listener of [...this.sessionSupersededListeners]) {
|
|
42692
|
+
try {
|
|
42693
|
+
listener();
|
|
42694
|
+
} catch (e) {
|
|
42695
|
+
writeServiceLog2("error", "transport.session_superseded_listener_failed", {}, e);
|
|
42696
|
+
}
|
|
42697
|
+
}
|
|
42698
|
+
}
|
|
42699
|
+
/**
|
|
42700
|
+
* Idempotent (C9, spec §4.1.6 low 3): every call returns the FIRST call's
|
|
42701
|
+
* promise, so a SIGTERM during or after the supersede quiesce never runs
|
|
42702
|
+
* store.close() twice. A rejected close stays rejected: a retry would close
|
|
42703
|
+
* the store a second time.
|
|
42704
|
+
*/
|
|
42705
|
+
close() {
|
|
42706
|
+
this.closing ??= this.closeOnce();
|
|
42707
|
+
return this.closing;
|
|
42708
|
+
}
|
|
42709
|
+
async closeOnce() {
|
|
42476
42710
|
this.closingDown = true;
|
|
42477
42711
|
this.statusGate.close();
|
|
42478
42712
|
for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
|
|
@@ -42485,6 +42719,28 @@ ${channelId}`;
|
|
|
42485
42719
|
if (this.safetyTimer) clearInterval(this.safetyTimer);
|
|
42486
42720
|
if (this.runtimeConfigTimer) clearInterval(this.runtimeConfigTimer);
|
|
42487
42721
|
if (this.membershipSweepTimer) clearInterval(this.membershipSweepTimer);
|
|
42722
|
+
const ingest = this.ingestInFlight;
|
|
42723
|
+
if (ingest) {
|
|
42724
|
+
let timer;
|
|
42725
|
+
await Promise.race([
|
|
42726
|
+
ingest.released,
|
|
42727
|
+
new Promise((resolve) => {
|
|
42728
|
+
timer = setTimeout(resolve, _BridgeAgent.GRACEFUL_DRAIN_MS);
|
|
42729
|
+
})
|
|
42730
|
+
]);
|
|
42731
|
+
if (timer) clearTimeout(timer);
|
|
42732
|
+
}
|
|
42733
|
+
const starting = this.startInFlight;
|
|
42734
|
+
if (starting) {
|
|
42735
|
+
let timer;
|
|
42736
|
+
await Promise.race([
|
|
42737
|
+
starting,
|
|
42738
|
+
new Promise((resolve) => {
|
|
42739
|
+
timer = setTimeout(resolve, _BridgeAgent.GRACEFUL_DRAIN_MS);
|
|
42740
|
+
})
|
|
42741
|
+
]);
|
|
42742
|
+
if (timer) clearTimeout(timer);
|
|
42743
|
+
}
|
|
42488
42744
|
if (this.membershipSweepWork) {
|
|
42489
42745
|
let timer;
|
|
42490
42746
|
await Promise.race([
|
|
@@ -42571,8 +42827,30 @@ ${channelId}`;
|
|
|
42571
42827
|
import { readFileSync as readFileSync10, statSync as statSync5 } from "node:fs";
|
|
42572
42828
|
import { dirname as dirname6, join as join18 } from "node:path";
|
|
42573
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
|
+
};
|
|
42574
42846
|
function loadConfig(opts) {
|
|
42575
|
-
|
|
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
|
+
}
|
|
42576
42854
|
try {
|
|
42577
42855
|
const mode = statSync5(opts.credentialPath).mode & 511;
|
|
42578
42856
|
if (mode & 63) writeServiceLog2("warn", "config.credential_permissions_unsafe");
|
|
@@ -42582,9 +42860,9 @@ function loadConfig(opts) {
|
|
|
42582
42860
|
try {
|
|
42583
42861
|
json2 = JSON.parse(raw);
|
|
42584
42862
|
} catch {
|
|
42585
|
-
throw new
|
|
42863
|
+
throw new ConfigError("credential_not_json");
|
|
42586
42864
|
}
|
|
42587
|
-
for (const k of REQUIRED) if (typeof json2[k] !== "string" || !json2[k]) throw new
|
|
42865
|
+
for (const k of REQUIRED) if (typeof json2[k] !== "string" || !json2[k]) throw new ConfigError("credential_missing_field", k);
|
|
42588
42866
|
const storePath = opts.storePath ?? join18(dirname6(opts.credentialPath), "bridge-state.db");
|
|
42589
42867
|
const ownerGrantPin = loadOwnerGrantPin({ credentialPath: opts.credentialPath, credentialJson: json2 });
|
|
42590
42868
|
writeServiceLog2(ownerGrantPin.state === "ok" || ownerGrantPin.state === "no_pin" ? "info" : "warn", "authz.pin", {
|
|
@@ -42622,8 +42900,14 @@ function loadConfig(opts) {
|
|
|
42622
42900
|
let voice;
|
|
42623
42901
|
try {
|
|
42624
42902
|
voice = parseVoiceConfig(json2.voice, process.env);
|
|
42625
|
-
} catch
|
|
42626
|
-
|
|
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);
|
|
42627
42911
|
}
|
|
42628
42912
|
const modelSource = json2.memory?.model_source === "import_only" ? "import_only" : "download";
|
|
42629
42913
|
const enabledRaw = json2.memory?.enabled;
|
|
@@ -42761,6 +43045,9 @@ var BRIDGE_ERRORS = {
|
|
|
42761
43045
|
// longer holds the shared bytes or key. Terminal for that link: never fall
|
|
42762
43046
|
// back to the current version. `detail.pin` names the refused pin.
|
|
42763
43047
|
vault_ref_changed: { http: 422, message: "this file changed in Files after it was shared; not opened" },
|
|
43048
|
+
// C9 (spec §4.1.6 step 1): another copy of this agent took its SecureComs
|
|
43049
|
+
// session (WS 4409), so this copy stopped. Every local route but /healthz.
|
|
43050
|
+
session_superseded: { http: 503, message: "another copy of this agent took its session; this copy stopped" },
|
|
42764
43051
|
internal: { http: 500, message: "internal error" }
|
|
42765
43052
|
};
|
|
42766
43053
|
function errorBody(code, detail) {
|
|
@@ -42991,6 +43278,10 @@ function createHttpServer(agent, opts) {
|
|
|
42991
43278
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
42992
43279
|
const path = url.pathname;
|
|
42993
43280
|
const method = req.method ?? "GET";
|
|
43281
|
+
if (agent.sessionState === "session_superseded") {
|
|
43282
|
+
if (method === "GET" && path === "/healthz") return json(res, 200, { state: "session_superseded" });
|
|
43283
|
+
return jsonError(res, "session_superseded");
|
|
43284
|
+
}
|
|
42994
43285
|
if (method === "GET" && path === "/healthz") {
|
|
42995
43286
|
return json(res, 200, { ...agent.health(), capabilities: capabilities(opts) });
|
|
42996
43287
|
}
|
|
@@ -43202,6 +43493,7 @@ var PING_INTERVAL_MS = 3e4;
|
|
|
43202
43493
|
var INFLIGHT_STALE_SWEEP_MS = 6e4;
|
|
43203
43494
|
var INFLIGHT_STALE_AFTER_MS = 15 * 6e4;
|
|
43204
43495
|
var HELLO_WAIT_MS = 2e3;
|
|
43496
|
+
var WS_CLOSE_SESSION_SUPERSEDED3 = 4409;
|
|
43205
43497
|
var MEMORY_NOTES_FEATURE = "memory_notes";
|
|
43206
43498
|
var MEMORY_RECALL_FEATURE = "memory_recall";
|
|
43207
43499
|
var MEMORY_UNIFIED_FEATURE = "memory_unified";
|
|
@@ -43222,7 +43514,12 @@ function authedUpgrade(req, opts) {
|
|
|
43222
43514
|
return true;
|
|
43223
43515
|
}
|
|
43224
43516
|
function attachAgentWebSocket(server, agent, opts) {
|
|
43225
|
-
|
|
43517
|
+
let sessionSuperseded = false;
|
|
43518
|
+
const wss = new WebSocketServer({
|
|
43519
|
+
server,
|
|
43520
|
+
path: "/v1/ws",
|
|
43521
|
+
verifyClient: (_info, done) => sessionSuperseded ? done(false, 503, "session_superseded") : done(true)
|
|
43522
|
+
});
|
|
43226
43523
|
const keepalive = setInterval(() => {
|
|
43227
43524
|
for (const client of wss.clients) {
|
|
43228
43525
|
const sock = client;
|
|
@@ -43362,6 +43659,7 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
43362
43659
|
if (helloTimer) clearTimeout(helloTimer);
|
|
43363
43660
|
agent.noteSessionClose();
|
|
43364
43661
|
unsubscribe();
|
|
43662
|
+
if (sessionSuperseded) inflight.clear();
|
|
43365
43663
|
if (inflight.size) {
|
|
43366
43664
|
agent.requeueFailedRuntimeDeliveries(
|
|
43367
43665
|
[...inflight.values()].filter((m) => !agent.isRuntimeMessageRetired(m.message_id))
|
|
@@ -43383,7 +43681,10 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
43383
43681
|
sock.on("close", cleanup);
|
|
43384
43682
|
sock.on("error", cleanup);
|
|
43385
43683
|
sock.on("message", (data) => {
|
|
43386
|
-
|
|
43684
|
+
if (sessionSuperseded) return;
|
|
43685
|
+
handleInbound(sock, agent, data, inflight, flush, session).catch((e) => {
|
|
43686
|
+
writeServiceLog2("error", "ws.inbound_failed", {}, e);
|
|
43687
|
+
});
|
|
43387
43688
|
});
|
|
43388
43689
|
flush();
|
|
43389
43690
|
});
|
|
@@ -43398,6 +43699,13 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
43398
43699
|
}
|
|
43399
43700
|
}
|
|
43400
43701
|
wss.close();
|
|
43702
|
+
},
|
|
43703
|
+
/** C9 (spec §4.1.6 step 1, OQ-9): close every open socket with 4409 /
|
|
43704
|
+
* `session_superseded`, and answer every later upgrade with 503. The
|
|
43705
|
+
* runtime's reconnect backoff does the rest; idempotent. */
|
|
43706
|
+
enterSessionSuperseded() {
|
|
43707
|
+
sessionSuperseded = true;
|
|
43708
|
+
for (const client of wss.clients) client.close(WS_CLOSE_SESSION_SUPERSEDED3, "session_superseded");
|
|
43401
43709
|
}
|
|
43402
43710
|
};
|
|
43403
43711
|
}
|
|
@@ -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;
|