@securecoms/agent-bridge 0.26.4 → 0.26.5
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 +11 -0
- package/connectors/hermes/adapter.py +202 -3
- package/connectors/hermes/securecoms_authz.py +190 -0
- package/connectors/hermes/tests/test_adapter_wiring.py +481 -3
- package/connectors/hermes/tests/test_securecoms_authz.py +235 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +19 -4
- package/connectors/openclaw/openclaw-entry.js +184 -3
- package/connectors/openclaw/openclaw-entry.status.test.ts +11 -1
- package/connectors/openclaw/openclaw-entry.toolgate.test.ts +466 -0
- package/dist/bin.js +88 -7
- package/dist/bridge-agent.d.ts +38 -4
- package/dist/harness-report.d.ts +14 -0
- package/dist/index.js +88 -7
- package/dist/service/runtime-config-dir.d.ts +22 -0
- package/dist/version.d.ts +1 -1
- package/dist/ws-server.d.ts +0 -77
- package/package.json +1 -1
package/dist/bridge-agent.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MlsGroupKeyProvider } from "@agentvault/connect-sdk-ts";
|
|
2
|
+
import type { HarnessReport } from "./harness-report.js";
|
|
2
3
|
import type { MemoryContextResult } from "@agentvault/connect-mls-store";
|
|
3
4
|
/**
|
|
4
5
|
* M6 (ADR-0013 Decision H): what the sweep observed about one channel.
|
|
@@ -58,7 +59,10 @@ export type BridgeToolGrant = {
|
|
|
58
59
|
* Tool grants (architect ruling, 2026-10-02): until channel-grant persistence
|
|
59
60
|
* lands, the ONLY non-empty grant list is OWNER_DM_DEFAULT_READ_GRANTS
|
|
60
61
|
* (`current_time` + `web_search`, read mode), and only on a DM whose sender is
|
|
61
|
-
*
|
|
62
|
+
* that DM's pinned human counterpart AND server-marked `is_agent_owner` (or a
|
|
63
|
+
* relayed voice turn whose speaker is). The pin is per channel, so the
|
|
64
|
+
* owner distinction rests on the server's flag: see authorizationFor's LIMIT
|
|
65
|
+
* note. Every
|
|
62
66
|
* other audience, an unpinned or anomalous owner, and the kill switch
|
|
63
67
|
* `SECURECOMS_OWNER_DM_DEFAULT_GRANTS` (anything but unset/`on`) get `[]`.
|
|
64
68
|
* SEC-06 is LIVE for these two tools: docs/security/sec-06-runtime-bounds-audit.md.
|
|
@@ -1959,14 +1963,25 @@ export declare class BridgeAgent {
|
|
|
1959
1963
|
*
|
|
1960
1964
|
* Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
|
|
1961
1965
|
* the kill switch is on, the channel is a DM, and EITHER
|
|
1962
|
-
* (a) audience is owner_dm AND the sender is the pinned
|
|
1963
|
-
* (`channelOwner`, never "anomalous")
|
|
1964
|
-
*
|
|
1966
|
+
* (a) audience is owner_dm AND the sender is the channel's pinned DM
|
|
1967
|
+
* counterpart (`channelOwner`, never "anomalous"). A server-marked
|
|
1968
|
+
* `is_agent_owner` on a member who is NOT the DM's single human
|
|
1969
|
+
* counterpart gets nothing; on the counterpart it is trusted (see
|
|
1970
|
+
* LIMIT below); OR
|
|
1965
1971
|
* (b) the sender is this channel's server-marked relay (`is_relay`) and the
|
|
1966
1972
|
* relayed `speaker_identity_id` (passed by the caller only for relay
|
|
1967
1973
|
* turns) is that same pinned owner, whose roster row is the agent's
|
|
1968
1974
|
* owner. A speaker claim from any non-relay sender gets nothing.
|
|
1969
1975
|
* Everything else gets the frozen empty list.
|
|
1976
|
+
*
|
|
1977
|
+
* LIMIT (harness gate review H2, 2026-10-05): "pinned structural owner"
|
|
1978
|
+
* is PER CHANNEL. resolveAndPinOwner pins the single human counterpart of
|
|
1979
|
+
* ANY DM, so in a DM with member Bob, Bob is that channel's pin. What
|
|
1980
|
+
* separates Bob from the real owner is only the server's `is_agent_owner`
|
|
1981
|
+
* flag (connect_channels.py: counterpart user == agent's creator). A
|
|
1982
|
+
* backend that sets that flag wrongly gives Bob the grants, and a harness
|
|
1983
|
+
* connector turns grants into its full toolset. Binding the owner identity
|
|
1984
|
+
* once, outside server control, is an open design follow-up.
|
|
1970
1985
|
*/
|
|
1971
1986
|
private authorizationFor;
|
|
1972
1987
|
/**
|
|
@@ -2298,6 +2313,19 @@ export declare class BridgeAgent {
|
|
|
2298
2313
|
* ws-server.ts right after prior.cleanup() in the eviction path.
|
|
2299
2314
|
*/
|
|
2300
2315
|
noteSessionEvicted(): void;
|
|
2316
|
+
/** Harness tool gate H4: latest connector self-report (see harness-report.ts). */
|
|
2317
|
+
private harness;
|
|
2318
|
+
/**
|
|
2319
|
+
* Record a connector's harness report (from its /v1/ws hello). WARN once
|
|
2320
|
+
* each time the attached harness turns to an inactive veto: such a harness
|
|
2321
|
+
* refuses every turn without a tool grant, so its rooms go quiet.
|
|
2322
|
+
*/
|
|
2323
|
+
recordHarnessReport(report: HarnessReport): void;
|
|
2324
|
+
/** Forget `report` when the connector that sent it disconnects. A report
|
|
2325
|
+
* from a newer connector (a different object) is kept. */
|
|
2326
|
+
clearHarnessReport(report: HarnessReport): void;
|
|
2327
|
+
/** The latest harness report, or null (GET /v1/status). */
|
|
2328
|
+
harnessReport(): HarnessReport | null;
|
|
2301
2329
|
health(): {
|
|
2302
2330
|
status: "ok";
|
|
2303
2331
|
identity_id: string;
|
|
@@ -2369,6 +2397,12 @@ export declare class BridgeAgent {
|
|
|
2369
2397
|
* agent_sessions stays >=1 means two connectors are flapping the same
|
|
2370
2398
|
* identity (split-brain), not one stable harness. */
|
|
2371
2399
|
evictions: number;
|
|
2400
|
+
/** Harness tool gate H4: is the attached harness's tool veto active?
|
|
2401
|
+
* null when no connector is attached with a report. false means that
|
|
2402
|
+
* harness refuses every turn without a tool grant. Only this boolean is
|
|
2403
|
+
* on the unauthenticated /healthz; name and version are on the
|
|
2404
|
+
* authenticated GET /v1/status. */
|
|
2405
|
+
harness_tool_veto_active: boolean | null;
|
|
2372
2406
|
uptime_s: number;
|
|
2373
2407
|
version: string;
|
|
2374
2408
|
/** M6: is the self-heal sweep running at all? It is the SOLE writer of the
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A connector's self-report, carried on its `/v1/ws` hello (harness tool
|
|
3
|
+
* gate, fix round 1 H4): which harness is attached and whether its tool veto
|
|
4
|
+
* is active. Untrusted input from a loopback client: every field is closed to
|
|
5
|
+
* a known set or a bounded version token, so only booleans/versions reach
|
|
6
|
+
* /healthz, GET /v1/status and the service log.
|
|
7
|
+
*/
|
|
8
|
+
export interface HarnessReport {
|
|
9
|
+
readonly name: "hermes" | "openclaw" | "other";
|
|
10
|
+
readonly version: string | null;
|
|
11
|
+
/** "inactive": the harness refuses every turn without a tool grant. */
|
|
12
|
+
readonly tool_veto: "active" | "inactive" | "unknown";
|
|
13
|
+
}
|
|
14
|
+
export declare function parseHarnessReport(raw: unknown): HarnessReport | null;
|
package/dist/index.js
CHANGED
|
@@ -28638,7 +28638,7 @@ function assertManagedName(name) {
|
|
|
28638
28638
|
}
|
|
28639
28639
|
function isLocalOnlyName(name) {
|
|
28640
28640
|
const n = name.toLowerCase().replace(/[. ]+$/, "");
|
|
28641
|
-
return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json";
|
|
28641
|
+
return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json" || n === "secrets";
|
|
28642
28642
|
}
|
|
28643
28643
|
function atomicWrite(path, body, deps) {
|
|
28644
28644
|
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
@@ -28697,6 +28697,26 @@ function lineHash(line) {
|
|
|
28697
28697
|
return createHash2("sha256").update(line.trim(), "utf8").digest("hex");
|
|
28698
28698
|
}
|
|
28699
28699
|
var LOCAL_REPORT_FILE = "local-config.json";
|
|
28700
|
+
var SECRET_PATH_COUNT_MAX = 1e4;
|
|
28701
|
+
var isCount = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0 && v <= SECRET_PATH_COUNT_MAX;
|
|
28702
|
+
function parseSecretPathsReport(raw) {
|
|
28703
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
28704
|
+
const r = raw;
|
|
28705
|
+
const f = r.outside_by_field;
|
|
28706
|
+
if (r.mode !== "report" && r.mode !== "enforce" || !isCount(r.inside) || !isCount(r.allowlisted) || !isCount(r.outside) || !f || typeof f !== "object" || Array.isArray(f) || !isCount(f.headers_file) || !isCount(f.postgres_url_file) || !isCount(f.search_key_file) || f.sqlite !== void 0 && !isCount(f.sqlite)) return void 0;
|
|
28707
|
+
return {
|
|
28708
|
+
mode: r.mode,
|
|
28709
|
+
inside: r.inside,
|
|
28710
|
+
allowlisted: r.allowlisted,
|
|
28711
|
+
outside: r.outside,
|
|
28712
|
+
outside_by_field: {
|
|
28713
|
+
headers_file: f.headers_file,
|
|
28714
|
+
postgres_url_file: f.postgres_url_file,
|
|
28715
|
+
search_key_file: f.search_key_file,
|
|
28716
|
+
...f.sqlite !== void 0 ? { sqlite: f.sqlite } : {}
|
|
28717
|
+
}
|
|
28718
|
+
};
|
|
28719
|
+
}
|
|
28700
28720
|
var LEGACY_MEMORY_SOURCE_MAX_BYTES = 4 * 1024 * 1024;
|
|
28701
28721
|
var LEGACY_MEMORY_REPORT_MAX_BYTES = 4 * 1024 * 1024;
|
|
28702
28722
|
var LEGACY_MEMORY_REPORT_MAX_SOURCES = 100;
|
|
@@ -28765,7 +28785,8 @@ function readLocalReport(home, identityId) {
|
|
|
28765
28785
|
persona,
|
|
28766
28786
|
has_search_key: hasSearchKey,
|
|
28767
28787
|
has_llm_key: hasLlmKey,
|
|
28768
|
-
legacy_memory: localLegacySources(doc).map(({ path: _path, ...source }) => source)
|
|
28788
|
+
legacy_memory: localLegacySources(doc).map(({ path: _path, ...source }) => source),
|
|
28789
|
+
secret_paths: parseSecretPathsReport(doc.secret_paths)
|
|
28769
28790
|
};
|
|
28770
28791
|
}
|
|
28771
28792
|
function deleteLegacyMemorySources(home, identityId, ids) {
|
|
@@ -29963,7 +29984,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
29963
29984
|
// Inert legacy files only. `readLocalReport` revalidates the current
|
|
29964
29985
|
// regular-file bytes and strips every local path before this leaves the
|
|
29965
29986
|
// machine.
|
|
29966
|
-
legacy_memory: local?.legacy_memory ?? []
|
|
29987
|
+
legacy_memory: local?.legacy_memory ?? [],
|
|
29988
|
+
// §B.4: counts of server-named secret paths inside / allowlisted /
|
|
29989
|
+
// outside the secrets directory, and the runtime's mode. Numbers and
|
|
29990
|
+
// one enum only; readLocalReport rebuilds it so no path rides along.
|
|
29991
|
+
secret_paths: local?.secret_paths ?? null
|
|
29967
29992
|
};
|
|
29968
29993
|
const posted = await this.fetchBounded("runtime_config_report", "control", `${base}/report`, {
|
|
29969
29994
|
method: "POST",
|
|
@@ -32191,14 +32216,25 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32191
32216
|
*
|
|
32192
32217
|
* Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
|
|
32193
32218
|
* the kill switch is on, the channel is a DM, and EITHER
|
|
32194
|
-
* (a) audience is owner_dm AND the sender is the pinned
|
|
32195
|
-
* (`channelOwner`, never "anomalous")
|
|
32196
|
-
*
|
|
32219
|
+
* (a) audience is owner_dm AND the sender is the channel's pinned DM
|
|
32220
|
+
* counterpart (`channelOwner`, never "anomalous"). A server-marked
|
|
32221
|
+
* `is_agent_owner` on a member who is NOT the DM's single human
|
|
32222
|
+
* counterpart gets nothing; on the counterpart it is trusted (see
|
|
32223
|
+
* LIMIT below); OR
|
|
32197
32224
|
* (b) the sender is this channel's server-marked relay (`is_relay`) and the
|
|
32198
32225
|
* relayed `speaker_identity_id` (passed by the caller only for relay
|
|
32199
32226
|
* turns) is that same pinned owner, whose roster row is the agent's
|
|
32200
32227
|
* owner. A speaker claim from any non-relay sender gets nothing.
|
|
32201
32228
|
* Everything else gets the frozen empty list.
|
|
32229
|
+
*
|
|
32230
|
+
* LIMIT (harness gate review H2, 2026-10-05): "pinned structural owner"
|
|
32231
|
+
* is PER CHANNEL. resolveAndPinOwner pins the single human counterpart of
|
|
32232
|
+
* ANY DM, so in a DM with member Bob, Bob is that channel's pin. What
|
|
32233
|
+
* separates Bob from the real owner is only the server's `is_agent_owner`
|
|
32234
|
+
* flag (connect_channels.py: counterpart user == agent's creator). A
|
|
32235
|
+
* backend that sets that flag wrongly gives Bob the grants, and a harness
|
|
32236
|
+
* connector turns grants into its full toolset. Binding the owner identity
|
|
32237
|
+
* once, outside server control, is an open design follow-up.
|
|
32202
32238
|
*/
|
|
32203
32239
|
authorizationFor(channelId, senderId, relaySpeakerId) {
|
|
32204
32240
|
const meta = this.freshMeta(channelId);
|
|
@@ -33744,6 +33780,31 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
33744
33780
|
noteSessionEvicted() {
|
|
33745
33781
|
this.evictions++;
|
|
33746
33782
|
}
|
|
33783
|
+
/** Harness tool gate H4: latest connector self-report (see harness-report.ts). */
|
|
33784
|
+
harness = null;
|
|
33785
|
+
/**
|
|
33786
|
+
* Record a connector's harness report (from its /v1/ws hello). WARN once
|
|
33787
|
+
* each time the attached harness turns to an inactive veto: such a harness
|
|
33788
|
+
* refuses every turn without a tool grant, so its rooms go quiet.
|
|
33789
|
+
*/
|
|
33790
|
+
recordHarnessReport(report) {
|
|
33791
|
+
const wasInactive = this.harness?.tool_veto === "inactive";
|
|
33792
|
+
this.harness = report;
|
|
33793
|
+
if (report.tool_veto === "inactive" && !wasInactive) {
|
|
33794
|
+
writeServiceLog2("warn", "harness.tool_veto_inactive", {
|
|
33795
|
+
harness: ServiceLogEnum.of(report.name, ["hermes", "openclaw", "other"])
|
|
33796
|
+
});
|
|
33797
|
+
}
|
|
33798
|
+
}
|
|
33799
|
+
/** Forget `report` when the connector that sent it disconnects. A report
|
|
33800
|
+
* from a newer connector (a different object) is kept. */
|
|
33801
|
+
clearHarnessReport(report) {
|
|
33802
|
+
if (this.harness === report) this.harness = null;
|
|
33803
|
+
}
|
|
33804
|
+
/** The latest harness report, or null (GET /v1/status). */
|
|
33805
|
+
harnessReport() {
|
|
33806
|
+
return this.harness;
|
|
33807
|
+
}
|
|
33747
33808
|
health() {
|
|
33748
33809
|
let joined = 0;
|
|
33749
33810
|
for (const s of this.joinState.values()) if (s === "JOINED") joined++;
|
|
@@ -33813,6 +33874,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
33813
33874
|
delivery_pings: this.pingsReceived,
|
|
33814
33875
|
agent_sessions: this.authSessions,
|
|
33815
33876
|
evictions: this.evictions,
|
|
33877
|
+
harness_tool_veto_active: this.harness === null ? null : this.harness.tool_veto === "active",
|
|
33816
33878
|
uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
|
|
33817
33879
|
version: BRIDGE_VERSION,
|
|
33818
33880
|
...this.memory.stats?.() ?? {}
|
|
@@ -34345,6 +34407,9 @@ function createHttpServer(agent, opts) {
|
|
|
34345
34407
|
const r = await agent.vaultDelete(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id);
|
|
34346
34408
|
return json(res, 200, r);
|
|
34347
34409
|
}
|
|
34410
|
+
if (method === "GET" && path === "/v1/status") {
|
|
34411
|
+
return json(res, 200, { harness: agent.harnessReport() });
|
|
34412
|
+
}
|
|
34348
34413
|
if (method === "POST" && path === "/v1/status") {
|
|
34349
34414
|
const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
|
|
34350
34415
|
if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
|
|
@@ -34434,6 +34499,17 @@ function createHttpServer(agent, opts) {
|
|
|
34434
34499
|
return { server, closeAllSseStreams: () => closeSse() };
|
|
34435
34500
|
}
|
|
34436
34501
|
|
|
34502
|
+
// src/harness-report.ts
|
|
34503
|
+
var VERSION_TOKEN = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,39}$/;
|
|
34504
|
+
function parseHarnessReport(raw) {
|
|
34505
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
34506
|
+
const r = raw;
|
|
34507
|
+
const name = r.name === "hermes" || r.name === "openclaw" ? r.name : "other";
|
|
34508
|
+
const version = typeof r.version === "string" && VERSION_TOKEN.test(r.version) ? r.version : null;
|
|
34509
|
+
const tool_veto = r.tool_veto === "active" || r.tool_veto === "inactive" ? r.tool_veto : "unknown";
|
|
34510
|
+
return Object.freeze({ name, version, tool_veto });
|
|
34511
|
+
}
|
|
34512
|
+
|
|
34437
34513
|
// src/ws-server.ts
|
|
34438
34514
|
import { WebSocketServer, WebSocket } from "ws";
|
|
34439
34515
|
var PING_INTERVAL_MS = 3e4;
|
|
@@ -34564,6 +34640,7 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
34564
34640
|
}
|
|
34565
34641
|
delivered.clear();
|
|
34566
34642
|
if (current?.sock === sock) current = null;
|
|
34643
|
+
if (session.harness) agent.clearHarnessReport(session.harness);
|
|
34567
34644
|
};
|
|
34568
34645
|
current = { sock, cleanup, sweepStale };
|
|
34569
34646
|
sock.on("close", cleanup);
|
|
@@ -34626,7 +34703,11 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
|
|
|
34626
34703
|
return;
|
|
34627
34704
|
}
|
|
34628
34705
|
if (m.type === "hello") {
|
|
34629
|
-
|
|
34706
|
+
const report = m.harness === void 0 ? null : parseHarnessReport(m.harness);
|
|
34707
|
+
if (report) {
|
|
34708
|
+
session.harness = report;
|
|
34709
|
+
agent.recordHarnessReport(report);
|
|
34710
|
+
} else session.consumer = "runtime";
|
|
34630
34711
|
sendJson(sock, { type: "hello", features: ["nack"] });
|
|
34631
34712
|
return;
|
|
34632
34713
|
}
|
|
@@ -35,6 +35,28 @@ export interface LocalReport {
|
|
|
35
35
|
has_search_key: boolean;
|
|
36
36
|
has_llm_key: boolean;
|
|
37
37
|
legacy_memory: LegacyMemoryReport[];
|
|
38
|
+
/** §B.4 counts; undefined when the runtime reported none or the block was malformed. */
|
|
39
|
+
secret_paths?: SecretPathsReport;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* §B.4: how many server-named secret paths in the managed config are inside
|
|
43
|
+
* the secrets directory, owner-allowlisted, or outside it, and the runtime's
|
|
44
|
+
* mode. Rebuilt field by field from the runtime's file: numbers and one enum,
|
|
45
|
+
* so no path (the owner's `allowed_secret_paths` sits in the same file) can
|
|
46
|
+
* ride along to the backend.
|
|
47
|
+
*/
|
|
48
|
+
export interface SecretPathsReport {
|
|
49
|
+
mode: "report" | "enforce";
|
|
50
|
+
inside: number;
|
|
51
|
+
allowlisted: number;
|
|
52
|
+
outside: number;
|
|
53
|
+
/** `sqlite` (H1) is absent from a runtime that predates it; absent means unknown, not zero. */
|
|
54
|
+
outside_by_field: {
|
|
55
|
+
headers_file: number;
|
|
56
|
+
postgres_url_file: number;
|
|
57
|
+
search_key_file: number;
|
|
58
|
+
sqlite?: number;
|
|
59
|
+
};
|
|
38
60
|
}
|
|
39
61
|
export type LegacyMemoryKind = "global-memory" | "conversation-history" | "captured-memory";
|
|
40
62
|
export declare const LEGACY_MEMORY_FACT_MAX_BYTES = 1024;
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const BRIDGE_VERSION = "0.26.
|
|
1
|
+
export declare const BRIDGE_VERSION = "0.26.5";
|
package/dist/ws-server.d.ts
CHANGED
|
@@ -1,80 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Agent-facing keepalive WebSocket (/v1/ws).
|
|
3
|
-
*
|
|
4
|
-
* Adds a single persistent bidirectional socket for the AGENT⇄DAEMON hop,
|
|
5
|
-
* modeled on AgentVault's keepalive WS (token in `?token=` query OR
|
|
6
|
-
* `Authorization: Bearer`). This replaces the curl/long-poll loop: the agent
|
|
7
|
-
* receives live inbound messages as JSON frames and sends/reports status over
|
|
8
|
-
* the same socket.
|
|
9
|
-
*
|
|
10
|
-
* Protocol (all frames are JSON text):
|
|
11
|
-
* Daemon → agent:
|
|
12
|
-
* { "type":"message", channel_id, message_id, sender_id, text, ts,
|
|
13
|
-
* authorization:{version,audience,sender_membership,tool_grants} } // inbound, decrypted
|
|
14
|
-
* { "type":"sent", message_id } // ack of a send
|
|
15
|
-
* { "type":"ok" } // ack of a status report
|
|
16
|
-
* { "type":"error", error, message, detail? } // canonical codes (see errors.ts):
|
|
17
|
-
* // channel_pending | rate_limited |
|
|
18
|
-
* // invalid_request | agent_paused |
|
|
19
|
-
* // send_forbidden (those two are
|
|
20
|
-
* // TERMINAL: the in_reply_to message
|
|
21
|
-
* // is acked by the daemon, no retry) |
|
|
22
|
-
* // group_superseded (NOT terminal:
|
|
23
|
-
* // the reply is republished after
|
|
24
|
-
* // the agent rejoins)
|
|
25
|
-
* Agent → daemon:
|
|
26
|
-
* { "type":"send", channel_id, text?, a2a?, idempotency_key?,
|
|
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
|
|
32
|
-
* { "type":"status", channel_id, state, detail? }
|
|
33
|
-
* { "type":"ack", message_id } // confirms a delivered `message` was consumed
|
|
34
|
-
* { "type":"nack", message_id, reason? } // final-wave I1(b): THIS message's turn
|
|
35
|
-
* // failed — requeued behind a backoff; the
|
|
36
|
-
* // socket and other channels' turns stay up
|
|
37
|
-
* { "type":"hello", features? } // → { "type":"hello", features:["nack"] }; a
|
|
38
|
-
* // runtime nacks only after seeing "nack" here
|
|
39
|
-
* { "type":"action_begin", channel_id } // → { "type":"action_begun", run_id }
|
|
40
|
-
* { "type":"action_start", run_id, node_type, title, parent_id? } // → { "type":"action_started", node_id }
|
|
41
|
-
* { "type":"action_update", run_id, node_id, status?, detail? } // → { "type":"ok" }
|
|
42
|
-
* { "type":"action_finish", run_id, node_id, status, detail? } // → { "type":"ok" }
|
|
43
|
-
* { "type":"action_settle", run_id, result_message_id? } // → { "type":"action_settled", run_id }
|
|
44
|
-
*
|
|
45
|
-
* Inbound delivery is at-least-once. A delivered `message` is held in-flight
|
|
46
|
-
* until the agent sends a matching `ack`; if the socket drops first, the frame
|
|
47
|
-
* is re-buffered and redelivered to the next consumer. The agent dedups by
|
|
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.
|
|
51
|
-
*
|
|
52
|
-
* Bounded work (Phase 3):
|
|
53
|
-
* - F2 ordering: at most ONE message per channel is in flight on a socket;
|
|
54
|
-
* the next message for that channel is sent only after the previous one is
|
|
55
|
-
* acked (or retired). A backlog replays in order instead of flooding the
|
|
56
|
-
* runtime, which used to answer the overflow with a "busy" reply and ack it.
|
|
57
|
-
* - F1 attempt cap: every delivery is counted durably; a message that
|
|
58
|
-
* reaches the cap is dead-lettered (content-free log + ack + one
|
|
59
|
-
* content-free notice to the channel per hour) rather than redelivered
|
|
60
|
-
* forever, and a channel where the agent is paused is not delivered at
|
|
61
|
-
* all (no model call).
|
|
62
|
-
* - Final-wave I1: a FAILED delivery (a `nack`, or the socket closing with
|
|
63
|
-
* it in flight — older runtimes close on any failed turn) is requeued
|
|
64
|
-
* behind a 2^attempts × 5 s backoff. A nack fails only its own message.
|
|
65
|
-
* Messages never handed over (mid-flush close, a failed frame write) go
|
|
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.
|
|
70
|
-
*
|
|
71
|
-
* Single consumer: the daemon serves one agent per process. A new `/v1/ws`
|
|
72
|
-
* connection replaces the prior one (newest wins, mirroring the SSE path); the
|
|
73
|
-
* replaced socket's un-acked in-flight messages are re-buffered.
|
|
74
|
-
*
|
|
75
|
-
* Keepalive uses the ws library's server-side ping (the protocol-level ping
|
|
76
|
-
* frame, not an application message); dead sockets are reaped via pong tracking.
|
|
77
|
-
*/
|
|
78
1
|
import type { Server as HttpServer } from "node:http";
|
|
79
2
|
import type { BridgeAgent } from "./bridge-agent.js";
|
|
80
3
|
export interface AgentWebSocketOptions {
|