@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/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) {
|
|
@@ -28939,6 +28960,29 @@ var StatusGate = class {
|
|
|
28939
28960
|
}
|
|
28940
28961
|
};
|
|
28941
28962
|
|
|
28963
|
+
// src/room-tools.ts
|
|
28964
|
+
var TOOL_ROOM_CLASSES = Object.freeze({
|
|
28965
|
+
current_time: Object.freeze(["room_safe_read"]),
|
|
28966
|
+
echo: Object.freeze(["room_safe_read"]),
|
|
28967
|
+
web_search: Object.freeze(["owner_credential"]),
|
|
28968
|
+
fetch_url: Object.freeze(["egress"]),
|
|
28969
|
+
sql_query: Object.freeze(["owner_credential"]),
|
|
28970
|
+
http_request: Object.freeze(["owner_credential", "egress", "side_effect"])
|
|
28971
|
+
});
|
|
28972
|
+
var LIVE_MEMBER_STATUSES = /* @__PURE__ */ new Set(["active", "add_pending"]);
|
|
28973
|
+
function isLiveRow(r) {
|
|
28974
|
+
return (r.status === void 0 || LIVE_MEMBER_STATUSES.has(r.status)) && r.left_but_in_group !== true;
|
|
28975
|
+
}
|
|
28976
|
+
function roomIsCrossOrg(rows) {
|
|
28977
|
+
if (rows.length === 0) return null;
|
|
28978
|
+
let unknown = false;
|
|
28979
|
+
for (const r of rows) {
|
|
28980
|
+
if (r.is_same_org_as_agent_owner === false) return true;
|
|
28981
|
+
if (r.is_same_org_as_agent_owner !== true) unknown = true;
|
|
28982
|
+
}
|
|
28983
|
+
return unknown ? null : false;
|
|
28984
|
+
}
|
|
28985
|
+
|
|
28942
28986
|
// src/bridge-agent.ts
|
|
28943
28987
|
function logSendSupersededAdoption(kind) {
|
|
28944
28988
|
switch (kind) {
|
|
@@ -28987,11 +29031,18 @@ var OWNER_DM_DEFAULT_READ_GRANTS = Object.freeze([
|
|
|
28987
29031
|
Object.freeze({ tool: "current_time", mode: "read" }),
|
|
28988
29032
|
Object.freeze({ tool: "web_search", mode: "read" })
|
|
28989
29033
|
]);
|
|
29034
|
+
var ROOM_DEFAULT_READ_GRANTS = Object.freeze([
|
|
29035
|
+
Object.freeze({ tool: "current_time", mode: "read" })
|
|
29036
|
+
]);
|
|
28990
29037
|
var NO_TOOL_GRANTS = Object.freeze([]);
|
|
28991
29038
|
function ownerDmDefaultGrantsEnabled(env = process.env) {
|
|
28992
29039
|
const v = env.SECURECOMS_OWNER_DM_DEFAULT_GRANTS;
|
|
28993
29040
|
return v === void 0 || v === "on";
|
|
28994
29041
|
}
|
|
29042
|
+
function roomDefaultGrantsEnabled(env = process.env) {
|
|
29043
|
+
const v = env.SECURECOMS_ROOM_DEFAULT_GRANTS;
|
|
29044
|
+
return v === void 0 || v === "on";
|
|
29045
|
+
}
|
|
28995
29046
|
var VAULT_REF_BARE_TEXT = "[Files link]";
|
|
28996
29047
|
var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
|
|
28997
29048
|
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.";
|
|
@@ -29170,6 +29221,19 @@ var SendForbiddenError = class extends Error {
|
|
|
29170
29221
|
this.name = "SendForbiddenError";
|
|
29171
29222
|
}
|
|
29172
29223
|
};
|
|
29224
|
+
var RoomClassChangedSendError = class extends SendForbiddenError {
|
|
29225
|
+
constructor(channelId) {
|
|
29226
|
+
super(channelId);
|
|
29227
|
+
this.name = "RoomClassChangedSendError";
|
|
29228
|
+
this.message = `reply dropped for channel ${channelId}: the room changed after its tools were granted; no retry`;
|
|
29229
|
+
}
|
|
29230
|
+
};
|
|
29231
|
+
function rosterFreshAt(entry, heldEpoch, heldGroupId) {
|
|
29232
|
+
return entry !== void 0 && typeof heldEpoch === "number" && entry.epoch === heldEpoch && typeof heldGroupId === "string" && entry.groupId === heldGroupId;
|
|
29233
|
+
}
|
|
29234
|
+
var RoomGrantEpochMovedError = class extends Error {
|
|
29235
|
+
};
|
|
29236
|
+
var ROOM_GRANT_TRACK_MAX = 4096;
|
|
29173
29237
|
var GroupSupersededSendError = class extends Error {
|
|
29174
29238
|
constructor(channelId, activeGroupId) {
|
|
29175
29239
|
super(`send refused for channel ${channelId}: the channel moved to a new secure group; not sent`);
|
|
@@ -29660,6 +29724,21 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
29660
29724
|
selfHealSweepEnabled = process.env.SECURECOMS_SELF_HEAL_SWEEP !== "false";
|
|
29661
29725
|
/** Read once at startup; see ownerDmDefaultGrantsEnabled. */
|
|
29662
29726
|
ownerDmDefaultGrants = ownerDmDefaultGrantsEnabled();
|
|
29727
|
+
/** Read once at startup; see roomDefaultGrantsEnabled. */
|
|
29728
|
+
roomDefaultGrants = roomDefaultGrantsEnabled();
|
|
29729
|
+
/**
|
|
29730
|
+
* Audit M-B4: DM channels whose roster was epoch-fresh at THIS ingest
|
|
29731
|
+
* cycle's refresh (refreshRostersForCycle). Cleared at each cycle's start.
|
|
29732
|
+
*/
|
|
29733
|
+
ownerDmCycleFresh = /* @__PURE__ */ new Set();
|
|
29734
|
+
/**
|
|
29735
|
+
* Audit M-B1: room-granted turns, keyed by the inbound message id, with the
|
|
29736
|
+
* channel, MLS group and epoch the room was classified at (review I-1: an
|
|
29737
|
+
* epoch NUMBER alone repeats across channels and across a supersede). A
|
|
29738
|
+
* reply to one is sealed only there, or re-classified once
|
|
29739
|
+
* (publishBodyForReply). Forgotten at the runtime's ack.
|
|
29740
|
+
*/
|
|
29741
|
+
roomGrantEpochs = /* @__PURE__ */ new Map();
|
|
29663
29742
|
/** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
|
|
29664
29743
|
static SELF_HEAL_SWEEP_INTERVAL_MS = SELF_HEAL_SWEEP_INTERVAL_MS;
|
|
29665
29744
|
/**
|
|
@@ -29963,7 +30042,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
29963
30042
|
// Inert legacy files only. `readLocalReport` revalidates the current
|
|
29964
30043
|
// regular-file bytes and strips every local path before this leaves the
|
|
29965
30044
|
// machine.
|
|
29966
|
-
legacy_memory: local?.legacy_memory ?? []
|
|
30045
|
+
legacy_memory: local?.legacy_memory ?? [],
|
|
30046
|
+
// §B.4: counts of server-named secret paths inside / allowlisted /
|
|
30047
|
+
// outside the secrets directory, and the runtime's mode. Numbers and
|
|
30048
|
+
// one enum only; readLocalReport rebuilds it so no path rides along.
|
|
30049
|
+
secret_paths: local?.secret_paths ?? null
|
|
29967
30050
|
};
|
|
29968
30051
|
const posted = await this.fetchBounded("runtime_config_report", "control", `${base}/report`, {
|
|
29969
30052
|
method: "POST",
|
|
@@ -30967,7 +31050,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
30967
31050
|
* for every envelope built, BEFORE it is published (SEC-06 #18 persists it
|
|
30968
31051
|
* there so a retry can resend the exact bytes).
|
|
30969
31052
|
*/
|
|
30970
|
-
async publishBody(channelId, body, id, onEnvelope) {
|
|
31053
|
+
async publishBody(channelId, body, id, onEnvelope, sealCheck) {
|
|
30971
31054
|
this.enforceBodyRateFloor(channelId, body);
|
|
30972
31055
|
this.statusGate.replyStarted(channelId);
|
|
30973
31056
|
const joined = await this.reconcileJoin(channelId);
|
|
@@ -30976,33 +31059,36 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
30976
31059
|
await this.sendWithSelfHealTerminal(
|
|
30977
31060
|
this.provider,
|
|
30978
31061
|
channelId,
|
|
30979
|
-
(epoch) =>
|
|
30980
|
-
|
|
30981
|
-
|
|
30982
|
-
|
|
30983
|
-
|
|
30984
|
-
|
|
30985
|
-
|
|
30986
|
-
|
|
30987
|
-
|
|
30988
|
-
|
|
30989
|
-
|
|
30990
|
-
|
|
30991
|
-
type: "a2a",
|
|
30992
|
-
|
|
30993
|
-
|
|
30994
|
-
|
|
30995
|
-
|
|
30996
|
-
|
|
30997
|
-
|
|
30998
|
-
|
|
30999
|
-
|
|
31000
|
-
|
|
31001
|
-
|
|
31002
|
-
|
|
31003
|
-
|
|
31004
|
-
|
|
31005
|
-
|
|
31062
|
+
(epoch) => {
|
|
31063
|
+
sealCheck?.(epoch);
|
|
31064
|
+
return this.latency.time(
|
|
31065
|
+
"send.encrypt",
|
|
31066
|
+
() => buildOutboundEnvelope({
|
|
31067
|
+
custody: this.custody,
|
|
31068
|
+
groupKey: this.provider,
|
|
31069
|
+
messageId: id,
|
|
31070
|
+
channelId,
|
|
31071
|
+
enclaveId: this.resolveEnclave(channelId),
|
|
31072
|
+
senderId: this.cfg.identityId,
|
|
31073
|
+
senderType: "agent",
|
|
31074
|
+
type: body.kind === "text" ? "text" : "a2a",
|
|
31075
|
+
bodyPlaintext: body.kind === "text" ? JSON.stringify({ text: body.text }) : JSON.stringify({
|
|
31076
|
+
type: "a2a",
|
|
31077
|
+
a2a_version: body.a2a.a2a_version,
|
|
31078
|
+
kind: body.a2a.kind,
|
|
31079
|
+
object: body.a2a.object
|
|
31080
|
+
}),
|
|
31081
|
+
bindingId: "",
|
|
31082
|
+
bindingVersion: "0.0.0",
|
|
31083
|
+
agentSkillId: null,
|
|
31084
|
+
epoch,
|
|
31085
|
+
traceparent: ZERO_TRACEPARENT,
|
|
31086
|
+
references: [],
|
|
31087
|
+
stage5Outcome: "allow",
|
|
31088
|
+
stage5Evaluators: []
|
|
31089
|
+
})
|
|
31090
|
+
);
|
|
31091
|
+
},
|
|
31006
31092
|
async (envelope) => {
|
|
31007
31093
|
if (onEnvelope) await onEnvelope(envelope);
|
|
31008
31094
|
return this.publishEnvelope(
|
|
@@ -31013,6 +31099,71 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
31013
31099
|
);
|
|
31014
31100
|
this.latency.record("send.total", performance.now() - sendStart);
|
|
31015
31101
|
}
|
|
31102
|
+
/**
|
|
31103
|
+
* Audit M-B1 (controller ruling): a reply to a room-granted turn is sealed
|
|
31104
|
+
* only at the epoch the room was classified at. On a move, the room is
|
|
31105
|
+
* re-classified ONCE from a roster fetched at the new epoch: still internal,
|
|
31106
|
+
* the reply is sealed at that epoch; anything else, it is dropped. The check
|
|
31107
|
+
* throws before an envelope is built, so nothing is stored or posted.
|
|
31108
|
+
*
|
|
31109
|
+
* `inReplyTo` is the STORED intent's inbound id, not the send's options, so
|
|
31110
|
+
* the redelivery replay (replayReplyIntents, which has no in_reply_to in its
|
|
31111
|
+
* options) is checked the same as a live send.
|
|
31112
|
+
*/
|
|
31113
|
+
async publishBodyForReply(channelId, body, id, inReplyTo, onEnvelope) {
|
|
31114
|
+
const grant = inReplyTo !== null ? this.roomGrantEpochs.get(inReplyTo) : void 0;
|
|
31115
|
+
if (grant === void 0) return this.publishBody(channelId, body, id, onEnvelope);
|
|
31116
|
+
if (channelId !== grant.channelId) {
|
|
31117
|
+
writeServiceLog2("warn", "reply.room_class_changed");
|
|
31118
|
+
throw new RoomClassChangedSendError(channelId);
|
|
31119
|
+
}
|
|
31120
|
+
let reclassified = false;
|
|
31121
|
+
for (; ; ) {
|
|
31122
|
+
try {
|
|
31123
|
+
return await this.publishBody(
|
|
31124
|
+
channelId,
|
|
31125
|
+
body,
|
|
31126
|
+
id,
|
|
31127
|
+
// Review I-1: the group the envelope was ACTUALLY sealed under
|
|
31128
|
+
// (captured with the key, inside the channel lock), checked before
|
|
31129
|
+
// the durable save and before the post. An equal epoch number in a
|
|
31130
|
+
// new group (the supersede replay) is a move.
|
|
31131
|
+
async (envelope) => {
|
|
31132
|
+
if (sealedGroupIdOf(envelope) !== grant.groupId) throw new RoomGrantEpochMovedError();
|
|
31133
|
+
if (onEnvelope) await onEnvelope(envelope);
|
|
31134
|
+
},
|
|
31135
|
+
(epoch) => {
|
|
31136
|
+
if (epoch !== grant.epoch) throw new RoomGrantEpochMovedError();
|
|
31137
|
+
}
|
|
31138
|
+
);
|
|
31139
|
+
} catch (e) {
|
|
31140
|
+
if (!(e instanceof RoomGrantEpochMovedError)) throw e;
|
|
31141
|
+
if (reclassified || !await this.reclassifyForReply(channelId, grant)) {
|
|
31142
|
+
writeServiceLog2("warn", "reply.room_class_changed");
|
|
31143
|
+
throw new RoomClassChangedSendError(channelId);
|
|
31144
|
+
}
|
|
31145
|
+
reclassified = true;
|
|
31146
|
+
}
|
|
31147
|
+
}
|
|
31148
|
+
}
|
|
31149
|
+
/**
|
|
31150
|
+
* Re-read the room at the agent's current group and epoch; true only if it
|
|
31151
|
+
* is still internal there. Review I-1: the entry must come from THIS re-read
|
|
31152
|
+
* (a fail-soft refresh leaves an older entry whose epoch label can repeat
|
|
31153
|
+
* across a supersede), and the group must not move around the reads.
|
|
31154
|
+
*/
|
|
31155
|
+
async reclassifyForReply(channelId, grant) {
|
|
31156
|
+
const startedAt = Date.now();
|
|
31157
|
+
await this.refreshChannelRoster(channelId);
|
|
31158
|
+
const held = await this.heldPosition(channelId);
|
|
31159
|
+
const hit = this.freshRosterEntry(channelId);
|
|
31160
|
+
if (hit === void 0 || hit.at < startedAt || !rosterFreshAt(hit, held.epoch, held.groupId) || hit.crossOrg !== false || held.epoch === null || held.groupId === null) {
|
|
31161
|
+
return false;
|
|
31162
|
+
}
|
|
31163
|
+
grant.groupId = held.groupId;
|
|
31164
|
+
grant.epoch = held.epoch;
|
|
31165
|
+
return true;
|
|
31166
|
+
}
|
|
31016
31167
|
enforceBodyRateFloor(channelId, body) {
|
|
31017
31168
|
if (body.kind === "a2a") {
|
|
31018
31169
|
this.enforceRateFloor("a2a", channelId);
|
|
@@ -31138,7 +31289,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
31138
31289
|
const bytes = intent.body;
|
|
31139
31290
|
const body = decodeReplyBody(bytes);
|
|
31140
31291
|
try {
|
|
31141
|
-
await this.publishIntentWithRetry(intent.channelId, body, intent.replyMessageId, key);
|
|
31292
|
+
await this.publishIntentWithRetry(intent.channelId, body, intent.replyMessageId, key, intent.inReplyTo);
|
|
31142
31293
|
} catch (e) {
|
|
31143
31294
|
if (e instanceof AgentPausedSendError || e instanceof SendForbiddenError) {
|
|
31144
31295
|
if (intent.inReplyTo !== null) this.store.deletePendingReplyIntents(intent.inReplyTo);
|
|
@@ -31193,12 +31344,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
31193
31344
|
* failure. Either way the intent stays pending, so the redelivery
|
|
31194
31345
|
* republishes the same reply.
|
|
31195
31346
|
*/
|
|
31196
|
-
async publishIntentWithRetry(channelId, body, id, key) {
|
|
31347
|
+
async publishIntentWithRetry(channelId, body, id, key, inReplyTo) {
|
|
31197
31348
|
let rateLimited = 0;
|
|
31198
31349
|
let inFlight = 0;
|
|
31199
31350
|
for (; ; ) {
|
|
31200
31351
|
try {
|
|
31201
|
-
await this.publishIntentOnce(channelId, body, id, key);
|
|
31352
|
+
await this.publishIntentOnce(channelId, body, id, key, inReplyTo);
|
|
31202
31353
|
return;
|
|
31203
31354
|
} catch (e) {
|
|
31204
31355
|
if (e instanceof ReplyInFlightError) {
|
|
@@ -31269,7 +31420,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
31269
31420
|
* original ack; a conflict can only mean the slot belongs to another
|
|
31270
31421
|
* message (for example an old reply whose intent was pruned).
|
|
31271
31422
|
*/
|
|
31272
|
-
async publishIntentOnce(channelId, body, id, key) {
|
|
31423
|
+
async publishIntentOnce(channelId, body, id, key, inReplyTo) {
|
|
31273
31424
|
const current = await this.store.getReplyIntent(key.scope, key.part);
|
|
31274
31425
|
try {
|
|
31275
31426
|
if (current?.envelope) {
|
|
@@ -31310,10 +31461,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
31310
31461
|
}
|
|
31311
31462
|
}
|
|
31312
31463
|
try {
|
|
31313
|
-
await this.
|
|
31464
|
+
await this.publishBodyForReply(
|
|
31314
31465
|
channelId,
|
|
31315
31466
|
body,
|
|
31316
31467
|
id,
|
|
31468
|
+
inReplyTo,
|
|
31317
31469
|
(envelope) => this.store.saveReplyIntentEnvelope(
|
|
31318
31470
|
key.scope,
|
|
31319
31471
|
key.part,
|
|
@@ -32144,10 +32296,33 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32144
32296
|
}
|
|
32145
32297
|
/** A live roster for a channel, or undefined if missing OR expired past the TTL. */
|
|
32146
32298
|
freshRoster(channelId) {
|
|
32299
|
+
return this.freshRosterEntry(channelId)?.members;
|
|
32300
|
+
}
|
|
32301
|
+
/** The whole cache entry when fresh, else undefined (TTL; the epoch is checked by the caller). */
|
|
32302
|
+
freshRosterEntry(channelId) {
|
|
32147
32303
|
const hit = this.channelRoster.get(channelId);
|
|
32148
|
-
if (!hit) return void 0;
|
|
32149
|
-
|
|
32150
|
-
|
|
32304
|
+
if (!hit || Date.now() - hit.at >= CHANNEL_ROSTER_TTL_MS) return void 0;
|
|
32305
|
+
return hit;
|
|
32306
|
+
}
|
|
32307
|
+
/** The agent's held MLS group id for a channel; null when none (fail closed). */
|
|
32308
|
+
async heldGroupFor(channelId) {
|
|
32309
|
+
return this.provider.heldGroupIdFor(channelId).catch(() => null);
|
|
32310
|
+
}
|
|
32311
|
+
/**
|
|
32312
|
+
* Review I-1 + fix round 2: the agent's held (group, epoch) for a channel.
|
|
32313
|
+
* The group is read on BOTH sides of the epoch read, so the epoch provably
|
|
32314
|
+
* belongs to it (a supersede in between installs a new group whose epoch
|
|
32315
|
+
* counter restarts); a group that moved, or none held, is null (fail closed).
|
|
32316
|
+
*/
|
|
32317
|
+
async heldPosition(channelId) {
|
|
32318
|
+
const before = await this.heldGroupFor(channelId);
|
|
32319
|
+
const epoch = await this.heldEpochFor(channelId);
|
|
32320
|
+
const after = await this.heldGroupFor(channelId);
|
|
32321
|
+
return { groupId: before !== null && after === before ? before : null, epoch };
|
|
32322
|
+
}
|
|
32323
|
+
/** The agent's own MLS epoch for a channel; null when no group is held (fail closed). */
|
|
32324
|
+
async heldEpochFor(channelId) {
|
|
32325
|
+
return this.provider.currentEpoch(channelId).catch(() => null);
|
|
32151
32326
|
}
|
|
32152
32327
|
/**
|
|
32153
32328
|
* Resolve one channel's roster (members + display names + is-agent) from the
|
|
@@ -32158,16 +32333,18 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32158
32333
|
*/
|
|
32159
32334
|
async refreshChannelRoster(channelId) {
|
|
32160
32335
|
try {
|
|
32336
|
+
const { epoch, groupId } = await this.heldPosition(channelId);
|
|
32161
32337
|
const url = `${this.cfg.apiBase}/api/v1/channels/${encodeURIComponent(
|
|
32162
32338
|
channelId
|
|
32163
|
-
)}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
|
|
32339
|
+
)}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}&include_pending=true`;
|
|
32164
32340
|
const res = await this.fetchBounded("channel_roster", "read", url, {
|
|
32165
32341
|
headers: { authorization: `Bearer ${this.cfg.apiKey}` }
|
|
32166
32342
|
});
|
|
32167
32343
|
if (!res.ok) return;
|
|
32168
32344
|
const rows = await res.json();
|
|
32169
32345
|
const members = /* @__PURE__ */ new Map();
|
|
32170
|
-
|
|
32346
|
+
const liveRows = rows.filter(isLiveRow);
|
|
32347
|
+
for (const r of liveRows) {
|
|
32171
32348
|
members.set(r.identity_id, {
|
|
32172
32349
|
name: r.display_name,
|
|
32173
32350
|
is_agent: r.is_agent,
|
|
@@ -32176,13 +32353,45 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32176
32353
|
is_relay: r.is_relay === true
|
|
32177
32354
|
});
|
|
32178
32355
|
}
|
|
32179
|
-
this.channelRoster.set(channelId, { members, at: Date.now() });
|
|
32356
|
+
this.channelRoster.set(channelId, { members, at: Date.now(), epoch, groupId, crossOrg: roomIsCrossOrg(rows) });
|
|
32180
32357
|
const self = members.get(this.cfg.identityId);
|
|
32181
32358
|
if (self?.name) this.lastOwnDisplayName = self.name;
|
|
32182
|
-
await this.resolveAndPinOwner(channelId,
|
|
32359
|
+
await this.resolveAndPinOwner(channelId, liveRows);
|
|
32183
32360
|
} catch {
|
|
32184
32361
|
}
|
|
32185
32362
|
}
|
|
32363
|
+
/**
|
|
32364
|
+
* S-1 (roster) + §7A.5 + audit M-B4: once per ingest cycle, per distinct
|
|
32365
|
+
* channel, re-read the roster when the TTL lapsed OR the epoch moved since
|
|
32366
|
+
* the fetch, then record which DMs are epoch-fresh for the rest of this
|
|
32367
|
+
* cycle. Fail-soft inside refreshChannelRoster (never blocks ingest).
|
|
32368
|
+
*/
|
|
32369
|
+
async refreshRostersForCycle(channelIds) {
|
|
32370
|
+
this.ownerDmCycleFresh.clear();
|
|
32371
|
+
for (const channelId of new Set(channelIds)) {
|
|
32372
|
+
const held = await this.heldPosition(channelId);
|
|
32373
|
+
const hit = this.freshRosterEntry(channelId);
|
|
32374
|
+
if (hit === void 0 || hit.epoch !== held.epoch || hit.groupId !== held.groupId) {
|
|
32375
|
+
await this.refreshChannelRoster(channelId);
|
|
32376
|
+
}
|
|
32377
|
+
const now = this.freshRosterEntry(channelId);
|
|
32378
|
+
if (rosterFreshAt(now, held.epoch, held.groupId) && this.freshMeta(channelId)?.is_dm === true) {
|
|
32379
|
+
this.ownerDmCycleFresh.add(channelId);
|
|
32380
|
+
}
|
|
32381
|
+
}
|
|
32382
|
+
}
|
|
32383
|
+
/** Classify one inbound turn at the agent's CURRENT epoch for its channel. */
|
|
32384
|
+
async authorizeTurn(channelId, messageId, senderId, relaySpeakerId) {
|
|
32385
|
+
const held = await this.heldPosition(channelId);
|
|
32386
|
+
const auth = this.authorizationFor(channelId, senderId, relaySpeakerId, held.epoch, held.groupId);
|
|
32387
|
+
if (auth.tool_grants !== ROOM_DEFAULT_READ_GRANTS) return auth;
|
|
32388
|
+
if (this.roomGrantEpochs.size >= ROOM_GRANT_TRACK_MAX || held.epoch === null || held.groupId === null) {
|
|
32389
|
+
writeServiceLog2("warn", "room_grant.untracked");
|
|
32390
|
+
return Object.freeze({ ...auth, tool_grants: NO_TOOL_GRANTS });
|
|
32391
|
+
}
|
|
32392
|
+
this.roomGrantEpochs.set(messageId, { channelId, groupId: held.groupId, epoch: held.epoch });
|
|
32393
|
+
return auth;
|
|
32394
|
+
}
|
|
32186
32395
|
/**
|
|
32187
32396
|
* Classify the sender only from server-authenticated channel/roster data.
|
|
32188
32397
|
* Missing or stale metadata is UNKNOWN with zero grants, never a permissive
|
|
@@ -32191,18 +32400,37 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32191
32400
|
*
|
|
32192
32401
|
* Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
|
|
32193
32402
|
* 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
|
-
*
|
|
32403
|
+
* (a) audience is owner_dm AND the sender is the channel's pinned DM
|
|
32404
|
+
* counterpart (`channelOwner`, never "anomalous"). A server-marked
|
|
32405
|
+
* `is_agent_owner` on a member who is NOT the DM's single human
|
|
32406
|
+
* counterpart gets nothing; on the counterpart it is trusted (see
|
|
32407
|
+
* LIMIT below); OR
|
|
32197
32408
|
* (b) the sender is this channel's server-marked relay (`is_relay`) and the
|
|
32198
32409
|
* relayed `speaker_identity_id` (passed by the caller only for relay
|
|
32199
32410
|
* turns) is that same pinned owner, whose roster row is the agent's
|
|
32200
32411
|
* owner. A speaker claim from any non-relay sender gets nothing.
|
|
32412
|
+
* Both need the roster fetched at `heldEpoch` (the agent's current MLS
|
|
32413
|
+
* epoch for the channel), or the DM marked epoch-fresh at this ingest
|
|
32414
|
+
* cycle's refresh (audit M-B4).
|
|
32415
|
+
* PR R: ROOM_DEFAULT_READ_GRANTS only when the room kill switch is on, a
|
|
32416
|
+
* per-tool consumer is attached, the roster was fetched at `heldEpoch`
|
|
32417
|
+
* (no cycle carve-out), the room is INTERNAL (roomIsCrossOrg === false
|
|
32418
|
+
* over every row, leaf rows included) and the audience is team_room.
|
|
32201
32419
|
* Everything else gets the frozen empty list.
|
|
32420
|
+
*
|
|
32421
|
+
* LIMIT (harness gate review H2, 2026-10-05): "pinned structural owner"
|
|
32422
|
+
* is PER CHANNEL. resolveAndPinOwner pins the single human counterpart of
|
|
32423
|
+
* ANY DM, so in a DM with member Bob, Bob is that channel's pin. What
|
|
32424
|
+
* separates Bob from the real owner is only the server's `is_agent_owner`
|
|
32425
|
+
* flag (connect_channels.py: counterpart user == agent's creator). A
|
|
32426
|
+
* backend that sets that flag wrongly gives Bob the grants, and a harness
|
|
32427
|
+
* connector turns grants into its full toolset. Binding the owner identity
|
|
32428
|
+
* once, outside server control, is an open design follow-up.
|
|
32202
32429
|
*/
|
|
32203
|
-
authorizationFor(channelId, senderId, relaySpeakerId) {
|
|
32430
|
+
authorizationFor(channelId, senderId, relaySpeakerId, heldEpoch, heldGroupId) {
|
|
32204
32431
|
const meta = this.freshMeta(channelId);
|
|
32205
|
-
const
|
|
32432
|
+
const entry = this.freshRosterEntry(channelId);
|
|
32433
|
+
const roster = entry?.members;
|
|
32206
32434
|
const sender = roster?.get(senderId);
|
|
32207
32435
|
let audience = "unknown";
|
|
32208
32436
|
if (meta && sender) {
|
|
@@ -32213,7 +32441,10 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32213
32441
|
else audience = "team_room";
|
|
32214
32442
|
}
|
|
32215
32443
|
let tool_grants = NO_TOOL_GRANTS;
|
|
32216
|
-
|
|
32444
|
+
let relaySpeaker;
|
|
32445
|
+
const epochFresh = rosterFreshAt(entry, heldEpoch, heldGroupId);
|
|
32446
|
+
const ownerDmFresh = epochFresh || this.ownerDmCycleFresh.has(channelId);
|
|
32447
|
+
if (ownerDmFresh && this.ownerDmDefaultGrants && meta?.is_dm === true && sender) {
|
|
32217
32448
|
const pinned = this.channelOwner.get(channelId);
|
|
32218
32449
|
const ownerId = pinned && pinned !== "anomalous" ? pinned.identityId : void 0;
|
|
32219
32450
|
if (ownerId !== void 0) {
|
|
@@ -32221,14 +32452,20 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32221
32452
|
const owner = roster?.get(ownerId);
|
|
32222
32453
|
const ownerRelayed = relaySpeakerId !== void 0 && sender.is_relay && relaySpeakerId === ownerId && owner !== void 0 && !owner.is_agent && owner.is_agent_owner;
|
|
32223
32454
|
if (ownerDirect || ownerRelayed) tool_grants = OWNER_DM_DEFAULT_READ_GRANTS;
|
|
32455
|
+
if (ownerRelayed) relaySpeaker = "owner";
|
|
32224
32456
|
}
|
|
32225
32457
|
}
|
|
32226
|
-
this.
|
|
32458
|
+
if (this.roomDefaultGrants && this.perToolConsumer !== null && epochFresh && meta?.is_dm === false && audience === "team_room" && entry.crossOrg === false) {
|
|
32459
|
+
tool_grants = ROOM_DEFAULT_READ_GRANTS;
|
|
32460
|
+
}
|
|
32461
|
+
const roomClass = meta?.is_dm !== false ? "na" : entry?.crossOrg === false ? "internal" : entry?.crossOrg === true ? "cross_org" : "unknown";
|
|
32462
|
+
this.logAuthzDecision(channelId, senderId, relaySpeakerId, audience, tool_grants.length, roomClass, epochFresh);
|
|
32227
32463
|
return Object.freeze({
|
|
32228
32464
|
version: 1,
|
|
32229
32465
|
audience,
|
|
32230
32466
|
sender_membership: sender ? "active" : "unknown",
|
|
32231
|
-
tool_grants
|
|
32467
|
+
tool_grants,
|
|
32468
|
+
...relaySpeaker ? { relay_speaker: relaySpeaker } : {}
|
|
32232
32469
|
});
|
|
32233
32470
|
}
|
|
32234
32471
|
/**
|
|
@@ -32247,8 +32484,15 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32247
32484
|
* startup: `on` = default grants enabled
|
|
32248
32485
|
* meta_fresh / roster_fresh whether the channel meta / roster caches were
|
|
32249
32486
|
* fresh (either stale forces audience=unknown)
|
|
32487
|
+
* room_class the room's class from the server roster (`na`
|
|
32488
|
+
* for a DM): internal | cross_org | unknown
|
|
32489
|
+
* epoch_fresh the roster was fetched at the agent's current
|
|
32490
|
+
* MLS epoch for the channel
|
|
32491
|
+
* room_kill_switch SECURECOMS_ROOM_DEFAULT_GRANTS as read at startup
|
|
32492
|
+
* per_tool_consumer a consumer that declared per-tool grant
|
|
32493
|
+
* enforcement is attached
|
|
32250
32494
|
*/
|
|
32251
|
-
logAuthzDecision(channelId, senderId, relaySpeakerId, audience, grants) {
|
|
32495
|
+
logAuthzDecision(channelId, senderId, relaySpeakerId, audience, grants, roomClass, epochFresh) {
|
|
32252
32496
|
const meta = this.freshMeta(channelId);
|
|
32253
32497
|
const roster = this.freshRoster(channelId);
|
|
32254
32498
|
const sender = roster?.get(senderId);
|
|
@@ -32276,7 +32520,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32276
32520
|
is_dm: meta?.is_dm === true,
|
|
32277
32521
|
kill_switch: ServiceLogEnum.of(this.ownerDmDefaultGrants ? "on" : "off", ["on", "off"]),
|
|
32278
32522
|
meta_fresh: meta !== void 0,
|
|
32279
|
-
roster_fresh: roster !== void 0
|
|
32523
|
+
roster_fresh: roster !== void 0,
|
|
32524
|
+
room_class: ServiceLogEnum.of(roomClass, ["na", "internal", "cross_org", "unknown"]),
|
|
32525
|
+
epoch_fresh: epochFresh,
|
|
32526
|
+
room_kill_switch: ServiceLogEnum.of(this.roomDefaultGrants ? "on" : "off", ["on", "off"]),
|
|
32527
|
+
per_tool_consumer: this.perToolConsumer !== null
|
|
32280
32528
|
});
|
|
32281
32529
|
}
|
|
32282
32530
|
/**
|
|
@@ -32862,14 +33110,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32862
33110
|
await this.refreshChannelMeta();
|
|
32863
33111
|
metaRefreshed = true;
|
|
32864
33112
|
}
|
|
32865
|
-
|
|
32866
|
-
for (const m of entries) {
|
|
32867
|
-
if (rosterRefreshed.has(m.channelId)) continue;
|
|
32868
|
-
rosterRefreshed.add(m.channelId);
|
|
32869
|
-
if (this.freshRoster(m.channelId) === void 0) {
|
|
32870
|
-
await this.refreshChannelRoster(m.channelId);
|
|
32871
|
-
}
|
|
32872
|
-
}
|
|
33113
|
+
await this.refreshRostersForCycle(entries.map((m) => m.channelId));
|
|
32873
33114
|
const relayMissRefreshed = /* @__PURE__ */ new Set();
|
|
32874
33115
|
const acked = [];
|
|
32875
33116
|
for (const m of entries) {
|
|
@@ -32952,7 +33193,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32952
33193
|
// ignores vault_refs.
|
|
32953
33194
|
text: ref.text ? ref.text : VAULT_REF_BARE_TEXT,
|
|
32954
33195
|
ts: m.receivedAt,
|
|
32955
|
-
authorization: this.
|
|
33196
|
+
authorization: await this.authorizeTurn(m.channelId, m.messageId, m.envelope.sender_id),
|
|
32956
33197
|
sender_name: this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name,
|
|
32957
33198
|
sender_is_agent: senderIsAgent,
|
|
32958
33199
|
vault_refs: [toVaultRefEntry(ref)],
|
|
@@ -33008,7 +33249,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
33008
33249
|
sender_id: m.envelope.sender_id,
|
|
33009
33250
|
text: typeof parsed.text === "string" ? parsed.text : "",
|
|
33010
33251
|
ts: m.receivedAt,
|
|
33011
|
-
authorization: this.
|
|
33252
|
+
authorization: await this.authorizeTurn(m.channelId, m.messageId, m.envelope.sender_id),
|
|
33012
33253
|
sender_name: senderName,
|
|
33013
33254
|
sender_is_agent: senderIsAgent,
|
|
33014
33255
|
attachments: [attachment],
|
|
@@ -33068,8 +33309,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
33068
33309
|
const senderName = this.freshRoster(m.channelId)?.get(
|
|
33069
33310
|
m.envelope.sender_id
|
|
33070
33311
|
)?.name;
|
|
33071
|
-
const turnAuthorization = this.
|
|
33312
|
+
const turnAuthorization = await this.authorizeTurn(
|
|
33072
33313
|
m.channelId,
|
|
33314
|
+
m.messageId,
|
|
33073
33315
|
m.envelope.sender_id,
|
|
33074
33316
|
speaker?.identityId
|
|
33075
33317
|
);
|
|
@@ -33640,6 +33882,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
33640
33882
|
}
|
|
33641
33883
|
/** Complete the durable handoff only after the runtime explicitly acks. */
|
|
33642
33884
|
ackRuntimeMessages(messageIds) {
|
|
33885
|
+
for (const id of messageIds) this.roomGrantEpochs.delete(id);
|
|
33643
33886
|
for (const messageId of messageIds) {
|
|
33644
33887
|
this.store.ackRuntimeInbox(messageId);
|
|
33645
33888
|
this.runtimePendingIds.delete(messageId);
|
|
@@ -33744,6 +33987,49 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
33744
33987
|
noteSessionEvicted() {
|
|
33745
33988
|
this.evictions++;
|
|
33746
33989
|
}
|
|
33990
|
+
/** Harness tool gate H4: latest connector self-report (see harness-report.ts). */
|
|
33991
|
+
harness = null;
|
|
33992
|
+
/**
|
|
33993
|
+
* Record a connector's harness report (from its /v1/ws hello). WARN once
|
|
33994
|
+
* each time the attached harness turns to an inactive veto: such a harness
|
|
33995
|
+
* refuses every turn without a tool grant, so its rooms go quiet.
|
|
33996
|
+
*/
|
|
33997
|
+
recordHarnessReport(report) {
|
|
33998
|
+
const wasInactive = this.harness?.tool_veto === "inactive";
|
|
33999
|
+
this.harness = report;
|
|
34000
|
+
if (report.tool_veto === "inactive" && !wasInactive) {
|
|
34001
|
+
writeServiceLog2("warn", "harness.tool_veto_inactive", {
|
|
34002
|
+
harness: ServiceLogEnum.of(report.name, ["hermes", "openclaw", "other"])
|
|
34003
|
+
});
|
|
34004
|
+
}
|
|
34005
|
+
}
|
|
34006
|
+
/** Forget `report` when the connector that sent it disconnects. A report
|
|
34007
|
+
* from a newer connector (a different object) is kept. */
|
|
34008
|
+
clearHarnessReport(report) {
|
|
34009
|
+
if (this.harness === report) this.harness = null;
|
|
34010
|
+
}
|
|
34011
|
+
/** The latest harness report, or null (GET /v1/status). */
|
|
34012
|
+
harnessReport() {
|
|
34013
|
+
return this.harness;
|
|
34014
|
+
}
|
|
34015
|
+
/**
|
|
34016
|
+
* Audit C-B1: the attached /v1/ws consumer that declared per-tool grant
|
|
34017
|
+
* enforcement (its hello carried "per_tool_grants" and no harness report),
|
|
34018
|
+
* or null. authorizationFor issues a room grant only while one is attached
|
|
34019
|
+
* (Task R2); every delivery path also strips room grants for any other
|
|
34020
|
+
* consumer (consumer-grants.ts). The token is the socket's session object.
|
|
34021
|
+
*/
|
|
34022
|
+
perToolConsumer = null;
|
|
34023
|
+
notePerToolConsumer(token) {
|
|
34024
|
+
this.perToolConsumer = token;
|
|
34025
|
+
}
|
|
34026
|
+
/** Forget `token` when its socket closes; a newer consumer's token is kept. */
|
|
34027
|
+
clearPerToolConsumer(token) {
|
|
34028
|
+
if (this.perToolConsumer === token) this.perToolConsumer = null;
|
|
34029
|
+
}
|
|
34030
|
+
perToolConsumerAttached() {
|
|
34031
|
+
return this.perToolConsumer !== null;
|
|
34032
|
+
}
|
|
33747
34033
|
health() {
|
|
33748
34034
|
let joined = 0;
|
|
33749
34035
|
for (const s of this.joinState.values()) if (s === "JOINED") joined++;
|
|
@@ -33813,6 +34099,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
33813
34099
|
delivery_pings: this.pingsReceived,
|
|
33814
34100
|
agent_sessions: this.authSessions,
|
|
33815
34101
|
evictions: this.evictions,
|
|
34102
|
+
harness_tool_veto_active: this.harness === null ? null : this.harness.tool_veto === "active",
|
|
33816
34103
|
uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
|
|
33817
34104
|
version: BRIDGE_VERSION,
|
|
33818
34105
|
...this.memory.stats?.() ?? {}
|
|
@@ -34096,6 +34383,16 @@ function wsErrorFrame(code, detail) {
|
|
|
34096
34383
|
return { type: "error", ...errorBody(code, detail) };
|
|
34097
34384
|
}
|
|
34098
34385
|
|
|
34386
|
+
// src/consumer-grants.ts
|
|
34387
|
+
var NO_GRANTS = Object.freeze([]);
|
|
34388
|
+
var PER_TOOL_GRANTS_FEATURE = "per_tool_grants";
|
|
34389
|
+
function withConsumerGrants(m, perToolConsumer) {
|
|
34390
|
+
if (perToolConsumer) return m;
|
|
34391
|
+
const a = m.authorization;
|
|
34392
|
+
if (!a || a.tool_grants.length === 0 || a.audience === "owner_dm" || a.relay_speaker === "owner") return m;
|
|
34393
|
+
return { ...m, authorization: Object.freeze({ ...a, tool_grants: NO_GRANTS }) };
|
|
34394
|
+
}
|
|
34395
|
+
|
|
34099
34396
|
// src/http-server.ts
|
|
34100
34397
|
function writeSse(res, msgs, onWriteError) {
|
|
34101
34398
|
for (let i = 0; i < msgs.length; i++) {
|
|
@@ -34115,6 +34412,12 @@ data: ${JSON.stringify(msgs[i])}
|
|
|
34115
34412
|
}
|
|
34116
34413
|
return [];
|
|
34117
34414
|
}
|
|
34415
|
+
function deliverSseBatch(res, batch, agent, onWriteError) {
|
|
34416
|
+
const left3 = writeSse(res, batch.map((m) => withConsumerGrants(m, false)), onWriteError);
|
|
34417
|
+
const leftIds = new Set(left3.map((message) => message.message_id));
|
|
34418
|
+
agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
|
|
34419
|
+
if (left3.length) agent.requeueFront(batch.filter((message) => leftIds.has(message.message_id)));
|
|
34420
|
+
}
|
|
34118
34421
|
function sseWriteFailureReporter() {
|
|
34119
34422
|
let warned = false;
|
|
34120
34423
|
return (error) => {
|
|
@@ -34345,6 +34648,9 @@ function createHttpServer(agent, opts) {
|
|
|
34345
34648
|
const r = await agent.vaultDelete(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id);
|
|
34346
34649
|
return json(res, 200, r);
|
|
34347
34650
|
}
|
|
34651
|
+
if (method === "GET" && path === "/v1/status") {
|
|
34652
|
+
return json(res, 200, { harness: agent.harnessReport() });
|
|
34653
|
+
}
|
|
34348
34654
|
if (method === "POST" && path === "/v1/status") {
|
|
34349
34655
|
const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
|
|
34350
34656
|
if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
|
|
@@ -34374,7 +34680,7 @@ function createHttpServer(agent, opts) {
|
|
|
34374
34680
|
if (method === "GET" && path === "/v1/messages") {
|
|
34375
34681
|
const channel = url.searchParams.get("channel_id") ?? void 0;
|
|
34376
34682
|
const wait = Math.max(0, Math.min(50, Number(url.searchParams.get("wait") ?? 0)));
|
|
34377
|
-
return json(res, 200, await agent.drain(channel, wait));
|
|
34683
|
+
return json(res, 200, (await agent.drain(channel, wait)).map((m) => withConsumerGrants(m, false)));
|
|
34378
34684
|
}
|
|
34379
34685
|
if (method === "GET" && path === "/v1/events") {
|
|
34380
34686
|
const channel = url.searchParams.get("channel_id") ?? void 0;
|
|
@@ -34386,13 +34692,7 @@ function createHttpServer(agent, opts) {
|
|
|
34386
34692
|
});
|
|
34387
34693
|
res.write(":connected\n\n");
|
|
34388
34694
|
const onWriteError = sseWriteFailureReporter();
|
|
34389
|
-
const drainToClient = () =>
|
|
34390
|
-
const batch = agent.takeBuffered(channel);
|
|
34391
|
-
const left3 = writeSse(res, batch, onWriteError);
|
|
34392
|
-
const leftIds = new Set(left3.map((message) => message.message_id));
|
|
34393
|
-
agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
|
|
34394
|
-
if (left3.length) agent.requeueFront(left3);
|
|
34395
|
-
};
|
|
34695
|
+
const drainToClient = () => deliverSseBatch(res, agent.takeBuffered(channel), agent, onWriteError);
|
|
34396
34696
|
const unsubscribe = agent.subscribe(drainToClient);
|
|
34397
34697
|
const keepalive = setInterval(() => {
|
|
34398
34698
|
try {
|
|
@@ -34434,6 +34734,17 @@ function createHttpServer(agent, opts) {
|
|
|
34434
34734
|
return { server, closeAllSseStreams: () => closeSse() };
|
|
34435
34735
|
}
|
|
34436
34736
|
|
|
34737
|
+
// src/harness-report.ts
|
|
34738
|
+
var VERSION_TOKEN = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,39}$/;
|
|
34739
|
+
function parseHarnessReport(raw) {
|
|
34740
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
34741
|
+
const r = raw;
|
|
34742
|
+
const name = r.name === "hermes" || r.name === "openclaw" ? r.name : "other";
|
|
34743
|
+
const version = typeof r.version === "string" && VERSION_TOKEN.test(r.version) ? r.version : null;
|
|
34744
|
+
const tool_veto = r.tool_veto === "active" || r.tool_veto === "inactive" ? r.tool_veto : "unknown";
|
|
34745
|
+
return Object.freeze({ name, version, tool_veto });
|
|
34746
|
+
}
|
|
34747
|
+
|
|
34437
34748
|
// src/ws-server.ts
|
|
34438
34749
|
import { WebSocketServer, WebSocket } from "ws";
|
|
34439
34750
|
var PING_INTERVAL_MS = 3e4;
|
|
@@ -34497,7 +34808,7 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
34497
34808
|
});
|
|
34498
34809
|
const inflight = /* @__PURE__ */ new Map();
|
|
34499
34810
|
const delivered = /* @__PURE__ */ new Map();
|
|
34500
|
-
const session = { consumer: "unknown" };
|
|
34811
|
+
const session = { consumer: "unknown", perToolGrants: false };
|
|
34501
34812
|
const sweepStale = () => {
|
|
34502
34813
|
const at = now();
|
|
34503
34814
|
for (const [id, d] of delivered) {
|
|
@@ -34535,7 +34846,7 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
34535
34846
|
retired = true;
|
|
34536
34847
|
continue;
|
|
34537
34848
|
}
|
|
34538
|
-
const frame = { type: "message", ...batch[i] };
|
|
34849
|
+
const frame = { type: "message", ...withConsumerGrants(batch[i], session.perToolGrants) };
|
|
34539
34850
|
try {
|
|
34540
34851
|
sock.send(JSON.stringify(frame));
|
|
34541
34852
|
inflight.set(batch[i].message_id, batch[i]);
|
|
@@ -34564,6 +34875,8 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
34564
34875
|
}
|
|
34565
34876
|
delivered.clear();
|
|
34566
34877
|
if (current?.sock === sock) current = null;
|
|
34878
|
+
if (session.harness) agent.clearHarnessReport(session.harness);
|
|
34879
|
+
if (session.perToolGrants) agent.clearPerToolConsumer(session);
|
|
34567
34880
|
};
|
|
34568
34881
|
current = { sock, cleanup, sweepStale };
|
|
34569
34882
|
sock.on("close", cleanup);
|
|
@@ -34626,7 +34939,20 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
|
|
|
34626
34939
|
return;
|
|
34627
34940
|
}
|
|
34628
34941
|
if (m.type === "hello") {
|
|
34629
|
-
|
|
34942
|
+
const report = m.harness === void 0 ? null : parseHarnessReport(m.harness);
|
|
34943
|
+
if (report) {
|
|
34944
|
+
session.harness = report;
|
|
34945
|
+
agent.recordHarnessReport(report);
|
|
34946
|
+
} else session.consumer = "runtime";
|
|
34947
|
+
if (m.harness !== void 0) session.harnessClaimed = true;
|
|
34948
|
+
const perTool = session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(PER_TOOL_GRANTS_FEATURE);
|
|
34949
|
+
if (perTool && !session.perToolGrants) {
|
|
34950
|
+
session.perToolGrants = true;
|
|
34951
|
+
agent.notePerToolConsumer(session);
|
|
34952
|
+
} else if (!perTool && session.perToolGrants) {
|
|
34953
|
+
session.perToolGrants = false;
|
|
34954
|
+
agent.clearPerToolConsumer(session);
|
|
34955
|
+
}
|
|
34630
34956
|
sendJson(sock, { type: "hello", features: ["nack"] });
|
|
34631
34957
|
return;
|
|
34632
34958
|
}
|