@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 "
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
//
|
|
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:
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
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.
|
|
1
|
+
export declare const BRIDGE_VERSION = "0.25.2";
|