@securecoms/agent-bridge 0.25.0 → 0.25.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.
@@ -129,6 +129,38 @@ export function chatTypeFor(authorization) {
129
129
  return audience === "owner_dm" || audience === "other_human_dm" ? "direct" : "group";
130
130
  }
131
131
 
132
+ // ---------------------------------------------------------------------------
133
+ // Content-free diagnostic lines (#910). Same discipline as the bridge's
134
+ // writeServiceLog (src/service-log.ts, and the literal-event lint in
135
+ // src/service-log.test.ts): the caller passes a LITERAL template and typed
136
+ // fields, and every substituted value must be a finite number or a bare
137
+ // identifier token (a status state, an error class name). Anything else —
138
+ // free text, an error message, an id with dashes, a URL — renders as
139
+ // "invalid", so no message content or identifier can reach the log through
140
+ // these lines even if a caller passes one by mistake.
141
+ // ---------------------------------------------------------------------------
142
+
143
+ const SAFE_TOKEN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
144
+
145
+ function _safeField(v) {
146
+ if (typeof v === "number" && Number.isFinite(v)) return String(Math.round(v));
147
+ if (typeof v === "string" && SAFE_TOKEN.test(v)) return v;
148
+ return "invalid";
149
+ }
150
+
151
+ /** Render `template`, replacing each `{key}` with the sanitized `fields[key]`. */
152
+ export function formatSafeLine(template, fields) {
153
+ return String(template).replace(/\{(\w+)\}/g, (_, key) => _safeField(fields?.[key]));
154
+ }
155
+
156
+ /** An error's class name without reading its message or stack (never throws). */
157
+ function _errorClass(err) {
158
+ try {
159
+ if (err && typeof err === "object" && typeof err.name === "string" && SAFE_TOKEN.test(err.name)) return err.name;
160
+ } catch { /* hostile getter */ }
161
+ return typeof err === "object" && err !== null ? "Error" : "Thrown";
162
+ }
163
+
132
164
  // ---------------------------------------------------------------------------
133
165
  // Account / identity / connection-file resolution.
134
166
  // ---------------------------------------------------------------------------
@@ -265,6 +297,9 @@ export class LoopbackChannel {
265
297
  // reconnect backoff, so a failing turn is retried with growing delay
266
298
  // instead of a ~250 ms redelivery loop.
267
299
  this._failStreak = 0;
300
+ // Per-channel status reporter state (see reportStatus): channelId ->
301
+ // { desired, sent, inFlight }.
302
+ this._status = new Map();
268
303
  }
269
304
 
