@securecoms/agent-bridge 0.30.0 → 0.30.1
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 +37 -0
- package/dist/bin.d.ts +82 -0
- package/dist/bin.js +942 -150
- package/dist/bridge-agent.d.ts +60 -0
- package/dist/errors.d.ts +1 -1
- package/dist/index.js +295 -21
- package/dist/service/install-prep.d.ts +21 -1
- package/dist/service/instance-lock.d.ts +88 -0
- package/dist/service/launchd.d.ts +1 -1
- 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 +1 -1
package/dist/bridge-agent.d.ts
CHANGED
|
@@ -353,6 +353,9 @@ export interface BridgeAgentDeps {
|
|
|
353
353
|
transport?: any;
|
|
354
354
|
backend?: any;
|
|
355
355
|
fetchImpl?: typeof fetch;
|
|
356
|
+
/** TEST SEAM: WebSocket constructor for the real RestTransport's delivery-ping
|
|
357
|
+
* socket. Production leaves it undefined (globalThis.WebSocket). */
|
|
358
|
+
wsImpl?: typeof WebSocket;
|
|
356
359
|
/**
|
|
357
360
|
* Round 5 (runtime config delivery): the home directory under which the
|
|
358
361
|
* managed runtime-config directory lives. Injected only by tests, so they can
|
|
@@ -714,6 +717,15 @@ export declare class RoomClassChangedSendError extends SendForbiddenError {
|
|
|
714
717
|
* `activeGroupId` is the group the backend named, or null when the channel has
|
|
715
718
|
* no active group (fail closed: nothing to converge to).
|
|
716
719
|
*/
|
|
720
|
+
/**
|
|
721
|
+
* C10 (spec P-15, §4.1.6): start() was superseded (4409) before it finished.
|
|
722
|
+
* start() has already run the memoized close() quiesce when this is thrown, and
|
|
723
|
+
* armed none of its timers or the receive pump. runMode catches it: the process
|
|
724
|
+
* stays alive holding the lock and the port, and serves no route.
|
|
725
|
+
*/
|
|
726
|
+
export declare class SessionSupersededError extends Error {
|
|
727
|
+
constructor();
|
|
728
|
+
}
|
|
717
729
|
export declare class GroupSupersededSendError extends Error {
|
|
718
730
|
readonly channelId: string;
|
|
719
731
|
readonly activeGroupId: string | null;
|
|
@@ -780,6 +792,13 @@ export declare class BridgeAgent {
|
|
|
780
792
|
private readonly backend;
|
|
781
793
|
private readonly custody;
|
|
782
794
|
private readonly fetchImpl;
|
|
795
|
+
/** P-1: set once a 4409 close says another copy took this agent's session.
|
|
796
|
+
* The gated fetch built in the constructor refuses every request after it. */
|
|
797
|
+
private sessionSuperseded;
|
|
798
|
+
/** C9: who hears `session_superseded` (runMode's quiesce). */
|
|
799
|
+
private readonly sessionSupersededListeners;
|
|
800
|
+
/** C9 (spec §4.1.6, low 3): the one close() run every caller shares. */
|
|
801
|
+
private closing;
|
|
783
802
|
/** Round 5: home for the managed runtime-config directory (test seam). */
|
|
784
803
|
private readonly home;
|
|
785
804
|
private readonly grantWriters;
|
|
@@ -1243,6 +1262,12 @@ export declare class BridgeAgent {
|
|
|
1243
1262
|
readonly action: AgentActionAPI;
|
|
1244
1263
|
private readonly waiters;
|
|
1245
1264
|
private pingHandle?;
|
|
1265
|
+
/** C10 (P-15): the delivery-ping subscription start() opens at its top,
|
|
1266
|
+
* held unconsumed until startReceivePump() takes it. */
|
|
1267
|
+
private earlyPings;
|
|
1268
|
+
/** C10 fix round 1: settles (never rejects) when start()'s body does; close()
|
|
1269
|
+
* awaits it, bounded, so the store never closes under a start() step. */
|
|
1270
|
+
private startInFlight;
|
|
1246
1271
|
private safetyTimer?;
|
|
1247
1272
|
/** The ingest cycle now running, if any (see ingestOnce: single-flight). */
|
|
1248
1273
|
private ingestInFlight;
|
|
@@ -1490,7 +1515,24 @@ export declare class BridgeAgent {
|
|
|
1490
1515
|
* shed so a silently-dropping daemon stays observable (item 3 / ADR-0002).
|
|
1491
1516
|
*/
|
|
1492
1517
|
private capBuffer;
|
|
1518
|
+
/**
|
|
1519
|
+
* C10 (spec P-15, OQ-8 (b)): subscribe early, consume late. The
|
|
1520
|
+
* delivery-ping socket opens here, before anything else, so this copy claims
|
|
1521
|
+
* the device's session (and another copy gets its 4409) BEFORE this copy's
|
|
1522
|
+
* boot drain runs. The pings it carries are NOT consumed until
|
|
1523
|
+
* startReceivePump(), the same place as before: starting the pump first is
|
|
1524
|
+
* still forbidden (DRAIN BEFORE ROTATE, startBody).
|
|
1525
|
+
*
|
|
1526
|
+
* A 4409 during start() (audit H-3): the gated fetch is already set (C9 step
|
|
1527
|
+
* 0). start() then arms nothing more (supersededDuringStart before each
|
|
1528
|
+
* timer and before the pump), runs the memoized close() quiesce, and rejects
|
|
1529
|
+
* with SessionSupersededError. A step that FAILED because of the supersede
|
|
1530
|
+
* (a refused fetch, a store the quiesce closed) is the same outcome.
|
|
1531
|
+
*/
|
|
1493
1532
|
start(): Promise<void>;
|
|
1533
|
+
/** C10: true once a 4409 landed; checked before each arming step of start(). */
|
|
1534
|
+
private supersededDuringStart;
|
|
1535
|
+
private startBody;
|
|
1494
1536
|
/**
|
|
1495
1537
|
* #811 L-1: send the re-Welcome signal ONCE for each channel whose onWedged
|
|
1496
1538
|
* heal the boot drain refused. Runs after the drain settled and after the
|
|
@@ -3485,5 +3527,23 @@ export declare class BridgeAgent {
|
|
|
3485
3527
|
* and zero on restart (spec §5.5). */
|
|
3486
3528
|
build: string;
|
|
3487
3529
|
};
|
|
3530
|
+
/** C9: `session_superseded` once a 4409 said another copy took this
|
|
3531
|
+
* agent's session (the gated fetch already refuses every request). The
|
|
3532
|
+
* local HTTP routes read it: every route but `/healthz` answers 503. */
|
|
3533
|
+
get sessionState(): "live" | "session_superseded";
|
|
3534
|
+
/** C9: subscribe to `session_superseded`. runMode subscribes right after
|
|
3535
|
+
* construction, before start(), so a 4409 during start() is heard too. */
|
|
3536
|
+
on(event: "session_superseded", listener: () => void): () => void;
|
|
3537
|
+
/** P-1 step 0, then the event (spec §4.1.6): another copy of this agent took
|
|
3538
|
+
* the session (4409). The gated-fetch switch is set FIRST, so no HTTP
|
|
3539
|
+
* request leaves the process after this, whatever a listener does. */
|
|
3540
|
+
private onSessionSuperseded;
|
|
3541
|
+
/**
|
|
3542
|
+
* Idempotent (C9, spec §4.1.6 low 3): every call returns the FIRST call's
|
|
3543
|
+
* promise, so a SIGTERM during or after the supersede quiesce never runs
|
|
3544
|
+
* store.close() twice. A rejected close stays rejected: a retry would close
|
|
3545
|
+
* the store a second time.
|
|
3546
|
+
*/
|
|
3488
3547
|
close(): Promise<void>;
|
|
3548
|
+
private closeOnce;
|
|
3489
3549
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* WebSocket returns `{type:"error", error, message, detail?}`. A client gets the
|
|
7
7
|
* same `error` code regardless of which transport it used. (A2A §9.5/§10.6 pattern.)
|
|
8
8
|
*/
|
|
9
|
-
export type BridgeErrorCode = "unauthorized" | "forbidden_origin" | "not_found" | "invalid_request" | "invalid_json" | "body_too_large" | "channel_pending" | "rate_limited" | "agent_paused" | "send_forbidden" | "group_superseded" | "unknown_action_run" | "vault_integrity_failed" | "vault_ref_changed" | "internal";
|
|
9
|
+
export type BridgeErrorCode = "unauthorized" | "forbidden_origin" | "not_found" | "invalid_request" | "invalid_json" | "body_too_large" | "channel_pending" | "rate_limited" | "agent_paused" | "send_forbidden" | "group_superseded" | "unknown_action_run" | "vault_integrity_failed" | "vault_ref_changed" | "session_superseded" | "internal";
|
|
10
10
|
export interface BridgeErrorSpec {
|
|
11
11
|
/** HTTP status this code maps to (the WS binding ignores it). */
|
|
12
12
|
http: number;
|
package/dist/index.js
CHANGED
|
@@ -24091,6 +24091,7 @@ function base64UrlNoPad(s) {
|
|
|
24091
24091
|
}
|
|
24092
24092
|
var BEARER_SUBPROTOCOL = "securecoms.bearer.v1";
|
|
24093
24093
|
var WS_CLOSE_IDENTITY_REVOKED = 4006;
|
|
24094
|
+
var WS_CLOSE_SESSION_SUPERSEDED = 4409;
|
|
24094
24095
|
function isIdentityRevokedClose(code, reason) {
|
|
24095
24096
|
return code === WS_CLOSE_IDENTITY_REVOKED || reason === "identity_revoked";
|
|
24096
24097
|
}
|
|
@@ -24306,7 +24307,7 @@ function openDeliveryPings(o) {
|
|
|
24306
24307
|
end();
|
|
24307
24308
|
};
|
|
24308
24309
|
if (!hardened) {
|
|
24309
|
-
if (code === 4001 || isIdentityRevokedClose(code, reason)) {
|
|
24310
|
+
if (code === 4001 || code === WS_CLOSE_SESSION_SUPERSEDED || isIdentityRevokedClose(code, reason)) {
|
|
24310
24311
|
terminalClose();
|
|
24311
24312
|
return;
|
|
24312
24313
|
}
|
|
@@ -24315,7 +24316,7 @@ function openDeliveryPings(o) {
|
|
|
24315
24316
|
}
|
|
24316
24317
|
const tr = terminalReason;
|
|
24317
24318
|
terminalReason = void 0;
|
|
24318
|
-
if (code === 4006 || tr && code === 4001) {
|
|
24319
|
+
if (code === 4006 || code === WS_CLOSE_SESSION_SUPERSEDED || tr && code === 4001) {
|
|
24319
24320
|
lifecycle("terminal", tr ?? `code ${code}`);
|
|
24320
24321
|
terminalClose();
|
|
24321
24322
|
return;
|
|
@@ -24472,10 +24473,14 @@ var RestTransport = class {
|
|
|
24472
24473
|
f;
|
|
24473
24474
|
ws;
|
|
24474
24475
|
tokenProvider;
|
|
24475
|
-
/** The
|
|
24476
|
-
* cleared
|
|
24477
|
-
* a no-op when no subscription is active. */
|
|
24476
|
+
/** The shared delivery-ping socket's send, set while that socket is open and
|
|
24477
|
+
* cleared when it closes (last subscriber gone, or terminal). Control frames
|
|
24478
|
+
* (status keepalive) route through it; a no-op when no subscription is active. */
|
|
24478
24479
|
_controlSend = null;
|
|
24480
|
+
/** P-5: the one delivery-ping stream this transport holds, shared by every subscriber. */
|
|
24481
|
+
pingHub = null;
|
|
24482
|
+
/** Set once the shared stream ends on a terminal close; no socket is ever reopened. */
|
|
24483
|
+
pingsTerminal = false;
|
|
24479
24484
|
constructor(opts) {
|
|
24480
24485
|
this.opts = opts;
|
|
24481
24486
|
this.f = opts.fetchImpl ?? fetch;
|
|
@@ -24577,25 +24582,52 @@ var RestTransport = class {
|
|
|
24577
24582
|
idempotentReplay: j.idempotent_replay === true
|
|
24578
24583
|
};
|
|
24579
24584
|
}
|
|
24585
|
+
/** One device-wide delivery-ping socket per transport (P-5): the backend
|
|
24586
|
+
* supersedes a second socket for the same device, so every subscriber shares
|
|
24587
|
+
* one `openDeliveryPings` handle. Each gets its own bounded queue; its close()
|
|
24588
|
+
* detaches only itself; the socket closes with the last subscriber and a later
|
|
24589
|
+
* subscribe opens a fresh one. After a terminal close the transport is
|
|
24590
|
+
* terminal: later calls open no socket and get an already-ended iterator. */
|
|
24580
24591
|
subscribeDeliveryPings(_channelId) {
|
|
24592
|
+
if (this.pingsTerminal) {
|
|
24593
|
+
const ended = new PingSubscriber();
|
|
24594
|
+
ended.end();
|
|
24595
|
+
return { pings: ended.pings, close: () => {
|
|
24596
|
+
}, send: () => {
|
|
24597
|
+
} };
|
|
24598
|
+
}
|
|
24599
|
+
const hub = this.pingHub ??= this.openPingHub();
|
|
24600
|
+
const sub = hub.attach();
|
|
24601
|
+
return { pings: sub.pings, close: () => hub.detach(sub), send: hub.send };
|
|
24602
|
+
}
|
|
24603
|
+
openPingHub() {
|
|
24581
24604
|
let wsUrl = this.opts.baseUrl.replace(/^http/, "ws").replace(/\/$/, "") + "/api/v1/ws";
|
|
24582
24605
|
if (this.opts.connectIdentityId) {
|
|
24583
24606
|
wsUrl += `?connect_identity_id=${encodeURIComponent(this.opts.connectIdentityId)}`;
|
|
24584
24607
|
}
|
|
24585
|
-
const
|
|
24608
|
+
const hub = new PingHub(openDeliveryPings({
|
|
24586
24609
|
url: wsUrl,
|
|
24587
24610
|
tokenProvider: this.tokenProvider,
|
|
24588
24611
|
wsImpl: this.ws,
|
|
24589
24612
|
hardened: this.opts.hardenedReconnect ?? false,
|
|
24590
|
-
onLifecycle: this.opts.onWsLifecycle
|
|
24591
|
-
|
|
24592
|
-
|
|
24593
|
-
|
|
24594
|
-
|
|
24595
|
-
this.
|
|
24596
|
-
|
|
24597
|
-
|
|
24598
|
-
|
|
24613
|
+
onLifecycle: this.opts.onWsLifecycle,
|
|
24614
|
+
// Any terminal close (4001, 4006, 4409, hardened war-cap) of the shared
|
|
24615
|
+
// socket makes this transport terminal. The shared iterator ends right
|
|
24616
|
+
// after this call, so the pump exit tears down every subscriber.
|
|
24617
|
+
onTerminalClose: (close) => {
|
|
24618
|
+
this.pingsTerminal = true;
|
|
24619
|
+
this.opts.onTerminalClose?.(close);
|
|
24620
|
+
}
|
|
24621
|
+
}), () => this.dropPingHub(hub));
|
|
24622
|
+
this._controlSend = hub.send;
|
|
24623
|
+
return hub;
|
|
24624
|
+
}
|
|
24625
|
+
dropPingHub(hub) {
|
|
24626
|
+
if (this.pingHub === hub) {
|
|
24627
|
+
this.pingHub = null;
|
|
24628
|
+
this._controlSend = null;
|
|
24629
|
+
}
|
|
24630
|
+
hub.shutdown();
|
|
24599
24631
|
}
|
|
24600
24632
|
sendControlFrame(_channelId, frame) {
|
|
24601
24633
|
try {
|
|
@@ -24623,6 +24655,90 @@ var RestTransport = class {
|
|
|
24623
24655
|
}));
|
|
24624
24656
|
}
|
|
24625
24657
|
};
|
|
24658
|
+
var SUBSCRIBER_QUEUE_MAX = 8;
|
|
24659
|
+
var PingSubscriber = class {
|
|
24660
|
+
queue = [];
|
|
24661
|
+
waiter;
|
|
24662
|
+
ended = false;
|
|
24663
|
+
started = false;
|
|
24664
|
+
pings = {
|
|
24665
|
+
[Symbol.asyncIterator]: () => {
|
|
24666
|
+
if (this.started)
|
|
24667
|
+
throw new Error("subscribeDeliveryPings: single-use async iterable");
|
|
24668
|
+
this.started = true;
|
|
24669
|
+
return {
|
|
24670
|
+
next: () => {
|
|
24671
|
+
if (this.ended)
|
|
24672
|
+
return Promise.resolve({ value: void 0, done: true });
|
|
24673
|
+
if (this.queue.length)
|
|
24674
|
+
return Promise.resolve({ value: this.queue.shift(), done: false });
|
|
24675
|
+
return new Promise((resolve) => {
|
|
24676
|
+
this.waiter = resolve;
|
|
24677
|
+
});
|
|
24678
|
+
}
|
|
24679
|
+
};
|
|
24680
|
+
}
|
|
24681
|
+
};
|
|
24682
|
+
push(p) {
|
|
24683
|
+
if (this.ended)
|
|
24684
|
+
return;
|
|
24685
|
+
if (this.waiter) {
|
|
24686
|
+
const w = this.waiter;
|
|
24687
|
+
this.waiter = void 0;
|
|
24688
|
+
w({ value: p, done: false });
|
|
24689
|
+
} else if (this.queue.length < SUBSCRIBER_QUEUE_MAX) {
|
|
24690
|
+
this.queue.push(p);
|
|
24691
|
+
}
|
|
24692
|
+
}
|
|
24693
|
+
end() {
|
|
24694
|
+
this.ended = true;
|
|
24695
|
+
this.queue.length = 0;
|
|
24696
|
+
if (this.waiter) {
|
|
24697
|
+
const w = this.waiter;
|
|
24698
|
+
this.waiter = void 0;
|
|
24699
|
+
w({ value: void 0, done: true });
|
|
24700
|
+
}
|
|
24701
|
+
}
|
|
24702
|
+
};
|
|
24703
|
+
var PingHub = class {
|
|
24704
|
+
handle;
|
|
24705
|
+
onGone;
|
|
24706
|
+
subs = /* @__PURE__ */ new Set();
|
|
24707
|
+
send;
|
|
24708
|
+
constructor(handle, onGone) {
|
|
24709
|
+
this.handle = handle;
|
|
24710
|
+
this.onGone = onGone;
|
|
24711
|
+
this.send = handle.send;
|
|
24712
|
+
void this.pump();
|
|
24713
|
+
}
|
|
24714
|
+
/** `openDeliveryPings` iterables never reject and push() never throws, so
|
|
24715
|
+
* this loop only ends when the shared stream ends (our close, or terminal). */
|
|
24716
|
+
async pump() {
|
|
24717
|
+
for await (const p of this.handle.pings)
|
|
24718
|
+
for (const s of this.subs)
|
|
24719
|
+
s.push(p);
|
|
24720
|
+
this.onGone();
|
|
24721
|
+
}
|
|
24722
|
+
attach() {
|
|
24723
|
+
const s = new PingSubscriber();
|
|
24724
|
+
this.subs.add(s);
|
|
24725
|
+
return s;
|
|
24726
|
+
}
|
|
24727
|
+
/** Idempotent: detaches and ends only `s`; the last one out closes the socket. */
|
|
24728
|
+
detach(s) {
|
|
24729
|
+
this.subs.delete(s);
|
|
24730
|
+
s.end();
|
|
24731
|
+
if (this.subs.size === 0)
|
|
24732
|
+
this.onGone();
|
|
24733
|
+
}
|
|
24734
|
+
/** Idempotent: `openDeliveryPings().close()` is, and the set is empty after. */
|
|
24735
|
+
shutdown() {
|
|
24736
|
+
for (const s of this.subs)
|
|
24737
|
+
s.end();
|
|
24738
|
+
this.subs.clear();
|
|
24739
|
+
this.handle.close();
|
|
24740
|
+
}
|
|
24741
|
+
};
|
|
24626
24742
|
|
|
24627
24743
|
// ../connect-sdk-ts/dist/mls/rest-backend.js
|
|
24628
24744
|
function describeType(v) {
|
|
@@ -34952,6 +35068,7 @@ var ROOM_DEFAULT_READ_GRANTS = Object.freeze([
|
|
|
34952
35068
|
Object.freeze({ tool: "current_time", mode: "read" })
|
|
34953
35069
|
]);
|
|
34954
35070
|
var NO_TOOL_GRANTS = Object.freeze([]);
|
|
35071
|
+
var WS_CLOSE_SESSION_SUPERSEDED2 = 4409;
|
|
34955
35072
|
var OWNER_REPLAY_PRUNE_INTERVAL_MS = 5 * 60 * 1e3;
|
|
34956
35073
|
var OWNER_TURN_UNCHECKED = Object.freeze({ kind: "unchecked" });
|
|
34957
35074
|
function ownerDmDefaultGrantsEnabled(env = process.env) {
|
|
@@ -35226,6 +35343,12 @@ function rosterFreshAt(entry, heldEpoch, heldGroupId) {
|
|
|
35226
35343
|
var RoomGrantEpochMovedError = class extends Error {
|
|
35227
35344
|
};
|
|
35228
35345
|
var ROOM_GRANT_TRACK_MAX = 4096;
|
|
35346
|
+
var SessionSupersededError = class extends Error {
|
|
35347
|
+
constructor() {
|
|
35348
|
+
super("session_superseded: another copy of this agent took its session during start()");
|
|
35349
|
+
this.name = "SessionSupersededError";
|
|
35350
|
+
}
|
|
35351
|
+
};
|
|
35229
35352
|
var GroupSupersededSendError = class extends Error {
|
|
35230
35353
|
constructor(channelId, activeGroupId) {
|
|
35231
35354
|
super(`send refused for channel ${channelId}: the channel moved to a new secure group; not sent`);
|
|
@@ -35265,9 +35388,18 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
35265
35388
|
const seed = hexToBytes4(cfg.signingPrivateKey);
|
|
35266
35389
|
this.custody = SoftwareKeyCustody.fromPrivateKey("ed25519", seed);
|
|
35267
35390
|
this.replyIdKey = deriveReplyIdKey(seed);
|
|
35391
|
+
const base = deps.fetchImpl ?? fetch;
|
|
35392
|
+
this.fetchImpl = (input, init) => this.sessionSuperseded ? Promise.reject(new Error("session_superseded")) : base(input, init);
|
|
35268
35393
|
this.transport = deps.transport ?? new RestTransport({
|
|
35269
35394
|
baseUrl: cfg.apiBase,
|
|
35270
35395
|
bearerToken: cfg.apiKey,
|
|
35396
|
+
fetchImpl: this.fetchImpl,
|
|
35397
|
+
wsImpl: deps.wsImpl,
|
|
35398
|
+
// A 4409 close means another copy of this agent took the session.
|
|
35399
|
+
// Every other terminal close keeps today's behaviour.
|
|
35400
|
+
onTerminalClose: (close) => {
|
|
35401
|
+
if (close.code === WS_CLOSE_SESSION_SUPERSEDED2) this.onSessionSuperseded();
|
|
35402
|
+
},
|
|
35271
35403
|
connectIdentityId: cfg.identityId,
|
|
35272
35404
|
// P1b: hardened delivery-ping reconnect (verify-before-terminal +
|
|
35273
35405
|
// half-open + wake + auth war-cap). On by default for the daemon;
|
|
@@ -35281,7 +35413,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
35281
35413
|
else writeServiceLog2("warn", "transport.delivery_ping_lifecycle_unknown");
|
|
35282
35414
|
}
|
|
35283
35415
|
});
|
|
35284
|
-
this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
|
|
35416
|
+
this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey, fetchImpl: this.fetchImpl });
|
|
35285
35417
|
this.store = new SqliteMlsStateStore({
|
|
35286
35418
|
dbPath: cfg.storePath,
|
|
35287
35419
|
identityId: cfg.identityId,
|
|
@@ -35352,7 +35484,6 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
35352
35484
|
// throws (mirrors the divergence-heal signal path).
|
|
35353
35485
|
onSupersedeUnopenable: (channelId, groupId) => this.healSupersedeUnopenable(channelId, groupId, this.drainExemptionAtCallIn())
|
|
35354
35486
|
});
|
|
35355
|
-
this.fetchImpl = deps.fetchImpl ?? fetch;
|
|
35356
35487
|
this.home = deps.home ?? homedir4();
|
|
35357
35488
|
this.grantWriters = {
|
|
35358
35489
|
writeManifestPolicy: deps.grantWriters?.writeManifestPolicy ?? writeManifestPolicy,
|
|
@@ -35484,6 +35615,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
35484
35615
|
// stored for peek-then-join in ingestOnce
|
|
35485
35616
|
custody;
|
|
35486
35617
|
fetchImpl;
|
|
35618
|
+
/** P-1: set once a 4409 close says another copy took this agent's session.
|
|
35619
|
+
* The gated fetch built in the constructor refuses every request after it. */
|
|
35620
|
+
sessionSuperseded = false;
|
|
35621
|
+
/** C9: who hears `session_superseded` (runMode's quiesce). */
|
|
35622
|
+
sessionSupersededListeners = /* @__PURE__ */ new Set();
|
|
35623
|
+
/** C9 (spec §4.1.6, low 3): the one close() run every caller shares. */
|
|
35624
|
+
closing = null;
|
|
35487
35625
|
/** Round 5: home for the managed runtime-config directory (test seam). */
|
|
35488
35626
|
home;
|
|
35489
35627
|
grantWriters;
|
|
@@ -35959,6 +36097,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
35959
36097
|
action;
|
|
35960
36098
|
waiters = [];
|
|
35961
36099
|
pingHandle;
|
|
36100
|
+
/** C10 (P-15): the delivery-ping subscription start() opens at its top,
|
|
36101
|
+
* held unconsumed until startReceivePump() takes it. */
|
|
36102
|
+
earlyPings = null;
|
|
36103
|
+
/** C10 fix round 1: settles (never rejects) when start()'s body does; close()
|
|
36104
|
+
* awaits it, bounded, so the store never closes under a start() step. */
|
|
36105
|
+
startInFlight = null;
|
|
35962
36106
|
safetyTimer;
|
|
35963
36107
|
/** The ingest cycle now running, if any (see ingestOnce: single-flight). */
|
|
35964
36108
|
ingestInFlight = null;
|
|
@@ -36536,7 +36680,50 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
36536
36680
|
this.droppedMessages += overflow;
|
|
36537
36681
|
}
|
|
36538
36682
|
}
|
|
36683
|
+
/**
|
|
36684
|
+
* C10 (spec P-15, OQ-8 (b)): subscribe early, consume late. The
|
|
36685
|
+
* delivery-ping socket opens here, before anything else, so this copy claims
|
|
36686
|
+
* the device's session (and another copy gets its 4409) BEFORE this copy's
|
|
36687
|
+
* boot drain runs. The pings it carries are NOT consumed until
|
|
36688
|
+
* startReceivePump(), the same place as before: starting the pump first is
|
|
36689
|
+
* still forbidden (DRAIN BEFORE ROTATE, startBody).
|
|
36690
|
+
*
|
|
36691
|
+
* A 4409 during start() (audit H-3): the gated fetch is already set (C9 step
|
|
36692
|
+
* 0). start() then arms nothing more (supersededDuringStart before each
|
|
36693
|
+
* timer and before the pump), runs the memoized close() quiesce, and rejects
|
|
36694
|
+
* with SessionSupersededError. A step that FAILED because of the supersede
|
|
36695
|
+
* (a refused fetch, a store the quiesce closed) is the same outcome.
|
|
36696
|
+
*/
|
|
36539
36697
|
async start() {
|
|
36698
|
+
const early = this.transport.subscribeDeliveryPings("");
|
|
36699
|
+
this.earlyPings = early;
|
|
36700
|
+
this.pingHandle = { close: early.close };
|
|
36701
|
+
const body = this.startBody();
|
|
36702
|
+
this.startInFlight = body.then(() => {
|
|
36703
|
+
}, () => {
|
|
36704
|
+
});
|
|
36705
|
+
try {
|
|
36706
|
+
await body;
|
|
36707
|
+
} catch (e) {
|
|
36708
|
+
if (!this.sessionSuperseded) {
|
|
36709
|
+
if (this.earlyPings) {
|
|
36710
|
+
this.earlyPings.close();
|
|
36711
|
+
this.earlyPings = null;
|
|
36712
|
+
this.pingHandle = void 0;
|
|
36713
|
+
}
|
|
36714
|
+
throw e;
|
|
36715
|
+
}
|
|
36716
|
+
if (!(e instanceof SessionSupersededError)) writeServiceLog2("warn", "startup.superseded_start_error", {}, e);
|
|
36717
|
+
await this.close().catch(() => {
|
|
36718
|
+
});
|
|
36719
|
+
throw new SessionSupersededError();
|
|
36720
|
+
}
|
|
36721
|
+
}
|
|
36722
|
+
/** C10: true once a 4409 landed; checked before each arming step of start(). */
|
|
36723
|
+
supersededDuringStart() {
|
|
36724
|
+
return this.sessionSuperseded;
|
|
36725
|
+
}
|
|
36726
|
+
async startBody() {
|
|
36540
36727
|
this.startedAt = Date.now();
|
|
36541
36728
|
await this.restoreRuntimeInbox();
|
|
36542
36729
|
await this.loadToolGrantState();
|
|
@@ -36596,10 +36783,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
36596
36783
|
await this.warmRestoredNotes();
|
|
36597
36784
|
await this.reindexRestoredTurns();
|
|
36598
36785
|
if (this.unifiedOn) await this.reindexUnifiedRestored();
|
|
36786
|
+
if (this.supersededDuringStart()) throw new SessionSupersededError();
|
|
36599
36787
|
this.runtimeConfigTimer = setInterval(() => {
|
|
36600
36788
|
void this.refetchRuntimeConfig();
|
|
36601
36789
|
}, 6 * 60 * 60 * 1e3);
|
|
36602
36790
|
this.runtimeConfigTimer.unref?.();
|
|
36791
|
+
if (this.supersededDuringStart()) throw new SessionSupersededError();
|
|
36603
36792
|
this.membershipSweepTimer = setInterval(() => {
|
|
36604
36793
|
if (this.closingDown || this.membershipSweepWork) return;
|
|
36605
36794
|
this.membershipSweepWork = this.runMembershipSweeps().finally(() => {
|
|
@@ -36607,6 +36796,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
36607
36796
|
});
|
|
36608
36797
|
}, PURGE_SWEEP_INTERVAL_MS);
|
|
36609
36798
|
this.membershipSweepTimer.unref?.();
|
|
36799
|
+
if (this.supersededDuringStart()) throw new SessionSupersededError();
|
|
36610
36800
|
this.recallPruneTimer = setInterval(() => {
|
|
36611
36801
|
if (this.closingDown || this.recallPruneWork) return;
|
|
36612
36802
|
this.recallPruneWork = this.runRecallPruneTick().finally(() => {
|
|
@@ -36614,6 +36804,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
36614
36804
|
});
|
|
36615
36805
|
}, 60 * 60 * 1e3);
|
|
36616
36806
|
this.recallPruneTimer.unref?.();
|
|
36807
|
+
if (this.supersededDuringStart()) throw new SessionSupersededError();
|
|
36617
36808
|
if (this.machinePaths) this.recallMaintenanceTimer = setInterval(() => {
|
|
36618
36809
|
if (this.closingDown) return;
|
|
36619
36810
|
const n = ++this.maintenanceTickN;
|
|
@@ -36624,6 +36815,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
36624
36815
|
});
|
|
36625
36816
|
}, 1e3);
|
|
36626
36817
|
this.recallMaintenanceTimer?.unref?.();
|
|
36818
|
+
if (this.supersededDuringStart()) throw new SessionSupersededError();
|
|
36627
36819
|
this.startReceivePump();
|
|
36628
36820
|
if (drainedInTime) {
|
|
36629
36821
|
this.armSelfHealSweeps();
|
|
@@ -40887,7 +41079,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40887
41079
|
}
|
|
40888
41080
|
}
|
|
40889
41081
|
startReceivePump() {
|
|
40890
|
-
const sub = this.transport.subscribeDeliveryPings("");
|
|
41082
|
+
const sub = this.earlyPings ?? this.transport.subscribeDeliveryPings("");
|
|
41083
|
+
this.earlyPings = null;
|
|
40891
41084
|
this.pingHandle = { close: sub.close };
|
|
40892
41085
|
void (async () => {
|
|
40893
41086
|
try {
|
|
@@ -42472,7 +42665,42 @@ ${channelId}`;
|
|
|
42472
42665
|
build: BRIDGE_VERSION
|
|
42473
42666
|
};
|
|
42474
42667
|
}
|
|
42475
|
-
|
|
42668
|
+
/** C9: `session_superseded` once a 4409 said another copy took this
|
|
42669
|
+
* agent's session (the gated fetch already refuses every request). The
|
|
42670
|
+
* local HTTP routes read it: every route but `/healthz` answers 503. */
|
|
42671
|
+
get sessionState() {
|
|
42672
|
+
return this.sessionSuperseded ? "session_superseded" : "live";
|
|
42673
|
+
}
|
|
42674
|
+
/** C9: subscribe to `session_superseded`. runMode subscribes right after
|
|
42675
|
+
* construction, before start(), so a 4409 during start() is heard too. */
|
|
42676
|
+
on(event, listener) {
|
|
42677
|
+
this.sessionSupersededListeners.add(listener);
|
|
42678
|
+
return () => this.sessionSupersededListeners.delete(listener);
|
|
42679
|
+
}
|
|
42680
|
+
/** P-1 step 0, then the event (spec §4.1.6): another copy of this agent took
|
|
42681
|
+
* the session (4409). The gated-fetch switch is set FIRST, so no HTTP
|
|
42682
|
+
* request leaves the process after this, whatever a listener does. */
|
|
42683
|
+
onSessionSuperseded() {
|
|
42684
|
+
this.sessionSuperseded = true;
|
|
42685
|
+
for (const listener of [...this.sessionSupersededListeners]) {
|
|
42686
|
+
try {
|
|
42687
|
+
listener();
|
|
42688
|
+
} catch (e) {
|
|
42689
|
+
writeServiceLog2("error", "transport.session_superseded_listener_failed", {}, e);
|
|
42690
|
+
}
|
|
42691
|
+
}
|
|
42692
|
+
}
|
|
42693
|
+
/**
|
|
42694
|
+
* Idempotent (C9, spec §4.1.6 low 3): every call returns the FIRST call's
|
|
42695
|
+
* promise, so a SIGTERM during or after the supersede quiesce never runs
|
|
42696
|
+
* store.close() twice. A rejected close stays rejected: a retry would close
|
|
42697
|
+
* the store a second time.
|
|
42698
|
+
*/
|
|
42699
|
+
close() {
|
|
42700
|
+
this.closing ??= this.closeOnce();
|
|
42701
|
+
return this.closing;
|
|
42702
|
+
}
|
|
42703
|
+
async closeOnce() {
|
|
42476
42704
|
this.closingDown = true;
|
|
42477
42705
|
this.statusGate.close();
|
|
42478
42706
|
for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
|
|
@@ -42485,6 +42713,28 @@ ${channelId}`;
|
|
|
42485
42713
|
if (this.safetyTimer) clearInterval(this.safetyTimer);
|
|
42486
42714
|
if (this.runtimeConfigTimer) clearInterval(this.runtimeConfigTimer);
|
|
42487
42715
|
if (this.membershipSweepTimer) clearInterval(this.membershipSweepTimer);
|
|
42716
|
+
const ingest = this.ingestInFlight;
|
|
42717
|
+
if (ingest) {
|
|
42718
|
+
let timer;
|
|
42719
|
+
await Promise.race([
|
|
42720
|
+
ingest.released,
|
|
42721
|
+
new Promise((resolve) => {
|
|
42722
|
+
timer = setTimeout(resolve, _BridgeAgent.GRACEFUL_DRAIN_MS);
|
|
42723
|
+
})
|
|
42724
|
+
]);
|
|
42725
|
+
if (timer) clearTimeout(timer);
|
|
42726
|
+
}
|
|
42727
|
+
const starting = this.startInFlight;
|
|
42728
|
+
if (starting) {
|
|
42729
|
+
let timer;
|
|
42730
|
+
await Promise.race([
|
|
42731
|
+
starting,
|
|
42732
|
+
new Promise((resolve) => {
|
|
42733
|
+
timer = setTimeout(resolve, _BridgeAgent.GRACEFUL_DRAIN_MS);
|
|
42734
|
+
})
|
|
42735
|
+
]);
|
|
42736
|
+
if (timer) clearTimeout(timer);
|
|
42737
|
+
}
|
|
42488
42738
|
if (this.membershipSweepWork) {
|
|
42489
42739
|
let timer;
|
|
42490
42740
|
await Promise.race([
|
|
@@ -42761,6 +43011,9 @@ var BRIDGE_ERRORS = {
|
|
|
42761
43011
|
// longer holds the shared bytes or key. Terminal for that link: never fall
|
|
42762
43012
|
// back to the current version. `detail.pin` names the refused pin.
|
|
42763
43013
|
vault_ref_changed: { http: 422, message: "this file changed in Files after it was shared; not opened" },
|
|
43014
|
+
// C9 (spec §4.1.6 step 1): another copy of this agent took its SecureComs
|
|
43015
|
+
// session (WS 4409), so this copy stopped. Every local route but /healthz.
|
|
43016
|
+
session_superseded: { http: 503, message: "another copy of this agent took its session; this copy stopped" },
|
|
42764
43017
|
internal: { http: 500, message: "internal error" }
|
|
42765
43018
|
};
|
|
42766
43019
|
function errorBody(code, detail) {
|
|
@@ -42991,6 +43244,10 @@ function createHttpServer(agent, opts) {
|
|
|
42991
43244
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
42992
43245
|
const path = url.pathname;
|
|
42993
43246
|
const method = req.method ?? "GET";
|
|
43247
|
+
if (agent.sessionState === "session_superseded") {
|
|
43248
|
+
if (method === "GET" && path === "/healthz") return json(res, 200, { state: "session_superseded" });
|
|
43249
|
+
return jsonError(res, "session_superseded");
|
|
43250
|
+
}
|
|
42994
43251
|
if (method === "GET" && path === "/healthz") {
|
|
42995
43252
|
return json(res, 200, { ...agent.health(), capabilities: capabilities(opts) });
|
|
42996
43253
|
}
|
|
@@ -43202,6 +43459,7 @@ var PING_INTERVAL_MS = 3e4;
|
|
|
43202
43459
|
var INFLIGHT_STALE_SWEEP_MS = 6e4;
|
|
43203
43460
|
var INFLIGHT_STALE_AFTER_MS = 15 * 6e4;
|
|
43204
43461
|
var HELLO_WAIT_MS = 2e3;
|
|
43462
|
+
var WS_CLOSE_SESSION_SUPERSEDED3 = 4409;
|
|
43205
43463
|
var MEMORY_NOTES_FEATURE = "memory_notes";
|
|
43206
43464
|
var MEMORY_RECALL_FEATURE = "memory_recall";
|
|
43207
43465
|
var MEMORY_UNIFIED_FEATURE = "memory_unified";
|
|
@@ -43222,7 +43480,12 @@ function authedUpgrade(req, opts) {
|
|
|
43222
43480
|
return true;
|
|
43223
43481
|
}
|
|
43224
43482
|
function attachAgentWebSocket(server, agent, opts) {
|
|
43225
|
-
|
|
43483
|
+
let sessionSuperseded = false;
|
|
43484
|
+
const wss = new WebSocketServer({
|
|
43485
|
+
server,
|
|
43486
|
+
path: "/v1/ws",
|
|
43487
|
+
verifyClient: (_info, done) => sessionSuperseded ? done(false, 503, "session_superseded") : done(true)
|
|
43488
|
+
});
|
|
43226
43489
|
const keepalive = setInterval(() => {
|
|
43227
43490
|
for (const client of wss.clients) {
|
|
43228
43491
|
const sock = client;
|
|
@@ -43362,6 +43625,7 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
43362
43625
|
if (helloTimer) clearTimeout(helloTimer);
|
|
43363
43626
|
agent.noteSessionClose();
|
|
43364
43627
|
unsubscribe();
|
|
43628
|
+
if (sessionSuperseded) inflight.clear();
|
|
43365
43629
|
if (inflight.size) {
|
|
43366
43630
|
agent.requeueFailedRuntimeDeliveries(
|
|
43367
43631
|
[...inflight.values()].filter((m) => !agent.isRuntimeMessageRetired(m.message_id))
|
|
@@ -43383,7 +43647,10 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
43383
43647
|
sock.on("close", cleanup);
|
|
43384
43648
|
sock.on("error", cleanup);
|
|
43385
43649
|
sock.on("message", (data) => {
|
|
43386
|
-
|
|
43650
|
+
if (sessionSuperseded) return;
|
|
43651
|
+
handleInbound(sock, agent, data, inflight, flush, session).catch((e) => {
|
|
43652
|
+
writeServiceLog2("error", "ws.inbound_failed", {}, e);
|
|
43653
|
+
});
|
|
43387
43654
|
});
|
|
43388
43655
|
flush();
|
|
43389
43656
|
});
|
|
@@ -43398,6 +43665,13 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
43398
43665
|
}
|
|
43399
43666
|
}
|
|
43400
43667
|
wss.close();
|
|
43668
|
+
},
|
|
43669
|
+
/** C9 (spec §4.1.6 step 1, OQ-9): close every open socket with 4409 /
|
|
43670
|
+
* `session_superseded`, and answer every later upgrade with 503. The
|
|
43671
|
+
* runtime's reconnect backoff does the rest; idempotent. */
|
|
43672
|
+
enterSessionSuperseded() {
|
|
43673
|
+
sessionSuperseded = true;
|
|
43674
|
+
for (const client of wss.clients) client.close(WS_CLOSE_SESSION_SUPERSEDED3, "session_superseded");
|
|
43401
43675
|
}
|
|
43402
43676
|
};
|
|
43403
43677
|
}
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { type ServiceInput } from "./config.js";
|
|
2
2
|
import { type AutoWireDeps } from "./harness-wire.js";
|
|
3
|
+
/** The parsed `/healthz` body fields the install and status liveness checks read. */
|
|
4
|
+
export interface DaemonHealth {
|
|
5
|
+
agent_sessions?: number;
|
|
6
|
+
evictions?: number;
|
|
7
|
+
state?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Liveness is a routed `/healthz` 200 whose state is not `session_superseded`
|
|
11
|
+
* (P-2). A TCP accept proves nothing: the daemon pre-binds its port before
|
|
12
|
+
* `agent.start()`, so a connect succeeds before the bridge routes anything.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isDaemonRouted(h: DaemonHealth | null): h is DaemonHealth;
|
|
15
|
+
/** Default install wait: the boot drain may take up to BOOT_DRAIN_WAIT_MS_DEFAULT, plus 15 s. */
|
|
16
|
+
export declare const VERIFY_BUDGET_MS: number;
|
|
3
17
|
export interface PrepDeps {
|
|
4
18
|
readFile: (p: string) => string;
|
|
5
19
|
writeFileMode: (p: string, data: string, mode: number) => void;
|
|
@@ -8,8 +22,11 @@ export interface PrepDeps {
|
|
|
8
22
|
realpath: (p: string) => string;
|
|
9
23
|
listDir: (p: string) => string[];
|
|
10
24
|
probeBindFree: (port: number) => boolean;
|
|
11
|
-
|
|
25
|
+
/** Routed `/healthz` probe (P-2): null unless the daemon answered a 200 with parseable JSON. */
|
|
26
|
+
getHealth: (port: number) => DaemonHealth | null;
|
|
12
27
|
sleep: (ms: number) => Promise<void>;
|
|
28
|
+
/** Wall clock in ms (Date.now). verifyDaemonUp's deadline reads it, so probe time counts, not only sleep. */
|
|
29
|
+
now: () => number;
|
|
13
30
|
}
|
|
14
31
|
export interface ParsedCredential {
|
|
15
32
|
deviceId: string;
|
|
@@ -19,6 +36,9 @@ export interface ParsedCredential {
|
|
|
19
36
|
export declare function assertReadableCredential(absPath: string, deps: PrepDeps): ParsedCredential;
|
|
20
37
|
export declare function durableDir(home: string, slug: string): string;
|
|
21
38
|
export declare function durableCredentialPath(home: string, slug: string): string;
|
|
39
|
+
/** M5 (OQ-3): the approved refusal text for an identity that is already installed. */
|
|
40
|
+
export declare class InstallRefusedError extends Error {
|
|
41
|
+
}
|
|
22
42
|
export declare function takeCustody(args: {
|
|
23
43
|
home: string;
|
|
24
44
|
slug: string;
|