@securecoms/agent-bridge 0.26.4 → 0.26.6
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 +24 -0
- package/connectors/hermes/adapter.py +202 -3
- package/connectors/hermes/securecoms_authz.py +202 -0
- package/connectors/hermes/tests/test_adapter_wiring.py +481 -3
- package/connectors/hermes/tests/test_securecoms_authz.py +260 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +19 -4
- package/connectors/openclaw/openclaw-entry.js +192 -3
- package/connectors/openclaw/openclaw-entry.status.test.ts +11 -1
- package/connectors/openclaw/openclaw-entry.toolgate.test.ts +477 -0
- package/dist/bin.js +401 -75
- package/dist/bridge-agent.d.ts +157 -4
- package/dist/consumer-grants.d.ts +22 -0
- package/dist/harness-report.d.ts +14 -0
- package/dist/http-server.d.ts +10 -0
- package/dist/index.js +401 -75
- package/dist/room-tools.d.ts +23 -0
- 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/bin.js
CHANGED
|
@@ -28790,7 +28790,7 @@ function assertManagedName(name) {
|
|
|
28790
28790
|
}
|
|
28791
28791
|
function isLocalOnlyName(name) {
|
|
28792
28792
|
const n = name.toLowerCase().replace(/[. ]+$/, "");
|
|
28793
|
-
return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json";
|
|
28793
|
+
return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json" || n === "secrets";
|
|
28794
28794
|
}
|
|
28795
28795
|
function atomicWrite(path, body, deps) {
|
|
28796
28796
|
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
@@ -28849,6 +28849,26 @@ function lineHash(line) {
|
|
|
28849
28849
|
return createHash2("sha256").update(line.trim(), "utf8").digest("hex");
|
|
28850
28850
|
}
|
|
28851
28851
|
var LOCAL_REPORT_FILE = "local-config.json";
|
|
28852
|
+
var SECRET_PATH_COUNT_MAX = 1e4;
|
|
28853
|
+
var isCount = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0 && v <= SECRET_PATH_COUNT_MAX;
|
|
28854
|
+
function parseSecretPathsReport(raw) {
|
|
28855
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
28856
|
+
const r = raw;
|
|
28857
|
+
const f = r.outside_by_field;
|
|
28858
|
+
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;
|
|
28859
|
+
return {
|
|
28860
|
+
mode: r.mode,
|
|
28861
|
+
inside: r.inside,
|
|
28862
|
+
allowlisted: r.allowlisted,
|
|
28863
|
+
outside: r.outside,
|
|
28864
|
+
outside_by_field: {
|
|
28865
|
+
headers_file: f.headers_file,
|
|
28866
|
+
postgres_url_file: f.postgres_url_file,
|
|
28867
|
+
search_key_file: f.search_key_file,
|
|
28868
|
+
...f.sqlite !== void 0 ? { sqlite: f.sqlite } : {}
|
|
28869
|
+
}
|
|
28870
|
+
};
|
|
28871
|
+
}
|
|
28852
28872
|
var LEGACY_MEMORY_SOURCE_MAX_BYTES = 4 * 1024 * 1024;
|
|
28853
28873
|
var LEGACY_MEMORY_REPORT_MAX_BYTES = 4 * 1024 * 1024;
|
|
28854
28874
|
var LEGACY_MEMORY_REPORT_MAX_SOURCES = 100;
|
|
@@ -28917,7 +28937,8 @@ function readLocalReport(home, identityId) {
|
|
|
28917
28937
|
persona,
|
|
28918
28938
|
has_search_key: hasSearchKey,
|
|
28919
28939
|
has_llm_key: hasLlmKey,
|
|
28920
|
-
legacy_memory: localLegacySources(doc).map(({ path: _path, ...source }) => source)
|
|
28940
|
+
legacy_memory: localLegacySources(doc).map(({ path: _path, ...source }) => source),
|
|
28941
|
+
secret_paths: parseSecretPathsReport(doc.secret_paths)
|
|
28921
28942
|
};
|
|
28922
28943
|
}
|
|
28923
28944
|
function deleteLegacyMemorySources(home, identityId, ids) {
|
|
@@ -29091,6 +29112,29 @@ var StatusGate = class {
|
|
|
29091
29112
|
}
|
|
29092
29113
|
};
|
|
29093
29114
|
|
|
29115
|
+
// src/room-tools.ts
|
|
29116
|
+
var TOOL_ROOM_CLASSES = Object.freeze({
|
|
29117
|
+
current_time: Object.freeze(["room_safe_read"]),
|
|
29118
|
+
echo: Object.freeze(["room_safe_read"]),
|
|
29119
|
+
web_search: Object.freeze(["owner_credential"]),
|
|
29120
|
+
fetch_url: Object.freeze(["egress"]),
|
|
29121
|
+
sql_query: Object.freeze(["owner_credential"]),
|
|
29122
|
+
http_request: Object.freeze(["owner_credential", "egress", "side_effect"])
|
|
29123
|
+
});
|
|
29124
|
+
var LIVE_MEMBER_STATUSES = /* @__PURE__ */ new Set(["active", "add_pending"]);
|
|
29125
|
+
function isLiveRow(r) {
|
|
29126
|
+
return (r.status === void 0 || LIVE_MEMBER_STATUSES.has(r.status)) && r.left_but_in_group !== true;
|
|
29127
|
+
}
|
|
29128
|
+
function roomIsCrossOrg(rows) {
|
|
29129
|
+
if (rows.length === 0) return null;
|
|
29130
|
+
let unknown = false;
|
|
29131
|
+
for (const r of rows) {
|
|
29132
|
+
if (r.is_same_org_as_agent_owner === false) return true;
|
|
29133
|
+
if (r.is_same_org_as_agent_owner !== true) unknown = true;
|
|
29134
|
+
}
|
|
29135
|
+
return unknown ? null : false;
|
|
29136
|
+
}
|
|
29137
|
+
|
|
29094
29138
|
// src/bridge-agent.ts
|
|
29095
29139
|
function logSendSupersededAdoption(kind) {
|
|
29096
29140
|
switch (kind) {
|
|
@@ -29139,11 +29183,18 @@ var OWNER_DM_DEFAULT_READ_GRANTS = Object.freeze([
|
|
|
29139
29183
|
Object.freeze({ tool: "current_time", mode: "read" }),
|
|
29140
29184
|
Object.freeze({ tool: "web_search", mode: "read" })
|
|
29141
29185
|
]);
|
|
29186
|
+
var ROOM_DEFAULT_READ_GRANTS = Object.freeze([
|
|
29187
|
+
Object.freeze({ tool: "current_time", mode: "read" })
|
|
29188
|
+
]);
|
|
29142
29189
|
var NO_TOOL_GRANTS = Object.freeze([]);
|
|
29143
29190
|
function ownerDmDefaultGrantsEnabled(env = process.env) {
|
|
29144
29191
|
const v = env.SECURECOMS_OWNER_DM_DEFAULT_GRANTS;
|
|
29145
29192
|
return v === void 0 || v === "on";
|
|
29146
29193
|
}
|
|
29194
|
+
function roomDefaultGrantsEnabled(env = process.env) {
|
|
29195
|
+
const v = env.SECURECOMS_ROOM_DEFAULT_GRANTS;
|
|
29196
|
+
return v === void 0 || v === "on";
|
|
29197
|
+
}
|
|
29147
29198
|
var VAULT_REF_BARE_TEXT = "[Files link]";
|
|
29148
29199
|
var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
|
|
29149
29200
|
var FAST_PATH_SPOKEN_GUIDANCE = "A one-sentence spoken acknowledgement has already been sent for this turn. Reply only with information it did not contain, in one or two short sentences; if there is nothing to add, send nothing.";
|
|
@@ -29322,6 +29373,19 @@ var SendForbiddenError = class extends Error {
|
|
|
29322
29373
|
this.name = "SendForbiddenError";
|
|
29323
29374
|
}
|
|
29324
29375
|
};
|
|
29376
|
+
var RoomClassChangedSendError = class extends SendForbiddenError {
|
|
29377
|
+
constructor(channelId) {
|
|
29378
|
+
super(channelId);
|
|
29379
|
+
this.name = "RoomClassChangedSendError";
|
|
29380
|
+
this.message = `reply dropped for channel ${channelId}: the room changed after its tools were granted; no retry`;
|
|
29381
|
+
}
|
|
29382
|
+
};
|
|
29383
|
+
function rosterFreshAt(entry, heldEpoch, heldGroupId) {
|
|
29384
|
+
return entry !== void 0 && typeof heldEpoch === "number" && entry.epoch === heldEpoch && typeof heldGroupId === "string" && entry.groupId === heldGroupId;
|
|
29385
|
+
}
|
|
29386
|
+
var RoomGrantEpochMovedError = class extends Error {
|
|
29387
|
+
};
|
|
29388
|
+
var ROOM_GRANT_TRACK_MAX = 4096;
|
|
29325
29389
|
var GroupSupersededSendError = class extends Error {
|
|
29326
29390
|
constructor(channelId, activeGroupId) {
|
|
29327
29391
|
super(`send refused for channel ${channelId}: the channel moved to a new secure group; not sent`);
|
|
@@ -29812,6 +29876,21 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
29812
29876
|
selfHealSweepEnabled = process.env.SECURECOMS_SELF_HEAL_SWEEP !== "false";
|
|
29813
29877
|
/** Read once at startup; see ownerDmDefaultGrantsEnabled. */
|
|
29814
29878
|
ownerDmDefaultGrants = ownerDmDefaultGrantsEnabled();
|
|
29879
|
+
/** Read once at startup; see roomDefaultGrantsEnabled. */
|
|
29880
|
+
roomDefaultGrants = roomDefaultGrantsEnabled();
|
|
29881
|
+
/**
|
|
29882
|
+
* Audit M-B4: DM channels whose roster was epoch-fresh at THIS ingest
|
|
29883
|
+
* cycle's refresh (refreshRostersForCycle). Cleared at each cycle's start.
|
|
29884
|
+
*/
|
|
29885
|
+
ownerDmCycleFresh = /* @__PURE__ */ new Set();
|
|
29886
|
+
/**
|
|
29887
|
+
* Audit M-B1: room-granted turns, keyed by the inbound message id, with the
|
|
29888
|
+
* channel, MLS group and epoch the room was classified at (review I-1: an
|
|
29889
|
+
* epoch NUMBER alone repeats across channels and across a supersede). A
|
|
29890
|
+
* reply to one is sealed only there, or re-classified once
|
|
29891
|
+
* (publishBodyForReply). Forgotten at the runtime's ack.
|
|
29892
|
+
*/
|
|
29893
|
+
roomGrantEpochs = /* @__PURE__ */ new Map();
|
|
29815
29894
|
/** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
|
|
29816
29895
|
static SELF_HEAL_SWEEP_INTERVAL_MS = SELF_HEAL_SWEEP_INTERVAL_MS;
|
|
29817
29896
|
/**
|
|
@@ -30115,7 +30194,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
30115
30194
|
// Inert legacy files only. `readLocalReport` revalidates the current
|
|
30116
30195
|
// regular-file bytes and strips every local path before this leaves the
|
|
30117
30196
|
// machine.
|
|
30118
|
-
legacy_memory: local?.legacy_memory ?? []
|
|
30197
|
+
legacy_memory: local?.legacy_memory ?? [],
|
|
30198
|
+
// §B.4: counts of server-named secret paths inside / allowlisted /
|
|
30199
|
+
// outside the secrets directory, and the runtime's mode. Numbers and
|
|
30200
|
+
// one enum only; readLocalReport rebuilds it so no path rides along.
|
|
30201
|
+
secret_paths: local?.secret_paths ?? null
|
|
30119
30202
|
};
|
|
30120
30203
|
const posted = await this.fetchBounded("runtime_config_report", "control", `${base}/report`, {
|
|
30121
30204
|
method: "POST",
|
|
@@ -31119,7 +31202,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
31119
31202
|
* for every envelope built, BEFORE it is published (SEC-06 #18 persists it
|
|
31120
31203
|
* there so a retry can resend the exact bytes).
|
|
31121
31204
|
*/
|
|
31122
|
-
async publishBody(channelId, body, id, onEnvelope) {
|
|
31205
|
+
async publishBody(channelId, body, id, onEnvelope, sealCheck) {
|
|
31123
31206
|
this.enforceBodyRateFloor(channelId, body);
|
|
31124
31207
|
this.statusGate.replyStarted(channelId);
|
|
31125
31208
|
const joined = await this.reconcileJoin(channelId);
|
|
@@ -31128,33 +31211,36 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
31128
31211
|
await this.sendWithSelfHealTerminal(
|
|
31129
31212
|
this.provider,
|
|
31130
31213
|
channelId,
|
|
31131
|
-
(epoch) =>
|
|
31132
|
-
|
|
31133
|
-
|
|
31134
|
-
|
|
31135
|
-
|
|
31136
|
-
|
|
31137
|
-
|
|
31138
|
-
|
|
31139
|
-
|
|
31140
|
-
|
|
31141
|
-
|
|
31142
|
-
|
|
31143
|
-
type: "a2a",
|
|
31144
|
-
|
|
31145
|
-
|
|
31146
|
-
|
|
31147
|
-
|
|
31148
|
-
|
|
31149
|
-
|
|
31150
|
-
|
|
31151
|
-
|
|
31152
|
-
|
|
31153
|
-
|
|
31154
|
-
|
|
31155
|
-
|
|
31156
|
-
|
|
31157
|
-
|
|
31214
|
+
(epoch) => {
|
|
31215
|
+
sealCheck?.(epoch);
|
|
31216
|
+
return this.latency.time(
|
|
31217
|
+
"send.encrypt",
|
|
31218
|
+
() => buildOutboundEnvelope({
|
|
31219
|
+
custody: this.custody,
|
|
31220
|
+
groupKey: this.provider,
|
|
31221
|
+
messageId: id,
|
|
31222
|
+
channelId,
|
|
31223
|
+
enclaveId: this.resolveEnclave(channelId),
|
|
31224
|
+
senderId: this.cfg.identityId,
|
|
31225
|
+
senderType: "agent",
|
|
31226
|
+
type: body.kind === "text" ? "text" : "a2a",
|
|
31227
|
+
bodyPlaintext: body.kind === "text" ? JSON.stringify({ text: body.text }) : JSON.stringify({
|
|
31228
|
+
type: "a2a",
|
|
31229
|
+
a2a_version: body.a2a.a2a_version,
|
|
31230
|
+
kind: body.a2a.kind,
|
|
31231
|
+
object: body.a2a.object
|
|
31232
|
+
}),
|
|
31233
|
+
bindingId: "",
|
|
31234
|
+
bindingVersion: "0.0.0",
|
|
31235
|
+
agentSkillId: null,
|
|
31236
|
+
epoch,
|
|
31237
|
+
traceparent: ZERO_TRACEPARENT,
|
|
31238
|
+
references: [],
|
|
31239
|
+
stage5Outcome: "allow",
|
|
31240
|
+
stage5Evaluators: []
|
|
31241
|
+
})
|
|
31242
|
+
);
|
|
31243
|
+
},
|
|
31158
31244
|
async (envelope) => {
|
|
31159
31245
|
if (onEnvelope) await onEnvelope(envelope);
|
|
31160
31246
|
return this.publishEnvelope(
|
|
@@ -31165,6 +31251,71 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
31165
31251
|
);
|
|
31166
31252
|
this.latency.record("send.total", performance.now() - sendStart);
|
|
31167
31253
|
}
|
|
31254
|
+
/**
|
|
31255
|
+
* Audit M-B1 (controller ruling): a reply to a room-granted turn is sealed
|
|
31256
|
+
* only at the epoch the room was classified at. On a move, the room is
|
|
31257
|
+
* re-classified ONCE from a roster fetched at the new epoch: still internal,
|
|
31258
|
+
* the reply is sealed at that epoch; anything else, it is dropped. The check
|
|
31259
|
+
* throws before an envelope is built, so nothing is stored or posted.
|
|
31260
|
+
*
|
|
31261
|
+
* `inReplyTo` is the STORED intent's inbound id, not the send's options, so
|
|
31262
|
+
* the redelivery replay (replayReplyIntents, which has no in_reply_to in its
|
|
31263
|
+
* options) is checked the same as a live send.
|
|
31264
|
+
*/
|
|
31265
|
+
async publishBodyForReply(channelId, body, id, inReplyTo, onEnvelope) {
|
|
31266
|
+
const grant = inReplyTo !== null ? this.roomGrantEpochs.get(inReplyTo) : void 0;
|
|
31267
|
+
if (grant === void 0) return this.publishBody(channelId, body, id, onEnvelope);
|
|
31268
|
+
if (channelId !== grant.channelId) {
|
|
31269
|
+
writeServiceLog("warn", "reply.room_class_changed");
|
|
31270
|
+
throw new RoomClassChangedSendError(channelId);
|
|
31271
|
+
}
|
|
31272
|
+
let reclassified = false;
|
|
31273
|
+
for (; ; ) {
|
|
31274
|
+
try {
|
|
31275
|
+
return await this.publishBody(
|
|
31276
|
+
channelId,
|
|
31277
|
+
body,
|
|
31278
|
+
id,
|
|
31279
|
+
// Review I-1: the group the envelope was ACTUALLY sealed under
|
|
31280
|
+
// (captured with the key, inside the channel lock), checked before
|
|
31281
|
+
// the durable save and before the post. An equal epoch number in a
|
|
31282
|
+
// new group (the supersede replay) is a move.
|
|
31283
|
+
async (envelope) => {
|
|
31284
|
+
if (sealedGroupIdOf(envelope) !== grant.groupId) throw new RoomGrantEpochMovedError();
|
|
31285
|
+
if (onEnvelope) await onEnvelope(envelope);
|
|
31286
|
+
},
|
|
31287
|
+
(epoch) => {
|
|
31288
|
+
if (epoch !== grant.epoch) throw new RoomGrantEpochMovedError();
|
|
31289
|
+
}
|
|
31290
|
+
);
|
|
31291
|
+
} catch (e) {
|
|
31292
|
+
if (!(e instanceof RoomGrantEpochMovedError)) throw e;
|
|
31293
|
+
if (reclassified || !await this.reclassifyForReply(channelId, grant)) {
|
|
31294
|
+
writeServiceLog("warn", "reply.room_class_changed");
|
|
31295
|
+
throw new RoomClassChangedSendError(channelId);
|
|
31296
|
+
}
|
|
31297
|
+
reclassified = true;
|
|
31298
|
+
}
|
|
31299
|
+
}
|
|
31300
|
+
}
|
|
31301
|
+
/**
|
|
31302
|
+
* Re-read the room at the agent's current group and epoch; true only if it
|
|
31303
|
+
* is still internal there. Review I-1: the entry must come from THIS re-read
|
|
31304
|
+
* (a fail-soft refresh leaves an older entry whose epoch label can repeat
|
|
31305
|
+
* across a supersede), and the group must not move around the reads.
|
|
31306
|
+
*/
|
|
31307
|
+
async reclassifyForReply(channelId, grant) {
|
|
31308
|
+
const startedAt = Date.now();
|
|
31309
|
+
await this.refreshChannelRoster(channelId);
|
|
31310
|
+
const held = await this.heldPosition(channelId);
|
|
31311
|
+
const hit = this.freshRosterEntry(channelId);
|
|
31312
|
+
if (hit === void 0 || hit.at < startedAt || !rosterFreshAt(hit, held.epoch, held.groupId) || hit.crossOrg !== false || held.epoch === null || held.groupId === null) {
|
|
31313
|
+
return false;
|
|
31314
|
+
}
|
|
31315
|
+
grant.groupId = held.groupId;
|
|
31316
|
+
grant.epoch = held.epoch;
|
|
31317
|
+
return true;
|
|
31318
|
+
}
|
|
31168
31319
|
enforceBodyRateFloor(channelId, body) {
|
|
31169
31320
|
if (body.kind === "a2a") {
|
|
31170
31321
|
this.enforceRateFloor("a2a", channelId);
|
|
@@ -31290,7 +31441,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
31290
31441
|
const bytes = intent.body;
|
|
31291
31442
|
const body = decodeReplyBody(bytes);
|
|
31292
31443
|
try {
|
|
31293
|
-
await this.publishIntentWithRetry(intent.channelId, body, intent.replyMessageId, key);
|
|
31444
|
+
await this.publishIntentWithRetry(intent.channelId, body, intent.replyMessageId, key, intent.inReplyTo);
|
|
31294
31445
|
} catch (e) {
|
|
31295
31446
|
if (e instanceof AgentPausedSendError || e instanceof SendForbiddenError) {
|
|
31296
31447
|
if (intent.inReplyTo !== null) this.store.deletePendingReplyIntents(intent.inReplyTo);
|
|
@@ -31345,12 +31496,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
31345
31496
|
* failure. Either way the intent stays pending, so the redelivery
|
|
31346
31497
|
* republishes the same reply.
|
|
31347
31498
|
*/
|
|
31348
|
-
async publishIntentWithRetry(channelId, body, id, key) {
|
|
31499
|
+
async publishIntentWithRetry(channelId, body, id, key, inReplyTo) {
|
|
31349
31500
|
let rateLimited = 0;
|
|
31350
31501
|
let inFlight = 0;
|
|
31351
31502
|
for (; ; ) {
|
|
31352
31503
|
try {
|
|
31353
|
-
await this.publishIntentOnce(channelId, body, id, key);
|
|
31504
|
+
await this.publishIntentOnce(channelId, body, id, key, inReplyTo);
|
|
31354
31505
|
return;
|
|
31355
31506
|
} catch (e) {
|
|
31356
31507
|
if (e instanceof ReplyInFlightError) {
|
|
@@ -31421,7 +31572,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
31421
31572
|
* original ack; a conflict can only mean the slot belongs to another
|
|
31422
31573
|
* message (for example an old reply whose intent was pruned).
|
|
31423
31574
|
*/
|
|
31424
|
-
async publishIntentOnce(channelId, body, id, key) {
|
|
31575
|
+
async publishIntentOnce(channelId, body, id, key, inReplyTo) {
|
|
31425
31576
|
const current = await this.store.getReplyIntent(key.scope, key.part);
|
|
31426
31577
|
try {
|
|
31427
31578
|
if (current?.envelope) {
|
|
@@ -31462,10 +31613,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
31462
31613
|
}
|
|
31463
31614
|
}
|
|
31464
31615
|
try {
|
|
31465
|
-
await this.
|
|
31616
|
+
await this.publishBodyForReply(
|
|
31466
31617
|
channelId,
|
|
31467
31618
|
body,
|
|
31468
31619
|
id,
|
|
31620
|
+
inReplyTo,
|
|
31469
31621
|
(envelope) => this.store.saveReplyIntentEnvelope(
|
|
31470
31622
|
key.scope,
|
|
31471
31623
|
key.part,
|
|
@@ -32296,10 +32448,33 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32296
32448
|
}
|
|
32297
32449
|
/** A live roster for a channel, or undefined if missing OR expired past the TTL. */
|
|
32298
32450
|
freshRoster(channelId) {
|
|
32451
|
+
return this.freshRosterEntry(channelId)?.members;
|
|
32452
|
+
}
|
|
32453
|
+
/** The whole cache entry when fresh, else undefined (TTL; the epoch is checked by the caller). */
|
|
32454
|
+
freshRosterEntry(channelId) {
|
|
32299
32455
|
const hit = this.channelRoster.get(channelId);
|
|
32300
|
-
if (!hit) return void 0;
|
|
32301
|
-
|
|
32302
|
-
|
|
32456
|
+
if (!hit || Date.now() - hit.at >= CHANNEL_ROSTER_TTL_MS) return void 0;
|
|
32457
|
+
return hit;
|
|
32458
|
+
}
|
|
32459
|
+
/** The agent's held MLS group id for a channel; null when none (fail closed). */
|
|
32460
|
+
async heldGroupFor(channelId) {
|
|
32461
|
+
return this.provider.heldGroupIdFor(channelId).catch(() => null);
|
|
32462
|
+
}
|
|
32463
|
+
/**
|
|
32464
|
+
* Review I-1 + fix round 2: the agent's held (group, epoch) for a channel.
|
|
32465
|
+
* The group is read on BOTH sides of the epoch read, so the epoch provably
|
|
32466
|
+
* belongs to it (a supersede in between installs a new group whose epoch
|
|
32467
|
+
* counter restarts); a group that moved, or none held, is null (fail closed).
|
|
32468
|
+
*/
|
|
32469
|
+
async heldPosition(channelId) {
|
|
32470
|
+
const before = await this.heldGroupFor(channelId);
|
|
32471
|
+
const epoch = await this.heldEpochFor(channelId);
|
|
32472
|
+
const after = await this.heldGroupFor(channelId);
|
|
32473
|
+
return { groupId: before !== null && after === before ? before : null, epoch };
|
|
32474
|
+
}
|
|
32475
|
+
/** The agent's own MLS epoch for a channel; null when no group is held (fail closed). */
|
|
32476
|
+
async heldEpochFor(channelId) {
|
|
32477
|
+
return this.provider.currentEpoch(channelId).catch(() => null);
|
|
32303
32478
|
}
|
|
32304
32479
|
/**
|
|
32305
32480
|
* Resolve one channel's roster (members + display names + is-agent) from the
|
|
@@ -32310,16 +32485,18 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32310
32485
|
*/
|
|
32311
32486
|
async refreshChannelRoster(channelId) {
|
|
32312
32487
|
try {
|
|
32488
|
+
const { epoch, groupId } = await this.heldPosition(channelId);
|
|
32313
32489
|
const url = `${this.cfg.apiBase}/api/v1/channels/${encodeURIComponent(
|
|
32314
32490
|
channelId
|
|
32315
|
-
)}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
|
|
32491
|
+
)}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}&include_pending=true`;
|
|
32316
32492
|
const res = await this.fetchBounded("channel_roster", "read", url, {
|
|
32317
32493
|
headers: { authorization: `Bearer ${this.cfg.apiKey}` }
|
|
32318
32494
|
});
|
|
32319
32495
|
if (!res.ok) return;
|
|
32320
32496
|
const rows = await res.json();
|
|
32321
32497
|
const members = /* @__PURE__ */ new Map();
|
|
32322
|
-
|
|
32498
|
+
const liveRows = rows.filter(isLiveRow);
|
|
32499
|
+
for (const r of liveRows) {
|
|
32323
32500
|
members.set(r.identity_id, {
|
|
32324
32501
|
name: r.display_name,
|
|
32325
32502
|
is_agent: r.is_agent,
|
|
@@ -32328,13 +32505,45 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32328
32505
|
is_relay: r.is_relay === true
|
|
32329
32506
|
});
|
|
32330
32507
|
}
|
|
32331
|
-
this.channelRoster.set(channelId, { members, at: Date.now() });
|
|
32508
|
+
this.channelRoster.set(channelId, { members, at: Date.now(), epoch, groupId, crossOrg: roomIsCrossOrg(rows) });
|
|
32332
32509
|
const self = members.get(this.cfg.identityId);
|
|
32333
32510
|
if (self?.name) this.lastOwnDisplayName = self.name;
|
|
32334
|
-
await this.resolveAndPinOwner(channelId,
|
|
32511
|
+
await this.resolveAndPinOwner(channelId, liveRows);
|
|
32335
32512
|
} catch {
|
|
32336
32513
|
}
|
|
32337
32514
|
}
|
|
32515
|
+
/**
|
|
32516
|
+
* S-1 (roster) + §7A.5 + audit M-B4: once per ingest cycle, per distinct
|
|
32517
|
+
* channel, re-read the roster when the TTL lapsed OR the epoch moved since
|
|
32518
|
+
* the fetch, then record which DMs are epoch-fresh for the rest of this
|
|
32519
|
+
* cycle. Fail-soft inside refreshChannelRoster (never blocks ingest).
|
|
32520
|
+
*/
|
|
32521
|
+
async refreshRostersForCycle(channelIds) {
|
|
32522
|
+
this.ownerDmCycleFresh.clear();
|
|
32523
|
+
for (const channelId of new Set(channelIds)) {
|
|
32524
|
+
const held = await this.heldPosition(channelId);
|
|
32525
|
+
const hit = this.freshRosterEntry(channelId);
|
|
32526
|
+
if (hit === void 0 || hit.epoch !== held.epoch || hit.groupId !== held.groupId) {
|
|
32527
|
+
await this.refreshChannelRoster(channelId);
|
|
32528
|
+
}
|
|
32529
|
+
const now = this.freshRosterEntry(channelId);
|
|
32530
|
+
if (rosterFreshAt(now, held.epoch, held.groupId) && this.freshMeta(channelId)?.is_dm === true) {
|
|
32531
|
+
this.ownerDmCycleFresh.add(channelId);
|
|
32532
|
+
}
|
|
32533
|
+
}
|
|
32534
|
+
}
|
|
32535
|
+
/** Classify one inbound turn at the agent's CURRENT epoch for its channel. */
|
|
32536
|
+
async authorizeTurn(channelId, messageId, senderId, relaySpeakerId) {
|
|
32537
|
+
const held = await this.heldPosition(channelId);
|
|
32538
|
+
const auth = this.authorizationFor(channelId, senderId, relaySpeakerId, held.epoch, held.groupId);
|
|
32539
|
+
if (auth.tool_grants !== ROOM_DEFAULT_READ_GRANTS) return auth;
|
|
32540
|
+
if (this.roomGrantEpochs.size >= ROOM_GRANT_TRACK_MAX || held.epoch === null || held.groupId === null) {
|
|
32541
|
+
writeServiceLog("warn", "room_grant.untracked");
|
|
32542
|
+
return Object.freeze({ ...auth, tool_grants: NO_TOOL_GRANTS });
|
|
32543
|
+
}
|
|
32544
|
+
this.roomGrantEpochs.set(messageId, { channelId, groupId: held.groupId, epoch: held.epoch });
|
|
32545
|
+
return auth;
|
|
32546
|
+
}
|
|
32338
32547
|
/**
|
|
32339
32548
|
* Classify the sender only from server-authenticated channel/roster data.
|
|
32340
32549
|
* Missing or stale metadata is UNKNOWN with zero grants, never a permissive
|
|
@@ -32343,18 +32552,37 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32343
32552
|
*
|
|
32344
32553
|
* Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
|
|
32345
32554
|
* the kill switch is on, the channel is a DM, and EITHER
|
|
32346
|
-
* (a) audience is owner_dm AND the sender is the pinned
|
|
32347
|
-
* (`channelOwner`, never "anomalous")
|
|
32348
|
-
*
|
|
32555
|
+
* (a) audience is owner_dm AND the sender is the channel's pinned DM
|
|
32556
|
+
* counterpart (`channelOwner`, never "anomalous"). A server-marked
|
|
32557
|
+
* `is_agent_owner` on a member who is NOT the DM's single human
|
|
32558
|
+
* counterpart gets nothing; on the counterpart it is trusted (see
|
|
32559
|
+
* LIMIT below); OR
|
|
32349
32560
|
* (b) the sender is this channel's server-marked relay (`is_relay`) and the
|
|
32350
32561
|
* relayed `speaker_identity_id` (passed by the caller only for relay
|
|
32351
32562
|
* turns) is that same pinned owner, whose roster row is the agent's
|
|
32352
32563
|
* owner. A speaker claim from any non-relay sender gets nothing.
|
|
32564
|
+
* Both need the roster fetched at `heldEpoch` (the agent's current MLS
|
|
32565
|
+
* epoch for the channel), or the DM marked epoch-fresh at this ingest
|
|
32566
|
+
* cycle's refresh (audit M-B4).
|
|
32567
|
+
* PR R: ROOM_DEFAULT_READ_GRANTS only when the room kill switch is on, a
|
|
32568
|
+
* per-tool consumer is attached, the roster was fetched at `heldEpoch`
|
|
32569
|
+
* (no cycle carve-out), the room is INTERNAL (roomIsCrossOrg === false
|
|
32570
|
+
* over every row, leaf rows included) and the audience is team_room.
|
|
32353
32571
|
* Everything else gets the frozen empty list.
|
|
32572
|
+
*
|
|
32573
|
+
* LIMIT (harness gate review H2, 2026-10-05): "pinned structural owner"
|
|
32574
|
+
* is PER CHANNEL. resolveAndPinOwner pins the single human counterpart of
|
|
32575
|
+
* ANY DM, so in a DM with member Bob, Bob is that channel's pin. What
|
|
32576
|
+
* separates Bob from the real owner is only the server's `is_agent_owner`
|
|
32577
|
+
* flag (connect_channels.py: counterpart user == agent's creator). A
|
|
32578
|
+
* backend that sets that flag wrongly gives Bob the grants, and a harness
|
|
32579
|
+
* connector turns grants into its full toolset. Binding the owner identity
|
|
32580
|
+
* once, outside server control, is an open design follow-up.
|
|
32354
32581
|
*/
|
|
32355
|
-
authorizationFor(channelId, senderId, relaySpeakerId) {
|
|
32582
|
+
authorizationFor(channelId, senderId, relaySpeakerId, heldEpoch, heldGroupId) {
|
|
32356
32583
|
const meta = this.freshMeta(channelId);
|
|
32357
|
-
const
|
|
32584
|
+
const entry = this.freshRosterEntry(channelId);
|
|
32585
|
+
const roster = entry?.members;
|
|
32358
32586
|
const sender = roster?.get(senderId);
|
|
32359
32587
|
let audience = "unknown";
|
|
32360
32588
|
if (meta && sender) {
|
|
@@ -32365,7 +32593,10 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32365
32593
|
else audience = "team_room";
|
|
32366
32594
|
}
|
|
32367
32595
|
let tool_grants = NO_TOOL_GRANTS;
|
|
32368
|
-
|
|
32596
|
+
let relaySpeaker;
|
|
32597
|
+
const epochFresh = rosterFreshAt(entry, heldEpoch, heldGroupId);
|
|
32598
|
+
const ownerDmFresh = epochFresh || this.ownerDmCycleFresh.has(channelId);
|
|
32599
|
+
if (ownerDmFresh && this.ownerDmDefaultGrants && meta?.is_dm === true && sender) {
|
|
32369
32600
|
const pinned = this.channelOwner.get(channelId);
|
|
32370
32601
|
const ownerId = pinned && pinned !== "anomalous" ? pinned.identityId : void 0;
|
|
32371
32602
|
if (ownerId !== void 0) {
|
|
@@ -32373,14 +32604,20 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32373
32604
|
const owner = roster?.get(ownerId);
|
|
32374
32605
|
const ownerRelayed = relaySpeakerId !== void 0 && sender.is_relay && relaySpeakerId === ownerId && owner !== void 0 && !owner.is_agent && owner.is_agent_owner;
|
|
32375
32606
|
if (ownerDirect || ownerRelayed) tool_grants = OWNER_DM_DEFAULT_READ_GRANTS;
|
|
32607
|
+
if (ownerRelayed) relaySpeaker = "owner";
|
|
32376
32608
|
}
|
|
32377
32609
|
}
|
|
32378
|
-
this.
|
|
32610
|
+
if (this.roomDefaultGrants && this.perToolConsumer !== null && epochFresh && meta?.is_dm === false && audience === "team_room" && entry.crossOrg === false) {
|
|
32611
|
+
tool_grants = ROOM_DEFAULT_READ_GRANTS;
|
|
32612
|
+
}
|
|
32613
|
+
const roomClass = meta?.is_dm !== false ? "na" : entry?.crossOrg === false ? "internal" : entry?.crossOrg === true ? "cross_org" : "unknown";
|
|
32614
|
+
this.logAuthzDecision(channelId, senderId, relaySpeakerId, audience, tool_grants.length, roomClass, epochFresh);
|
|
32379
32615
|
return Object.freeze({
|
|
32380
32616
|
version: 1,
|
|
32381
32617
|
audience,
|
|
32382
32618
|
sender_membership: sender ? "active" : "unknown",
|
|
32383
|
-
tool_grants
|
|
32619
|
+
tool_grants,
|
|
32620
|
+
...relaySpeaker ? { relay_speaker: relaySpeaker } : {}
|
|
32384
32621
|
});
|
|
32385
32622
|
}
|
|
32386
32623
|
/**
|
|
@@ -32399,8 +32636,15 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32399
32636
|
* startup: `on` = default grants enabled
|
|
32400
32637
|
* meta_fresh / roster_fresh whether the channel meta / roster caches were
|
|
32401
32638
|
* fresh (either stale forces audience=unknown)
|
|
32639
|
+
* room_class the room's class from the server roster (`na`
|
|
32640
|
+
* for a DM): internal | cross_org | unknown
|
|
32641
|
+
* epoch_fresh the roster was fetched at the agent's current
|
|
32642
|
+
* MLS epoch for the channel
|
|
32643
|
+
* room_kill_switch SECURECOMS_ROOM_DEFAULT_GRANTS as read at startup
|
|
32644
|
+
* per_tool_consumer a consumer that declared per-tool grant
|
|
32645
|
+
* enforcement is attached
|
|
32402
32646
|
*/
|
|
32403
|
-
logAuthzDecision(channelId, senderId, relaySpeakerId, audience, grants) {
|
|
32647
|
+
logAuthzDecision(channelId, senderId, relaySpeakerId, audience, grants, roomClass, epochFresh) {
|
|
32404
32648
|
const meta = this.freshMeta(channelId);
|
|
32405
32649
|
const roster = this.freshRoster(channelId);
|
|
32406
32650
|
const sender = roster?.get(senderId);
|
|
@@ -32428,7 +32672,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32428
32672
|
is_dm: meta?.is_dm === true,
|
|
32429
32673
|
kill_switch: ServiceLogEnum.of(this.ownerDmDefaultGrants ? "on" : "off", ["on", "off"]),
|
|
32430
32674
|
meta_fresh: meta !== void 0,
|
|
32431
|
-
roster_fresh: roster !== void 0
|
|
32675
|
+
roster_fresh: roster !== void 0,
|
|
32676
|
+
room_class: ServiceLogEnum.of(roomClass, ["na", "internal", "cross_org", "unknown"]),
|
|
32677
|
+
epoch_fresh: epochFresh,
|
|
32678
|
+
room_kill_switch: ServiceLogEnum.of(this.roomDefaultGrants ? "on" : "off", ["on", "off"]),
|
|
32679
|
+
per_tool_consumer: this.perToolConsumer !== null
|
|
32432
32680
|
});
|
|
32433
32681
|
}
|
|
32434
32682
|
/**
|
|
@@ -33014,14 +33262,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
33014
33262
|
await this.refreshChannelMeta();
|
|
33015
33263
|
metaRefreshed = true;
|
|
33016
33264
|
}
|
|
33017
|
-
|
|
33018
|
-
for (const m of entries) {
|
|
33019
|
-
if (rosterRefreshed.has(m.channelId)) continue;
|
|
33020
|
-
rosterRefreshed.add(m.channelId);
|
|
33021
|
-
if (this.freshRoster(m.channelId) === void 0) {
|
|
33022
|
-
await this.refreshChannelRoster(m.channelId);
|
|
33023
|
-
}
|
|
33024
|
-
}
|
|
33265
|
+
await this.refreshRostersForCycle(entries.map((m) => m.channelId));
|
|
33025
33266
|
const relayMissRefreshed = /* @__PURE__ */ new Set();
|
|
33026
33267
|
const acked = [];
|
|
33027
33268
|
for (const m of entries) {
|
|
@@ -33104,7 +33345,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
33104
33345
|
// ignores vault_refs.
|
|
33105
33346
|
text: ref.text ? ref.text : VAULT_REF_BARE_TEXT,
|
|
33106
33347
|
ts: m.receivedAt,
|
|
33107
|
-
authorization: this.
|
|
33348
|
+
authorization: await this.authorizeTurn(m.channelId, m.messageId, m.envelope.sender_id),
|
|
33108
33349
|
sender_name: this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name,
|
|
33109
33350
|
sender_is_agent: senderIsAgent,
|
|
33110
33351
|
vault_refs: [toVaultRefEntry(ref)],
|
|
@@ -33160,7 +33401,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
33160
33401
|
sender_id: m.envelope.sender_id,
|
|
33161
33402
|
text: typeof parsed.text === "string" ? parsed.text : "",
|
|
33162
33403
|
ts: m.receivedAt,
|
|
33163
|
-
authorization: this.
|
|
33404
|
+
authorization: await this.authorizeTurn(m.channelId, m.messageId, m.envelope.sender_id),
|
|
33164
33405
|
sender_name: senderName,
|
|
33165
33406
|
sender_is_agent: senderIsAgent,
|
|
33166
33407
|
attachments: [attachment],
|
|
@@ -33220,8 +33461,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
33220
33461
|
const senderName = this.freshRoster(m.channelId)?.get(
|
|
33221
33462
|
m.envelope.sender_id
|
|
33222
33463
|
)?.name;
|
|
33223
|
-
const turnAuthorization = this.
|
|
33464
|
+
const turnAuthorization = await this.authorizeTurn(
|
|
33224
33465
|
m.channelId,
|
|
33466
|
+
m.messageId,
|
|
33225
33467
|
m.envelope.sender_id,
|
|
33226
33468
|
speaker?.identityId
|
|
33227
33469
|
);
|
|
@@ -33792,6 +34034,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
33792
34034
|
}
|
|
33793
34035
|
/** Complete the durable handoff only after the runtime explicitly acks. */
|
|
33794
34036
|
ackRuntimeMessages(messageIds) {
|
|
34037
|
+
for (const id of messageIds) this.roomGrantEpochs.delete(id);
|
|
33795
34038
|
for (const messageId of messageIds) {
|
|
33796
34039
|
this.store.ackRuntimeInbox(messageId);
|
|
33797
34040
|
this.runtimePendingIds.delete(messageId);
|
|
@@ -33896,6 +34139,49 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
33896
34139
|
noteSessionEvicted() {
|
|
33897
34140
|
this.evictions++;
|
|
33898
34141
|
}
|
|
34142
|
+
/** Harness tool gate H4: latest connector self-report (see harness-report.ts). */
|
|
34143
|
+
harness = null;
|
|
34144
|
+
/**
|
|
34145
|
+
* Record a connector's harness report (from its /v1/ws hello). WARN once
|
|
34146
|
+
* each time the attached harness turns to an inactive veto: such a harness
|
|
34147
|
+
* refuses every turn without a tool grant, so its rooms go quiet.
|
|
34148
|
+
*/
|
|
34149
|
+
recordHarnessReport(report) {
|
|
34150
|
+
const wasInactive = this.harness?.tool_veto === "inactive";
|
|
34151
|
+
this.harness = report;
|
|
34152
|
+
if (report.tool_veto === "inactive" && !wasInactive) {
|
|
34153
|
+
writeServiceLog("warn", "harness.tool_veto_inactive", {
|
|
34154
|
+
harness: ServiceLogEnum.of(report.name, ["hermes", "openclaw", "other"])
|
|
34155
|
+
});
|
|
34156
|
+
}
|
|
34157
|
+
}
|
|
34158
|
+
/** Forget `report` when the connector that sent it disconnects. A report
|
|
34159
|
+
* from a newer connector (a different object) is kept. */
|
|
34160
|
+
clearHarnessReport(report) {
|
|
34161
|
+
if (this.harness === report) this.harness = null;
|
|
34162
|
+
}
|
|
34163
|
+
/** The latest harness report, or null (GET /v1/status). */
|
|
34164
|
+
harnessReport() {
|
|
34165
|
+
return this.harness;
|
|
34166
|
+
}
|
|
34167
|
+
/**
|
|
34168
|
+
* Audit C-B1: the attached /v1/ws consumer that declared per-tool grant
|
|
34169
|
+
* enforcement (its hello carried "per_tool_grants" and no harness report),
|
|
34170
|
+
* or null. authorizationFor issues a room grant only while one is attached
|
|
34171
|
+
* (Task R2); every delivery path also strips room grants for any other
|
|
34172
|
+
* consumer (consumer-grants.ts). The token is the socket's session object.
|
|
34173
|
+
*/
|
|
34174
|
+
perToolConsumer = null;
|
|
34175
|
+
notePerToolConsumer(token) {
|
|
34176
|
+
this.perToolConsumer = token;
|
|
34177
|
+
}
|
|
34178
|
+
/** Forget `token` when its socket closes; a newer consumer's token is kept. */
|
|
34179
|
+
clearPerToolConsumer(token) {
|
|
34180
|
+
if (this.perToolConsumer === token) this.perToolConsumer = null;
|
|
34181
|
+
}
|
|
34182
|
+
perToolConsumerAttached() {
|
|
34183
|
+
return this.perToolConsumer !== null;
|
|
34184
|
+
}
|
|
33899
34185
|
health() {
|
|
33900
34186
|
let joined = 0;
|
|
33901
34187
|
for (const s of this.joinState.values()) if (s === "JOINED") joined++;
|
|
@@ -33965,6 +34251,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
33965
34251
|
delivery_pings: this.pingsReceived,
|
|
33966
34252
|
agent_sessions: this.authSessions,
|
|
33967
34253
|
evictions: this.evictions,
|
|
34254
|
+
harness_tool_veto_active: this.harness === null ? null : this.harness.tool_veto === "active",
|
|
33968
34255
|
uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
|
|
33969
34256
|
version: BRIDGE_VERSION,
|
|
33970
34257
|
...this.memory.stats?.() ?? {}
|
|
@@ -34140,6 +34427,16 @@ function wsErrorFrame(code, detail) {
|
|
|
34140
34427
|
return { type: "error", ...errorBody(code, detail) };
|
|
34141
34428
|
}
|
|
34142
34429
|
|
|
34430
|
+
// src/consumer-grants.ts
|
|
34431
|
+
var NO_GRANTS = Object.freeze([]);
|
|
34432
|
+
var PER_TOOL_GRANTS_FEATURE = "per_tool_grants";
|
|
34433
|
+
function withConsumerGrants(m, perToolConsumer) {
|
|
34434
|
+
if (perToolConsumer) return m;
|
|
34435
|
+
const a = m.authorization;
|
|
34436
|
+
if (!a || a.tool_grants.length === 0 || a.audience === "owner_dm" || a.relay_speaker === "owner") return m;
|
|
34437
|
+
return { ...m, authorization: Object.freeze({ ...a, tool_grants: NO_GRANTS }) };
|
|
34438
|
+
}
|
|
34439
|
+
|
|
34143
34440
|
// src/http-server.ts
|
|
34144
34441
|
function writeSse(res, msgs, onWriteError) {
|
|
34145
34442
|
for (let i = 0; i < msgs.length; i++) {
|
|
@@ -34159,6 +34456,12 @@ data: ${JSON.stringify(msgs[i])}
|
|
|
34159
34456
|
}
|
|
34160
34457
|
return [];
|
|
34161
34458
|
}
|
|
34459
|
+
function deliverSseBatch(res, batch, agent, onWriteError) {
|
|
34460
|
+
const left3 = writeSse(res, batch.map((m) => withConsumerGrants(m, false)), onWriteError);
|
|
34461
|
+
const leftIds = new Set(left3.map((message) => message.message_id));
|
|
34462
|
+
agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
|
|
34463
|
+
if (left3.length) agent.requeueFront(batch.filter((message) => leftIds.has(message.message_id)));
|
|
34464
|
+
}
|
|
34162
34465
|
function sseWriteFailureReporter() {
|
|
34163
34466
|
let warned = false;
|
|
34164
34467
|
return (error) => {
|
|
@@ -34389,6 +34692,9 @@ function createHttpServer(agent, opts) {
|
|
|
34389
34692
|
const r = await agent.vaultDelete(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id);
|
|
34390
34693
|
return json(res, 200, r);
|
|
34391
34694
|
}
|
|
34695
|
+
if (method === "GET" && path === "/v1/status") {
|
|
34696
|
+
return json(res, 200, { harness: agent.harnessReport() });
|
|
34697
|
+
}
|
|
34392
34698
|
if (method === "POST" && path === "/v1/status") {
|
|
34393
34699
|
const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
|
|
34394
34700
|
if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
|
|
@@ -34418,7 +34724,7 @@ function createHttpServer(agent, opts) {
|
|
|
34418
34724
|
if (method === "GET" && path === "/v1/messages") {
|
|
34419
34725
|
const channel = url.searchParams.get("channel_id") ?? void 0;
|
|
34420
34726
|
const wait = Math.max(0, Math.min(50, Number(url.searchParams.get("wait") ?? 0)));
|
|
34421
|
-
return json(res, 200, await agent.drain(channel, wait));
|
|
34727
|
+
return json(res, 200, (await agent.drain(channel, wait)).map((m) => withConsumerGrants(m, false)));
|
|
34422
34728
|
}
|
|
34423
34729
|
if (method === "GET" && path === "/v1/events") {
|
|
34424
34730
|
const channel = url.searchParams.get("channel_id") ?? void 0;
|
|
@@ -34430,13 +34736,7 @@ function createHttpServer(agent, opts) {
|
|
|
34430
34736
|
});
|
|
34431
34737
|
res.write(":connected\n\n");
|
|
34432
34738
|
const onWriteError = sseWriteFailureReporter();
|
|
34433
|
-
const drainToClient = () =>
|
|
34434
|
-
const batch = agent.takeBuffered(channel);
|
|
34435
|
-
const left3 = writeSse(res, batch, onWriteError);
|
|
34436
|
-
const leftIds = new Set(left3.map((message) => message.message_id));
|
|
34437
|
-
agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
|
|
34438
|
-
if (left3.length) agent.requeueFront(left3);
|
|
34439
|
-
};
|
|
34739
|
+
const drainToClient = () => deliverSseBatch(res, agent.takeBuffered(channel), agent, onWriteError);
|
|
34440
34740
|
const unsubscribe = agent.subscribe(drainToClient);
|
|
34441
34741
|
const keepalive = setInterval(() => {
|
|
34442
34742
|
try {
|
|
@@ -34478,6 +34778,17 @@ function createHttpServer(agent, opts) {
|
|
|
34478
34778
|
return { server, closeAllSseStreams: () => closeSse() };
|
|
34479
34779
|
}
|
|
34480
34780
|
|
|
34781
|
+
// src/harness-report.ts
|
|
34782
|
+
var VERSION_TOKEN = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,39}$/;
|
|
34783
|
+
function parseHarnessReport(raw) {
|
|
34784
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
34785
|
+
const r = raw;
|
|
34786
|
+
const name = r.name === "hermes" || r.name === "openclaw" ? r.name : "other";
|
|
34787
|
+
const version = typeof r.version === "string" && VERSION_TOKEN.test(r.version) ? r.version : null;
|
|
34788
|
+
const tool_veto = r.tool_veto === "active" || r.tool_veto === "inactive" ? r.tool_veto : "unknown";
|
|
34789
|
+
return Object.freeze({ name, version, tool_veto });
|
|
34790
|
+
}
|
|
34791
|
+
|
|
34481
34792
|
// src/ws-server.ts
|
|
34482
34793
|
import { WebSocketServer, WebSocket } from "ws";
|
|
34483
34794
|
var PING_INTERVAL_MS = 3e4;
|
|
@@ -34541,7 +34852,7 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
34541
34852
|
});
|
|
34542
34853
|
const inflight = /* @__PURE__ */ new Map();
|
|
34543
34854
|
const delivered = /* @__PURE__ */ new Map();
|
|
34544
|
-
const session = { consumer: "unknown" };
|
|
34855
|
+
const session = { consumer: "unknown", perToolGrants: false };
|
|
34545
34856
|
const sweepStale = () => {
|
|
34546
34857
|
const at = now();
|
|
34547
34858
|
for (const [id, d] of delivered) {
|
|
@@ -34579,7 +34890,7 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
34579
34890
|
retired = true;
|
|
34580
34891
|
continue;
|
|
34581
34892
|
}
|
|
34582
|
-
const frame = { type: "message", ...batch[i] };
|
|
34893
|
+
const frame = { type: "message", ...withConsumerGrants(batch[i], session.perToolGrants) };
|
|
34583
34894
|
try {
|
|
34584
34895
|
sock.send(JSON.stringify(frame));
|
|
34585
34896
|
inflight.set(batch[i].message_id, batch[i]);
|
|
@@ -34608,6 +34919,8 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
34608
34919
|
}
|
|
34609
34920
|
delivered.clear();
|
|
34610
34921
|
if (current?.sock === sock) current = null;
|
|
34922
|
+
if (session.harness) agent.clearHarnessReport(session.harness);
|
|
34923
|
+
if (session.perToolGrants) agent.clearPerToolConsumer(session);
|
|
34611
34924
|
};
|
|
34612
34925
|
current = { sock, cleanup, sweepStale };
|
|
34613
34926
|
sock.on("close", cleanup);
|
|
@@ -34670,7 +34983,20 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
|
|
|
34670
34983
|
return;
|
|
34671
34984
|
}
|
|
34672
34985
|
if (m.type === "hello") {
|
|
34673
|
-
|
|
34986
|
+
const report = m.harness === void 0 ? null : parseHarnessReport(m.harness);
|
|
34987
|
+
if (report) {
|
|
34988
|
+
session.harness = report;
|
|
34989
|
+
agent.recordHarnessReport(report);
|
|
34990
|
+
} else session.consumer = "runtime";
|
|
34991
|
+
if (m.harness !== void 0) session.harnessClaimed = true;
|
|
34992
|
+
const perTool = session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(PER_TOOL_GRANTS_FEATURE);
|
|
34993
|
+
if (perTool && !session.perToolGrants) {
|
|
34994
|
+
session.perToolGrants = true;
|
|
34995
|
+
agent.notePerToolConsumer(session);
|
|
34996
|
+
} else if (!perTool && session.perToolGrants) {
|
|
34997
|
+
session.perToolGrants = false;
|
|
34998
|
+
agent.clearPerToolConsumer(session);
|
|
34999
|
+
}
|
|
34674
35000
|
sendJson(sock, { type: "hello", features: ["nack"] });
|
|
34675
35001
|
return;
|
|
34676
35002
|
}
|