270
305
  _setState(s) {
@@ -561,21 +596,74 @@ export class LoopbackChannel {
561
596
  }
562
597
 
563
598
  // Report agent operational status to the daemon (POST /v1/status). Drives the
564
- // web "Thinking…" indicator (J1). Best-effort and FIRE-AND-FORGET: callers do
565
- // NOT await it, so the status round-trip adds no latency to the turn. On this
566
- // deployment an MLS publish (which /v1/status performs) costs ~4.4s, so any
567
- // await here would add seconds before the reply — the exact lag we hit. No
568
- // AbortSignal timeout either: a 2s cap fired before the ~4.4s publish landed,
569
- // so the "working" status never published and the strip never showed. Let the
570
- // background fetch run to completion; it swallows its own errors.
571
- async reportStatus(channelId, state) {
599
+ // web "… is thinking…" strip (J1, #910). A port of the runtime's reporter
600
+ // (packages/connect-agent-runtime/src/status-reporter.ts, #911):
601
+ //
602
+ // * FIRE-AND-FORGET. Synchronous, returns nothing, never throws. The bridge
603
+ // answers /v1/status only after an MLS publish (~4.4s in prod), so no
604
+ // turn may await it. No AbortSignal timeout: a 2s cap once fired before
605
+ // the publish landed, so "working" never published.
606
+ //
607
+ // * ORDERED PER CHANNEL. The web keeps the latest status. Two concurrent
608
+ // POSTs are two concurrent publishes on the bridge, so a turn's "idle"
609
+ // could land with or before its "working" and the strip never showed.
610
+ // So each channel has at most ONE POST in flight; a state set meanwhile
611
+ // becomes the channel's desired state and is sent when that POST
612
+ // settles. Only the latest desired state is kept, and a state equal to
613
+ // the last one sent is not re-sent, so the final state published for a
614
+ // channel is always the last one set.
615
+ //
616
+ // * LOGGED, CONTENT-FREE. Each outcome is one line with the state and a
617
+ // duration, HTTP code or error class — never the channel id, the body
618
+ // or an error message.
619
+ reportStatus(channelId, state) {
620
+ let entry = this._status.get(channelId);
621
+ if (!entry) {
622
+ entry = { desired: state, sent: null, inFlight: false };
623
+ this._status.set(channelId, entry);
624
+ }
625
+ entry.desired = state;
626
+ if (entry.inFlight) return;
627
+ entry.inFlight = true;
628
+ void this._drainStatus(channelId, entry);
629
+ }
630
+
631
+ async _drainStatus(channelId, entry) {
632
+ while (entry.desired !== entry.sent) {
633
+ const state = entry.desired;
634
+ // Marked sent before the POST: a failed POST is not retried (the next
635
+ // state change will publish the truth anyway).
636
+ entry.sent = state;
637
+ await this._postStatus(channelId, state);
638
+ }
639
+ entry.inFlight = false;
640
+ // Bounded by channels with a change still to publish.
641
+ if (this._status.get(channelId) === entry) this._status.delete(channelId);
642
+ }
643
+
644
+ async _postStatus(channelId, state) {
645
+ const t0 = Date.now();
572
646
  try {
573
- await fetch(`${this.bridgeUrl}/v1/status`, {
647
+ // bridgeUrl/token read per POST: a reconnect can move the port or rotate the token.
648
+ const r = await fetch(`${this.bridgeUrl}/v1/status`, {
574
649
  method: "POST",
575
650
  headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
576
651
  body: JSON.stringify({ channel_id: channelId, state }),
577
652
  });
578
- } catch { /* best-effort: never break a turn on a status POST */ }
653
+ if (r.ok) this._logSafe("[securecoms] status {state} ok ms={ms}", { state, ms: Date.now() - t0 });
654
+ else this._logSafe("[securecoms] status {state} failed http={http}", { state, http: r.status });
655
+ // Release the connection; the body carries nothing we need.
656
+ try { await r.body?.cancel?.(); } catch { /* ignore */ }
657
+ } catch (err) {
658
+ // Best-effort presence: a status failure never reaches the turn.
659
+ this._logSafe("[securecoms] status {state} failed error={cls}", { state, cls: _errorClass(err) });
660
+ }
661
+ }
662
+
663
+ /** One content-free line (see formatSafeLine). A logger failure never propagates. */
664
+ _logSafe(template, fields) {
665
+ const sink = this.log;
666
+ try { sink(formatSafeLine(template, fields)); } catch { /* never break a turn on logging */ }
579
667
  }
580
668
 
581
669
  async stop() {
@@ -671,33 +759,63 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
671
759
  onRecordError: (err) => core.error?.(`[SecureComs] session record failed: ${String(err)}`),
672
760
  });
673
761
 
674
- // J1 activity indicator: flip the agent to "working" for the duration of the
675
- // reply turn (the LLM dispatch, the bulk of "thinking") and back to "idle"
676
- // when it finishes OR throws. FIRE-AND-FORGET (not awaited): the status
677
- // publish costs ~4.4s on this deployment, so awaiting it added that latency
678
- // before the reply and after the turn. Ordering stays correct without the
679
- // await because "working" is issued before a multi-second dispatch, giving it
680
- // a large head-start over the reply's own publish — the ping→pong race that
681
- // motivated awaiting doesn't occur when the reply itself takes seconds to
682
- // publish. The web client clears the strip on the later "idle".
762
+ // J1 activity indicator (#910): "working" for the reply turn, "idle" when it
763
+ // is over — including when dispatch throws. Both go through the channel's
764
+ // per-channel reporter (LoopbackChannel.reportStatus): synchronous, never
765
+ // awaited, never throws, and ordered, so "idle" can never land before
766
+ // "working".
767
+ //
768
+ // "Over" means dispatch returned AND no deliver is still publishing a reply
769
+ // block: if the dispatcher returns while a deliver it started is pending,
770
+ // "idle" waits for the last one to settle (success or failure) so the strip
771
+ // does not clear before the reply is out. The turn itself does not wait for
772
+ // that — ack/redelivery behavior is unchanged.
683
773
  channel.reportStatus(conv, "working");
774
+ const pendingDeliveries = new Set();
775
+ let deliveredBlocks = 0;
776
+ const dispatchStarted = Date.now();
684
777
  try {
685
778
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
686
779
  ctx: ctxPayload,
687
780
  cfg,
688
781
  dispatcherOptions: {
689
- deliver: async (payload) => {
690
- // Drop chain-of-thought / reasoning blocks; only send finalized text.
691
- if (payload?.kind === "thinking" || payload?.kind === "reasoning") return;
692
- const t = (payload?.text ?? "").trim();
693
- if (t) await channel.send(conv, t, metadata.messageId);
782
+ deliver: (payload) => {
783
+ const p = (async () => {
784
+ // Drop chain-of-thought / reasoning blocks; only send finalized text.
785
+ if (payload?.kind === "thinking" || payload?.kind === "reasoning") return;
786
+ const t = (payload?.text ?? "").trim();
787
+ if (t) {
788
+ await channel.send(conv, t, metadata.messageId);
789
+ deliveredBlocks++;
790
+ }
791
+ })();
792
+ pendingDeliveries.add(p);
793
+ const done = () => { pendingDeliveries.delete(p); };
794
+ p.then(done, done);
795
+ // The dispatcher still sees this block's own outcome (and error).
796
+ return p;
694
797
  },
695
798
  onError: (err, info) => core.error?.(`[SecureComs] ${info?.kind ?? "reply"} error: ${String(err)}`),
696
799
  },
697
800
  replyOptions: {},
698
801
  });
699
802
  } finally {
700
- channel.reportStatus(conv, "idle");
803
+ // One line per turn, content-free: tells whether dispatch returns before
804
+ // its reply blocks are delivered (delivered_blocks=0 with pending > 0).
805
+ channel._logSafe("[securecoms] turn dispatch_ms={ms} delivered_blocks={k} pending_deliveries={p}", {
806
+ ms: Date.now() - dispatchStarted,
807
+ k: deliveredBlocks,
808
+ p: pendingDeliveries.size,
809
+ });
810
+ if (pendingDeliveries.size === 0) {
811
+ channel.reportStatus(conv, "idle");
812
+ } else {
813
+ void (async () => {
814
+ // A deliver may start another while we wait; drain until none is left.
815
+ while (pendingDeliveries.size > 0) await Promise.allSettled([...pendingDeliveries]);
816
+ channel.reportStatus(conv, "idle");
817
+ })();
818
+ }
701
819
  }
702
820
  }
