@securecoms/agent-bridge 0.23.0 → 0.25.0

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.
@@ -0,0 +1,68 @@
1
+ /** The body of one reply part, as the intent stores it (sealed at rest). */
2
+ export type ReplyBody = {
3
+ kind: "text";
4
+ text: string;
5
+ } | {
6
+ kind: "a2a";
7
+ a2a: {
8
+ a2a_version: string;
9
+ kind: "message" | "task" | "artifact";
10
+ object: Record<string, unknown>;
11
+ };
12
+ };
13
+ /** Where an intent lives: its scope (what it answers) and its part within it. */
14
+ export interface ReplyIntentKey {
15
+ scope: string;
16
+ part: string;
17
+ inReplyTo: string | null;
18
+ }
19
+ /** Longest idempotency_key accepted (mirrors the in_reply_to bound). */
20
+ export declare const MAX_IDEMPOTENCY_KEY_LENGTH = 200;
21
+ /** The HMAC key for reply ids and body MACs, derived from the identity seed. */
22
+ export declare function deriveReplyIdKey(identitySeed: Uint8Array): Buffer;
23
+ /**
24
+ * The first 16 bytes of `digest` as an RFC 4122-shaped UUID (version nibble 4,
25
+ * variant 10). The backend parses message_id with `uuid.UUID()` and the
26
+ * envelope schema only requires `format: uuid`; this is the encoding the voice
27
+ * fast path's `fastReplyId` already ships with.
28
+ */
29
+ export declare function uuidFromDigest(digest: Uint8Array): string;
30
+ /**
31
+ * The deterministic message_id of one reply part. JSON array encoding keeps the
32
+ * input injective (no separator can be smuggled through a free-text key).
33
+ */
34
+ export declare function replyMessageId(key: Uint8Array, identityId: string, channelId: string, intent: Pick<ReplyIntentKey, "scope" | "part">): string;
35
+ /** Canonical bytes of a reply body (what is sealed and what is MACed). */
36
+ export declare function encodeReplyBody(body: ReplyBody): Uint8Array;
37
+ export declare function decodeReplyBody(bytes: Uint8Array): ReplyBody;
38
+ /**
39
+ * Keyed MAC binding a body to its reply id and channel. Compares retries
40
+ * without storing a plain digest, and (PR #716 review LOW-6) makes a body moved
41
+ * to another row, or a row whose channel was edited, fail verification.
42
+ */
43
+ export declare function replyBodyMac(key: Uint8Array, replyId: string, channelId: string, body: Uint8Array): string;
44
+ /** The ordinal of an unkeyed reply part (`p:<n>`), or null for a keyed part. */
45
+ export declare function partOrdinal(part: string): number | null;
46
+ /**
47
+ * The intent key for a send. `in_reply_to` wins the scope; an explicit
48
+ * idempotency_key names the part (else the per-delivery ordinal does). An
49
+ * idempotency_key alone is its own scope with a single part.
50
+ */
51
+ export declare function replyIntentKey(opts: {
52
+ inReplyTo?: string;
53
+ idempotencyKey?: string;
54
+ ordinal: () => number;
55
+ }): ReplyIntentKey | null;
56
+ /** A caller reused an explicit idempotency_key for a different body. */
57
+ export declare class IdempotencyConflictError extends Error {
58
+ constructor();
59
+ }
60
+ /**
61
+ * PR #716 review LOW-6: a stored reply intent failed verification (its id does
62
+ * not derive from its own identity, channel, scope and part, its body does not
63
+ * match its MAC, or its cached envelope names another id or channel). It is
64
+ * dropped and never published.
65
+ */
66
+ export declare class ReplyIntentIntegrityError extends Error {
67
+ constructor();
68
+ }
@@ -33,11 +33,17 @@
33
33
  * second warm-up, not a shortcut.
34
34
  *
35
35
  * ⛔ 2. DO NOT IMPORT `libsodium-wrappers-sumo` DIRECTLY HERE. ⇒ THIS ONE STILL