703
821
 
@@ -0,0 +1,432 @@
1
+ // packages/connect-agent-bridge/connectors/openclaw/openclaw-entry.status.test.ts
2
+ //
3
+ // #910 part 2: the web "… is thinking…" strip never showed for the OpenClaw
4
+ // connector. A direct working -> idle POST to the bridge showed it, so the loss
5
+ // was inside this plugin: reportStatus swallowed every error unlogged, and the
6
+ // turn fired `working` and `idle` as two concurrent, unordered POSTs (each an
7
+ // MLS publish on the bridge), so `idle` could land with or before `working`.
8
+ //
9
+ // These tests pin the port of the runtime's per-channel status reporter
10
+ // (packages/connect-agent-runtime/src/status-reporter.ts, #911):
11
+ // - one status POST in flight per channel, latest desired state wins, an
12
+ // equal state is never re-sent, so `idle` can never overtake `working`;
13
+ // - outcomes are logged content-free (`ok ms=` / `failed http=|error=`);
14
+ // - a status POST never delays or fails the turn;
15
+ // - `idle` waits until dispatch returned AND every deliver has settled;
16
+ // - one `turn dispatch_ms=… delivered_blocks=…` line per turn.
17
+
18
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
+ import { mkdtempSync, writeFileSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { buildConnectionFile, writeConnectionFile } from "../../src/service/connection-file.js";
23
+
24
+ const BOUND_ID = "agent-identity";
25
+ const OWNER_ID = "owner-human";
26
+ const PORT = 7097;
27
+ const TOKEN_VALUE = "status-test-token";
28
+ const ORIGINAL_CONN_FILE_ENV = process.env.SECURECOMS_CONNECTION_FILE;
29
+
30
+ const ownerAuth = { version: 1, audience: "owner_dm", sender_membership: "active", tool_grants: [] };
31
+
32
+ function waitFor(predicate: () => boolean, timeout = 3000): Promise<void> {
33
+ const start = Date.now();
34
+ return new Promise((resolve, reject) => {
35
+ const check = () => {
36
+ if (predicate()) return resolve();
37
+ if (Date.now() - start > timeout) return reject(new Error("waitFor: condition never became true"));
38
+ setTimeout(check, 5);
39
+ };
40
+ check();
41
+ });
42
+ }
43
+
44
+ const tick = (ms = 20) => new Promise((r) => setTimeout(r, ms));
45
+
46
+ type Deferred<T> = { promise: Promise<T>; resolve: (v: T) => void; reject: (e: unknown) => void };
47
+ function deferred<T>(): Deferred<T> {
48
+ let resolve!: (v: T) => void;
49
+ let reject!: (e: unknown) => void;
50
+ const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
51
+ return { promise, resolve, reject };
52
+ }
53
+
54
+ const okRes = () => ({ ok: true, status: 200, json: async () => ({ ok: true }) });
55
+
56
+ /**
57
+ * A controllable /v1/status endpoint: every POST is recorded and parked until
58
+ * the test settles it, so completion order is under the test's control.
59
+ */
60
+ function makeStatusEndpoint() {
61
+ const posts: Array<{ state: string; channel: string; settle: Deferred<unknown> }> = [];
62
+ const completed: string[] = [];
63
+ let inFlight = 0;
64
+ let maxInFlight = 0;
65
+ const handle = (init?: RequestInit) => {
66
+ const body = JSON.parse(String(init?.body));
67
+ const settle = deferred<unknown>();
68
+ posts.push({ state: body.state, channel: body.channel_id, settle });
69
+ inFlight++;
70
+ maxInFlight = Math.max(maxInFlight, inFlight);
71
+ return settle.promise.finally(() => {
72
+ inFlight--;
73
+ completed.push(body.state);
74
+ });
75
+ };
76
+ return {
77
+ posts,
78
+ completed,
79
+ handle,
80
+ get maxInFlight() { return maxInFlight; },
81
+ };
82
+ }
83
+
84
+ async function loadModule(home: string) {
85
+ vi.resetModules();
86
+ vi.doMock("node:os", async (importOriginal) => {
87
+ const actual = await importOriginal<typeof import("node:os")>();
88
+ return { ...actual, homedir: () => home };
89
+ });
90
+ return await import("./openclaw-entry.js");
91
+ }
92
+
93
+ function makeFakeWebSocket() {
94
+ const instances: any[] = [];
95
+ class FakeWebSocket {
96
+ url: string;
97
+ sent: Array<Record<string, unknown>> = [];
98
+ closed = false;
99
+ listeners: Record<string, Array<(ev?: unknown) => void>> = {};
100
+ constructor(url: string) { this.url = url; instances.push(this); }
101
+ addEventListener(type: string, cb: (ev?: unknown) => void) { (this.listeners[type] ??= []).push(cb); }
102
+ send(data: string) { this.sent.push(JSON.parse(data)); }
103
+ close() { if (this.closed) return; this.closed = true; this._emit("close"); }
104
+ _emit(type: string, ev?: unknown) { for (const cb of this.listeners[type] ?? []) cb(ev ?? {}); }
105
+ }
106
+ return { FakeWebSocket, instances };
107
+ }
108
+
109
+ function makeRuntime() {
110
+ let dispatchImpl: (args: any) => Promise<void> = async () => {};
111
+ const runtime = {
112
+ channel: {
113
+ routing: { resolveAgentRoute: () => ({ agentId: "main", sessionKey: "agent:main:main" }) },
114
+ session: {
115
+ resolveStorePath: () => "/tmp/openclaw-store-unused",
116
+ readSessionUpdatedAt: () => undefined,
117
+ recordInboundSession: async () => {},
118
+ },
119
+ reply: {
120
+ resolveEnvelopeFormatOptions: () => ({}),
121
+ formatAgentEnvelope: (args: any) => `ENV:${args.body}`,
122
+ finalizeInboundContext: (ctx: any) => ctx,
123
+ dispatchReplyWithBufferedBlockDispatcher: async (args: any) => { await dispatchImpl(args); },
124
+ },
125
+ },
126
+ error: () => {},
127
+ };
128
+ return { runtime, setDispatch(fn: (args: any) => Promise<void>) { dispatchImpl = fn; } };
129
+ }
130
+
131
+ describe("openclaw-entry — per-channel status reporter (#910)", () => {
132
+ let home: string;
133
+ let logs: string[];
134
+ let controller: AbortController;
135
+ let statusImpl: (init?: RequestInit) => Promise<unknown>;
136
+ let sendImpl: (body: Record<string, unknown>) => Promise<unknown>;
137
+ let statusPosts: string[];
138
+ let sends: Array<Record<string, unknown>>;
139
+
140
+ beforeEach(() => {
141
+ home = mkdtempSync(join(tmpdir(), "sc-status-"));
142
+ const tokenFile = join(home, "bridge-token");
143
+ writeFileSync(tokenFile, TOKEN_VALUE, { mode: 0o600 });
144
+ writeConnectionFile(home, buildConnectionFile({
145
+ identityId: BOUND_ID,
146
+ port: PORT,
147
+ tokenFile,
148
+ slug: "status-test",
149
+ label: "Status Test",
150
+ instanceId: "status-test-instance",
151
+ now: new Date().toISOString(),
152
+ }));
153
+ process.env.SECURECOMS_CONNECTION_FILE = join(home, ".securecoms", "connections", `${BOUND_ID}.json`);
154
+ logs = [];
155
+ statusPosts = [];
156
+ sends = [];
157
+ statusImpl = async () => okRes();
158
+ sendImpl = async () => ({ ok: true, status: 200, json: async () => ({ message_id: "reply-1" }) });
159
+ vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => {
160
+ const u = String(url);
161
+ if (u.endsWith("/healthz")) return { ok: true, json: async () => ({ identity_id: BOUND_ID, joined_channels: 1 }) };
162
+ if (u.includes("/v1/reply-receipt")) return { ok: true, status: 200, json: async () => ({ found: false }) };
163
+ if (u.endsWith("/v1/send")) {
164
+ const body = JSON.parse(String(init?.body));
165
+ sends.push(body);
166
+ return sendImpl(body);
167
+ }
168
+ if (u.endsWith("/v1/status")) {
169
+ statusPosts.push(JSON.parse(String(init?.body)).state);
170
+ return statusImpl(init);
171
+ }
172
+ throw new Error(`unexpected fetch: ${u}`);
173
+ }));
174
+ controller = new AbortController();
175
+ });
176
+
177
+ afterEach(() => {
178
+ controller.abort();
179
+ vi.unstubAllGlobals();
180
+ vi.unmock("node:os");
181
+ if (ORIGINAL_CONN_FILE_ENV === undefined) delete process.env.SECURECOMS_CONNECTION_FILE;
182
+ else process.env.SECURECOMS_CONNECTION_FILE = ORIGINAL_CONN_FILE_ENV;
183
+ vi.resetModules();
184
+ });
185
+
186
+ async function newChannel() {
187
+ const mod = await loadModule(home);
188
+ const channel = new mod.LoopbackChannel({ boundIdentity: BOUND_ID, log: (m: string) => logs.push(String(m)) });
189
+ channel.bridgeUrl = `http://127.0.0.1:${PORT}`;
190
+ channel.token = TOKEN_VALUE;
191
+ return channel;
192
+ }
193
+
194
+ async function startTurnHarness(runtime: unknown) {
195
+ const mod = await loadModule(home);
196
+ let plugin: any;
197
+ mod.default.register({ runtime, registerChannel: ({ plugin: p }: { plugin: unknown }) => { plugin = p; } });
198
+ const { FakeWebSocket, instances } = makeFakeWebSocket();
199
+ vi.stubGlobal("WebSocket", FakeWebSocket);
200
+ const account = plugin.config.resolveAccount({}, "default");
201
+ void plugin.gateway.startAccount({
202
+ account,
203
+ cfg: {},
204
+ log: (m: string) => logs.push(String(m)),
205
+ abortSignal: controller.signal,
206
+ });
207
+ await waitFor(() => instances.length >= 1);
208
+ instances[0]._emit("open");
209
+ const ws = instances[0];
210
+ const deliverFrame = (messageId: string, channelId = "dm-1") =>
211
+ ws._emit("message", { data: JSON.stringify({
212
+ type: "message", ts: 1_700_000_000_000, channel_id: channelId, message_id: messageId,
213
+ sender_id: OWNER_ID, text: "hello", authorization: ownerAuth,
214
+ }) });
215
+ const acks = () => ws.sent.filter((f: any) => f.type === "ack").map((f: any) => f.message_id);
216
+ return { deliverFrame, acks };
217
+ }
218
+
219
+ // ------------------------------------------------ reporter (unit) ----
220
+
221
+ it("working is sent before idle and idle never overtakes it, even when the POSTs would complete out of order", async () => {
222
+ const ep = makeStatusEndpoint();
223
+ statusImpl = ep.handle;
224
+ const channel = await newChannel();
225
+
226
+ channel.reportStatus("dm-1", "working");
227
+ channel.reportStatus("dm-1", "idle");
228
+ await tick();
229
+ // Only `working` is on the wire; `idle` is held until it settles.
230
+ expect(ep.posts.map((p) => p.state)).toEqual(["working"]);
231
+
232
+ ep.posts[0].settle.resolve(okRes());
233
+ await waitFor(() => ep.posts.length === 2);
234
+ expect(ep.posts.map((p) => p.state)).toEqual(["working", "idle"]);
235
+ ep.posts[1].settle.resolve(okRes());
236
+ await waitFor(() => ep.completed.length === 2);
237
+ expect(ep.completed).toEqual(["working", "idle"]);
238
+ expect(ep.maxInFlight).toBe(1);
239
+ });
240
+
241
+ it("two quick turns on one channel publish working then idle once (latest wins, equal state not re-sent)", async () => {
242
+ const ep = makeStatusEndpoint();
243
+ statusImpl = ep.handle;
244
+ const channel = await newChannel();
245
+
246
+ channel.reportStatus("dm-1", "working");
247
+ channel.reportStatus("dm-1", "idle");
248
+ channel.reportStatus("dm-1", "working");
249
+ channel.reportStatus("dm-1", "idle");
250
+ await tick();
251
+ ep.posts[0].settle.resolve(okRes());
252
+ await waitFor(() => ep.posts.length === 2);
253
+ ep.posts[1].settle.resolve(okRes());
254
+ await tick();
255
+ expect(ep.posts.map((p) => p.state)).toEqual(["working", "idle"]);
256
+ expect(ep.maxInFlight).toBe(1);
257
+ });
258
+
259
+ it("channels are independent: a slow POST on one channel does not hold another channel's status", async () => {
260
+ const ep = makeStatusEndpoint();
261
+ statusImpl = ep.handle;
262
+ const channel = await newChannel();
263
+ channel.reportStatus("dm-1", "working");
264
+ channel.reportStatus("dm-2", "working");
265
+ await tick();
266
+ expect(ep.posts.map((p) => `${p.channel}:${p.state}`).sort()).toEqual(["dm-1:working", "dm-2:working"]);
267
+ });
268
+
269
+ it("reportStatus returns synchronously and never throws, even when fetch throws synchronously", async () => {
270
+ const channel = await newChannel();
271
+ statusImpl = () => { throw new TypeError("fetch failed"); };
272
+ expect(() => channel.reportStatus("dm-1", "working")).not.toThrow();
273
+ expect(channel.reportStatus("dm-1", "idle")).toBeUndefined();
274
+ await waitFor(() => logs.some((m) => m.includes("status idle failed")));
275
+ });
276
+
277
+ it("logs a non-2xx status POST as failed with the HTTP code, and a success as ok with its duration", async () => {
278
+ const channel = await newChannel();
279
+ statusImpl = async () => ({ ok: false, status: 503, json: async () => ({}) });
280
+ channel.reportStatus("dm-1", "working");
281
+ await waitFor(() => logs.some((m) => m.includes("[securecoms] status working failed")));
282
+ expect(logs).toContain("[securecoms] status working failed http=503");
283
+
284
+ statusImpl = async () => okRes();
285
+ channel.reportStatus("dm-1", "idle");
286
+ await waitFor(() => logs.some((m) => m.includes("[securecoms] status idle ok")));
287
+ expect(logs.find((m) => m.includes("status idle ok"))).toMatch(/^\[securecoms\] status idle ok ms=\d+$/);
288
+ });
289
+
290
+ it("logs a rejected status POST as failed with the error class only (no message, no ids)", async () => {
291
+ const channel = await newChannel();
292
+ statusImpl = async () => { throw new TypeError("connect ECONNREFUSED 127.0.0.1:7097 dm-1 secret"); };
293
+ channel.reportStatus("dm-1", "working");
294
+ await waitFor(() => logs.some((m) => m.includes("status working failed")));
295
+ expect(logs).toContain("[securecoms] status working failed error=TypeError");
296
+ expect(logs.join("\n")).not.toContain("dm-1");
297
+ expect(logs.join("\n")).not.toContain("ECONNREFUSED");
298
+ });
299
+
300
+ it("formatSafeLine renders only finite numbers and bare identifier tokens; anything else is 'invalid'", async () => {
301
+ const mod = await loadModule(home);
302
+ const t = "[securecoms] x a={a} b={b} c={c} d={d} e={e} f={f}";
303
+ expect(mod.formatSafeLine(t, {
304
+ a: 12.4, b: "working", c: "hello world", d: "dm-1", e: Number.NaN, f: undefined,
305
+ })).toBe("[securecoms] x a=12 b=working c=invalid d=invalid e=invalid f=invalid");
306
+ expect(mod.formatSafeLine("{m}", { m: "HEAD-private-message-TAIL" })).toBe("invalid");
307
+ });
308
+
309
+ it("a hostile error (throwing name getter) is logged as a generic class, never breaking the reporter", async () => {
310
+ const channel = await newChannel();
311
+ const hostile = Object.create(Error.prototype, { name: { get: () => { throw new Error("getter read"); } } });
312
+ statusImpl = async () => { throw hostile; };
313
+ channel.reportStatus("dm-1", "working");
314
+ await waitFor(() => logs.includes("[securecoms] status working failed error=Error"));
315
+ });
316
+
317
+ it("a failed POST is not retried, and the next state is still sent after it", async () => {
318
+ const channel = await newChannel();
319
+ let n = 0;
320
+ statusImpl = async () => { n++; if (n === 1) throw new Error("boom"); return okRes(); };
321
+ channel.reportStatus("dm-1", "working");
322
+ channel.reportStatus("dm-1", "idle");
323
+ await waitFor(() => statusPosts.length === 2);
324
+ await tick();
325
+ expect(statusPosts).toEqual(["working", "idle"]);
326
+ });
327
+
328
+ // ------------------------------------------------ turn (integration) ----
329
+
330
+ it("a never-resolving status POST does not delay the turn: the reply is sent and the message acked", async () => {
331
+ statusImpl = () => new Promise(() => {});
332
+ const rt = makeRuntime();
333
+ rt.setDispatch(async (args) => { await args.dispatcherOptions.deliver({ text: "pong" }); });
334
+ const h = await startTurnHarness(rt.runtime);
335
+ h.deliverFrame("m-hang");
336
+ await waitFor(() => h.acks().includes("m-hang"));
337
+ expect(sends.map((s) => s.text)).toEqual(["pong"]);
338
+ expect(statusPosts).toEqual(["working"]); // idle is queued behind the hung POST, never concurrent
339
+ });
340
+
341
+ it("a failing status POST is logged and the turn is unaffected (reply sent, acked, idle still attempted)", async () => {
342
+ statusImpl = async () => ({ ok: false, status: 500, json: async () => ({}) });
343
+ const rt = makeRuntime();
344
+ rt.setDispatch(async (args) => { await args.dispatcherOptions.deliver({ text: "pong" }); });
345
+ const h = await startTurnHarness(rt.runtime);
346
+ h.deliverFrame("m-500");
347
+ await waitFor(() => h.acks().includes("m-500"));
348
+ await waitFor(() => statusPosts.length === 2);
349
+ expect(sends.map((s) => s.text)).toEqual(["pong"]);
350
+ expect(statusPosts).toEqual(["working", "idle"]);
351
+ await waitFor(() => logs.includes("[securecoms] status idle failed http=500"));
352
+ expect(logs).toContain("[securecoms] status working failed http=500");
353
+ });
354
+
355
+ it("idle waits for a deliver still in flight when dispatch returns early, then is sent once it settles", async () => {
356
+ const sendGate = deferred<unknown>();
357
+ sendImpl = () => sendGate.promise;
358
+ const rt = makeRuntime();
359
+ // Dispatch hands a block to deliver and returns WITHOUT awaiting it.
360
+ rt.setDispatch(async (args) => { void args.dispatcherOptions.deliver({ text: "late reply" }); });
361
+ const h = await startTurnHarness(rt.runtime);
362
+ h.deliverFrame("m-early");
363
+ await waitFor(() => sends.length === 1);
364
+ await waitFor(() => logs.some((m) => m.startsWith("[securecoms] turn dispatch_ms=")));
365
+ await tick(50);
366
+ expect(statusPosts).toEqual(["working"]); // no idle while the reply is still being published
367
+
368
+ sendGate.resolve({ ok: true, status: 200, json: async () => ({ message_id: "r" }) });
369
+ await waitFor(() => statusPosts.length === 2);
370
+ expect(statusPosts).toEqual(["working", "idle"]);
371
+ });
372
+
373
+ it("idle is still sent when a pending deliver fails", async () => {
374
+ const sendGate = deferred<unknown>();
375
+ sendImpl = () => sendGate.promise;
376
+ const rt = makeRuntime();
377
+ rt.setDispatch(async (args) => { void args.dispatcherOptions.deliver({ text: "x" }).catch(() => {}); });
378
+ const h = await startTurnHarness(rt.runtime);
379
+ h.deliverFrame("m-fail");
380
+ await waitFor(() => sends.length === 1);
381
+ await tick(30);
382
+ expect(statusPosts).toEqual(["working"]);
383
+ sendGate.reject(new Error("send exploded"));
384
+ await waitFor(() => statusPosts.length === 2);
385
+ expect(statusPosts).toEqual(["working", "idle"]);
386
+ });
387
+
388
+ it("idle is still sent when dispatch throws (unchanged error behavior)", async () => {
389
+ const rt = makeRuntime();
390
+ rt.setDispatch(async () => { throw new Error("model exploded"); });
391
+ const h = await startTurnHarness(rt.runtime);
392
+ h.deliverFrame("m-throw");
393
+ await waitFor(() => statusPosts.length === 2);
394
+ expect(statusPosts).toEqual(["working", "idle"]);
395
+ expect(h.acks()).not.toContain("m-throw");
396
+ });
397
+
398
+ it("logs the dispatch duration and delivered block count once per turn", async () => {
399
+ const rt = makeRuntime();
400
+ rt.setDispatch(async (args) => {
401
+ await args.dispatcherOptions.deliver({ text: "one" });
402
+ await args.dispatcherOptions.deliver({ kind: "thinking", text: "hidden" });
403
+ await args.dispatcherOptions.deliver({ text: "two" });
404
+ });
405
+ const h = await startTurnHarness(rt.runtime);
406
+ h.deliverFrame("m-log");
407
+ await waitFor(() => h.acks().includes("m-log"));
408
+ const lines = logs.filter((m) => m.startsWith("[securecoms] turn "));
409
+ expect(lines).toHaveLength(1);
410
+ expect(lines[0]).toMatch(/^\[securecoms\] turn dispatch_ms=\d+ delivered_blocks=2 pending_deliveries=0$/);
411
+ });
412
+
413
+ it("two quick turns on one channel end with working then idle, idle last", async () => {
414
+ const rt = makeRuntime();
415
+ rt.setDispatch(async (args) => { await args.dispatcherOptions.deliver({ text: "r" }); });
416
+ const ep = makeStatusEndpoint();
417
+ statusImpl = ep.handle;
418
+ const h = await startTurnHarness(rt.runtime);
419
+ h.deliverFrame("t1");
420
+ h.deliverFrame("t2");
421
+ await waitFor(() => h.acks().length === 2);
422
+ // Settle whatever is on the wire until the channel is drained.
423
+ for (let i = 0; i < 6; i++) {
424
+ for (const p of ep.posts) p.settle.resolve(okRes());
425
+ await tick();
426
+ }
427
+ expect(ep.maxInFlight).toBe(1);
428
+ expect(ep.posts[0].state).toBe("working");
429
+ expect(ep.posts.at(-1)?.state).toBe("idle");
430
+ expect(ep.posts.map((p) => p.state)).toEqual(["working", "idle"]);
431
+ });
432
+ });
package/dist/bin.js CHANGED
@@ -28583,6 +28583,13 @@ function assertManagedName(name) {
28583
28583
  if (!name || name.includes("/") || name.includes("\\") || name === "." || name === ".." || name !== basename2(name)) {
28584
28584
  throw new Error(`refusing managed config file name ${JSON.stringify(name)}: not a plain filename`);
28585
28585
  }
28586
+ if (isLocalOnlyName(name)) {
28587
+ throw new Error(`refusing managed config file name ${JSON.stringify(name)}: reserved for a local writer`);
28588
+ }
28589
+ }
28590
+ function isLocalOnlyName(name) {
28591
+ const n = name.toLowerCase().replace(/[. ]+$/, "");
28592
+ return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json";
28586
28593
  }
28587
28594
  function atomicWrite(path, body, deps) {
28588
28595
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
package/dist/index.js CHANGED
@@ -28432,6 +28432,13 @@ function assertManagedName(name) {
28432
28432
  if (!name || name.includes("/") || name.includes("\\") || name === "." || name === ".." || name !== basename2(name)) {
28433
28433
  throw new Error(`refusing managed config file name ${JSON.stringify(name)}: not a plain filename`);
28434
28434
  }
28435
+ if (isLocalOnlyName(name)) {
28436
+ throw new Error(`refusing managed config file name ${JSON.stringify(name)}: reserved for a local writer`);
28437
+ }
28438
+ }
28439
+ function isLocalOnlyName(name) {
28440
+ const n = name.toLowerCase().replace(/[. ]+$/, "");
28441
+ return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json";
28435
28442
  }
28436
28443
  function atomicWrite(path, body, deps) {
28437
28444
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const BRIDGE_VERSION = "0.25.0";
1
+ export declare const BRIDGE_VERSION = "0.25.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-bridge",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.5.0"