36
- * BINDS ON THIS FILE, UNCHANGED. `esbuild.config.mjs:15` inlines every
37
- * `@agentvault/connect-*` workspace package into the bridge bundle and that
38
- * specifier is absent from the EXTERNAL list at `:3-8`, so esbuild would bundle
39
- * a SECOND libsodium instance out of this file. The await would then warm a
40
- * heap that nothing reads, and this fix would look correct and do nothing.
36
+ * BINDS ON THIS FILE. Before #895 that specifier was absent from the EXTERNAL
37
+ * list in `esbuild.config.mjs`, so esbuild would have bundled a SECOND
38
+ * libsodium instance out of this file: the await would warm a heap that
39
+ * nothing reads, and the fix would look correct and do nothing. #895 inlines
40
+ * the patched `@agentvault/crypto` and makes `libsodium-wrappers-sumo` EXTERNAL
41
+ * precisely so the process keeps ONE instance, shared by the inlined crypto
42
+ * and anything else. ⛔ That singleton is now carried by the EXTERNAL entry;
43
+ * removing it from that list re-creates the split heap. `scripts/pack-smoke.sh`
44
+ * G-5b proves it on the installed tarball: the warm-up below, which only
45
+ * touches the INLINED crypto, must warm the libsodium the installed tree
46
+ * resolves.
41
47
  * ⚠️ The same inlining is why the delegate below is not a no-op in the shipped
42
48
  * bundle: the SDK barrier is inlined into `dist/bin.js` exactly once, and this
43
49
  * delegate points at that one copy.
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const BRIDGE_VERSION = "0.23.0";
1
+ export declare const BRIDGE_VERSION = "0.25.0";
@@ -1,13 +1,23 @@
1
1
  import type { FastPathConfig } from "./persona";
2
2
  import type { ChatMessage } from "./fast-prompt";
3
+ /**
4
+ * SEC-06 (#12): the response body cap. A fast completion is at most 400
5
+ * tokens (a few KB of JSON), and the backend proxy enforces the same 64 KiB.
6
+ */
7
+ export declare const FAST_LLM_MAX_RESPONSE_BYTES: number;
3
8
  export declare class FastLlmError extends Error {
4
- readonly kind: "timeout" | "http" | "shape" | "network";
5
- constructor(message: string, kind: "timeout" | "http" | "shape" | "network");
9
+ readonly kind: "timeout" | "http" | "shape" | "network" | "size";
10
+ constructor(message: string, kind: "timeout" | "http" | "shape" | "network" | "size");
6
11
  }
7
12
  export declare class FastLlmClient {
8
13
  private readonly cfg;
9
14
  private readonly fetchImpl;
10
15
  private readonly key;
11
16
  constructor(cfg: FastPathConfig, fetchImpl: typeof fetch);
17
+ /**
18
+ * One deadline (`cfg.timeoutMs`) covers the whole call: connect, headers
19
+ * AND the body. The timer stays armed until the body is read, so a peer that
20
+ * sends headers and then stalls or trickles cannot hold the voice turn.
21
+ */
12
22
  complete(messages: ChatMessage[]): Promise<string>;
13
23
  }
@@ -16,12 +16,19 @@
16
16
  * { "type":"error", error, message, detail? } // canonical codes (see errors.ts):
17
17
  * // channel_pending | rate_limited |
18
18
  * // invalid_request | agent_paused |
19
- * // send_forbidden (the last two are
19
+ * // send_forbidden (those two are
20
20
  * // TERMINAL: the in_reply_to message
21
- * // is acked by the daemon, no retry)
21
+ * // is acked by the daemon, no retry) |
22
+ * // group_superseded (NOT terminal:
23
+ * // the reply is republished after
24
+ * // the agent rejoins)
22
25
  * Agent → daemon:
23
26
  * { "type":"send", channel_id, text?, a2a?, idempotency_key?,
24
- * in_reply_to? } // exactly one of text | a2a; correlation is metadata-only
27
+ * in_reply_to? } // exactly one of text | a2a. SEC-06 #18: in_reply_to
28
+ * // and/or idempotency_key make the send idempotent — a
29
+ * // deterministic keyed message_id and a durable reply
30
+ * // intent written before the publish, so a retry or a
31
+ * // redelivery republishes the SAME reply
25
32
  * { "type":"status", channel_id, state, detail? }
26
33
  * { "type":"ack", message_id } // confirms a delivered `message` was consumed
27
34
  * { "type":"nack", message_id, reason? } // final-wave I1(b): THIS message's turn
@@ -38,7 +45,9 @@
38
45
  * Inbound delivery is at-least-once. A delivered `message` is held in-flight
39
46
  * until the agent sends a matching `ack`; if the socket drops first, the frame
40
47
  * is re-buffered and redelivered to the next consumer. The agent dedups by
41
- * message_id (a fresh UUID minted per send; server-side dedup is on replay_nonce).
48
+ * message_id. SEC-06 #18: a redelivered message whose reply already has a
49
+ * durable intent is NOT handed to the agent again; the daemon republishes that
50
+ * reply itself (same message_id), so the model does not run twice.
42
51
  *
43
52
  * Bounded work (Phase 3):
44
53
  * - F2 ordering: at most ONE message per channel is in flight on a socket;
@@ -55,6 +64,9 @@
55
64
  * behind a 2^attempts × 5 s backoff. A nack fails only its own message.
56
65
  * Messages never handed over (mid-flush close, a failed frame write) go
57
66
  * back without that penalty.
67
+ * - #842: a delivery still un-acked after INFLIGHT_STALE_AFTER_MS is LOGGED
68
+ * once (`runtime_inbox.inflight_stale`) and otherwise left alone. There
69
+ * is deliberately no lease: see INFLIGHT_STALE_AFTER_MS.
58
70
  *
59
71
  * Single consumer: the daemon serves one agent per process. A new `/v1/ws`
60
72
  * connection replaces the prior one (newest wins, mirroring the SSE path); the
@@ -68,7 +80,24 @@ import type { BridgeAgent } from "./bridge-agent.js";
68
80
  export interface AgentWebSocketOptions {
69
81
  token: string;
70
82
  allowOrigins: string[];
83
+ /** Test seam: the stale-in-flight sweep period (default INFLIGHT_STALE_SWEEP_MS). */
84
+ inflightStaleSweepMs?: number;
85
+ /** Test seam: the age past which an un-acked delivery is logged (default INFLIGHT_STALE_AFTER_MS). */
86
+ inflightStaleAfterMs?: number;
87
+ /** Test seam: the clock the delivery ages are measured on. */
88
+ now?: () => number;
71
89
  }
90
+ /**
91
+ * FB-P1-024 / #842 ruling §3: a stale-in-flight OBSERVATION, not a lease.
92
+ * The bridge cannot tell a message queued inside the consumer from one whose
93
+ * turn is running, so it must not requeue or cancel on age: a lease short
94
+ * enough to matter would re-run live turns (duplicate replies and side
95
+ * effects). It only logs `runtime_inbox.inflight_stale`, once per delivery,
96
+ * content-free, and takes NO action. The post-launch fix is protocol v2
97
+ * (`started` / `cancel` negotiated in `hello`), not a shorter threshold here.
98
+ */
99
+ export declare const INFLIGHT_STALE_SWEEP_MS = 60000;
100
+ export declare const INFLIGHT_STALE_AFTER_MS: number;
72
101
  export declare function attachAgentWebSocket(server: HttpServer, agent: BridgeAgent, opts: AgentWebSocketOptions): {
73
102
  close(): void;
74
103
  };
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@securecoms/agent-bridge",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "type": "module",
5
+ "engines": {
6
+ "node": ">=22.5.0"
7
+ },
5
8
  "main": "dist/index.js",
6
9
  "types": "dist/index.d.ts",
7
10
  "bin": {
@@ -34,17 +37,24 @@
34
37
  "test:watch": "vitest"
35
38
  },
36
39
  "dependencies": {
37
- "@agentvault/crypto": "0.4.1",
40
+ "@hpke/chacha20poly1305": "^1.8.0",
41
+ "@hpke/core": "^1.8.0",
42
+ "@hpke/dhkem-x448": "^1.8.0",
43
+ "@hpke/hybridkem-x-wing": "^0.7.0",
44
+ "@hpke/ml-kem": "^0.3.0",
38
45
  "@noble/ciphers": "^1.3.0",
39
46
  "@noble/curves": "^1.9.7",
40
47
  "@noble/hashes": "^1.4.0",
48
+ "@noble/post-quantum": "^0.6.0",
41
49
  "ajv": "^8.18.0",
42
50
  "ajv-formats": "^3.0.1",
51
+ "libsodium-wrappers-sumo": "0.7.15",
43
52
  "ws": "^8.21.0",
44
53
  "zod": "^3.23.0"
45
54
  },
46
55
  "devDependencies": {
47
56
  "@agentvault/connect-core": "*",
57
+ "@agentvault/crypto": "0.4.1",
48
58
  "@types/node": "^22.0.0",
49
59
  "@types/ws": "^8.18.1",
50
60
  "esbuild": "0.24.0",