@suveren/gateway 0.25.1 → 0.27.0
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/bin/suveren-gateway.js +10 -1
- package/content/guides/3-mandates.md +1 -1
- package/dist/control-plane/index.mjs +845 -449
- package/dist/mcp-server/http.mjs +306 -50
- package/dist/ui/assets/index-CIR3mXNv.js +120 -0
- package/dist/ui/assets/index-DeEzJCqI.css +1 -0
- package/dist/ui/index.html +2 -2
- package/lib/policy.d.mts +3 -1
- package/lib/policy.mjs +28 -2
- package/node_modules/ip-address/README.md +70 -70
- package/node_modules/ip-address/dist/ipv6.js +7 -4
- package/node_modules/ip-address/dist/ipv6.js.map +1 -1
- package/node_modules/ip-address/dist/v6/constants.d.ts +3 -0
- package/node_modules/ip-address/dist/v6/constants.js +3 -0
- package/node_modules/ip-address/dist/v6/constants.js.map +1 -1
- package/node_modules/ip-address/package.json +2 -1
- package/package.json +1 -1
- package/dist/ui/assets/index-Ccst5MQ7.css +0 -1
- package/dist/ui/assets/index-CxVyYf7C.js +0 -120
package/dist/mcp-server/http.mjs
CHANGED
|
@@ -154,7 +154,7 @@ import { dirname, join as join3 } from "path";
|
|
|
154
154
|
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
155
155
|
import { join as join2 } from "path";
|
|
156
156
|
import { spawnSync } from "child_process";
|
|
157
|
-
var POLICY_KEYS = ["AsUrl", "CaFile", "PinTls", "Proxy", "NoProxy", "Simulation", "InstallMethod"];
|
|
157
|
+
var POLICY_KEYS = ["AsUrl", "CaFile", "PinTls", "Proxy", "NoProxy", "Simulation", "InstallMethod", "Team"];
|
|
158
158
|
var DEFAULT_REGISTRY_BASE_KEY = "SOFTWARE\\Policies\\Suveren\\Gateway";
|
|
159
159
|
function registryKeyPath(hive) {
|
|
160
160
|
const baseKey = process.env.SUVEREN_POLICY_REGISTRY_KEY || DEFAULT_REGISTRY_BASE_KEY;
|
|
@@ -190,6 +190,11 @@ function validateCaFile(candidate) {
|
|
|
190
190
|
if (!existsSync2(trimmed)) return { ok: false, error: `No file at "${trimmed}".` };
|
|
191
191
|
return { ok: true, path: trimmed };
|
|
192
192
|
}
|
|
193
|
+
function validateTeamId(candidate) {
|
|
194
|
+
const trimmed = candidate.trim();
|
|
195
|
+
if (!trimmed) return { ok: false, error: "The team id is empty." };
|
|
196
|
+
return { ok: true, id: trimmed };
|
|
197
|
+
}
|
|
193
198
|
function validateProxyUrl(candidate) {
|
|
194
199
|
const trimmed = candidate.trim();
|
|
195
200
|
if (!trimmed) return { ok: false, error: "The proxy URL is empty." };
|
|
@@ -363,6 +368,14 @@ function readPolicy() {
|
|
|
363
368
|
policy.installMethod = "managed";
|
|
364
369
|
locked.add("installMethod");
|
|
365
370
|
}
|
|
371
|
+
if (merged.Team !== void 0) {
|
|
372
|
+
const v = validateTeamId(String(merged.Team));
|
|
373
|
+
if (!v.ok) {
|
|
374
|
+
throw new Error(`Invalid policy Team ("${String(merged.Team)}") from ${sourceOf.Team}: ${v.error}`);
|
|
375
|
+
}
|
|
376
|
+
policy.team = v.id;
|
|
377
|
+
locked.add("team");
|
|
378
|
+
}
|
|
366
379
|
cached = { policy, locked };
|
|
367
380
|
return cached;
|
|
368
381
|
}
|
|
@@ -704,6 +717,42 @@ function describeFetchError(err) {
|
|
|
704
717
|
return String(err);
|
|
705
718
|
}
|
|
706
719
|
|
|
720
|
+
// src/lib/cp-content-client.ts
|
|
721
|
+
var CP_PORT2 = process.env.SUVEREN_CP_PORT ?? "3402";
|
|
722
|
+
var CP_BASE2 = process.env.SUVEREN_CP_INTERNAL_URL ?? `http://127.0.0.1:${CP_PORT2}`;
|
|
723
|
+
var TIMEOUT_MS2 = 1e4;
|
|
724
|
+
async function postInternal(path, body) {
|
|
725
|
+
const secret = process.env.SUVEREN_INTERNAL_SECRET ?? "";
|
|
726
|
+
if (!secret) {
|
|
727
|
+
throw new Error("No internal secret configured between the MCP server and the control plane.");
|
|
728
|
+
}
|
|
729
|
+
const controller = new AbortController();
|
|
730
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS2);
|
|
731
|
+
try {
|
|
732
|
+
const res = await fetch(`${CP_BASE2}${path}`, {
|
|
733
|
+
method: "POST",
|
|
734
|
+
headers: { "Content-Type": "application/json", "X-Internal-Secret": secret },
|
|
735
|
+
body: JSON.stringify(body),
|
|
736
|
+
signal: controller.signal
|
|
737
|
+
});
|
|
738
|
+
const data = await res.json().catch(() => ({}));
|
|
739
|
+
if (!res.ok) {
|
|
740
|
+
throw new Error(data.error ?? `${path} failed: ${res.status}`);
|
|
741
|
+
}
|
|
742
|
+
return data;
|
|
743
|
+
} finally {
|
|
744
|
+
clearTimeout(timer);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
async function cpEncryptContent(toolArgs, recipientUserIds) {
|
|
748
|
+
const data = await postInternal("/internal/encrypt-content", { toolArgs, recipientUserIds });
|
|
749
|
+
return data;
|
|
750
|
+
}
|
|
751
|
+
async function cpDecryptContent(wire) {
|
|
752
|
+
const data = await postInternal("/internal/decrypt-content", { ciphertext: wire.ciphertext, keys: wire.keys });
|
|
753
|
+
return data.plaintext;
|
|
754
|
+
}
|
|
755
|
+
|
|
707
756
|
// src/lib/sp-client.ts
|
|
708
757
|
var SPReceiptError = class extends Error {
|
|
709
758
|
constructor(message, statusCode, body) {
|
|
@@ -861,6 +910,32 @@ var SPClient = class {
|
|
|
861
910
|
if (!res.ok) throw new Error(`SP compat request failed: ${res.status}`);
|
|
862
911
|
return res.json();
|
|
863
912
|
}
|
|
913
|
+
/**
|
|
914
|
+
* WS3 defence-in-depth (work-plan "Added 2026-10-11 — an account is
|
|
915
|
+
* Personal OR in one team"): the account's active TEAM id, or null when
|
|
916
|
+
* it currently has none (Personal mode) — `GET /api/groups/me`. Used only
|
|
917
|
+
* by AttestationCache.refreshActiveWorkspace, never on the hot tool-call
|
|
918
|
+
* path (it makes a live AS call).
|
|
919
|
+
*/
|
|
920
|
+
async getActiveTeamGroupId() {
|
|
921
|
+
const res = await this.fetch("/api/groups/me");
|
|
922
|
+
if (!res.ok) throw new Error(`SP groups/me request failed: ${res.status}`);
|
|
923
|
+
const data = await res.json();
|
|
924
|
+
return data.group?.id ?? null;
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* The account's own Personal group id — `GET /api/groups`, the entry
|
|
928
|
+
* with `isPersonal`. Same caller/purpose as {@link getActiveTeamGroupId}:
|
|
929
|
+
* together they resolve "the active workspace" (the active team if any,
|
|
930
|
+
* else Personal) without the gateway ever guessing at an id shape.
|
|
931
|
+
*/
|
|
932
|
+
async getPersonalGroupId() {
|
|
933
|
+
const res = await this.fetch("/api/groups");
|
|
934
|
+
if (!res.ok) throw new Error(`SP groups request failed: ${res.status}`);
|
|
935
|
+
const data = await res.json();
|
|
936
|
+
const personal = (data.groups ?? []).find((g) => g.isPersonal);
|
|
937
|
+
return personal?.id ?? null;
|
|
938
|
+
}
|
|
864
939
|
/**
|
|
865
940
|
* Get all mandates for an authorization id.
|
|
866
941
|
*
|
|
@@ -905,7 +980,11 @@ var SPClient = class {
|
|
|
905
980
|
* Errors throw SPReceiptError with the structured `errors` array in `body`.
|
|
906
981
|
*/
|
|
907
982
|
async postReceipt(data) {
|
|
908
|
-
const
|
|
983
|
+
const { proposalContentHash, ...rest } = data;
|
|
984
|
+
const body = JSON.stringify({
|
|
985
|
+
...rest,
|
|
986
|
+
...proposalContentHash ? { proposal_content_hash: proposalContentHash } : {}
|
|
987
|
+
});
|
|
909
988
|
const maxAttempts = data.idempotencyKey || data.proposalId ? this.receiptRetry.maxAttempts : 1;
|
|
910
989
|
let lastError;
|
|
911
990
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
@@ -945,8 +1024,20 @@ var SPClient = class {
|
|
|
945
1024
|
/**
|
|
946
1025
|
* Submit a proposal for deferred commitment review.
|
|
947
1026
|
* Phase 6: accepts optional pendingApprovers and createdBy for above-cap routing.
|
|
1027
|
+
*
|
|
1028
|
+
* PE1: toolArgs is encrypted (via the control plane's vault + the AS's
|
|
1029
|
+
* registered public keys — cp-content-client.ts) before it ever reaches
|
|
1030
|
+
* this method's request body; only the sealed `content` is sent, never
|
|
1031
|
+
* `tool_args`. `recipientUserIds` are who besides the submitter (added by
|
|
1032
|
+
* the control plane itself) must be able to read it back: the mandate
|
|
1033
|
+
* owner, every pending approver (above-cap path), or every active member
|
|
1034
|
+
* holding a pending domain (deferred-domain path) — decision 3 of the
|
|
1035
|
+
* PE1 brief. `unreadableFor` is G4: anyone in that set with no registered
|
|
1036
|
+
* end-to-end key — the proposal is still created for everyone who CAN
|
|
1037
|
+
* read it; the caller reports who cannot.
|
|
948
1038
|
*/
|
|
949
1039
|
async submitProposal(data) {
|
|
1040
|
+
const { content, unreadableFor } = await cpEncryptContent(data.toolArgs, data.recipientUserIds);
|
|
950
1041
|
const res = await this.fetch("/api/proposals", {
|
|
951
1042
|
method: "POST",
|
|
952
1043
|
body: JSON.stringify({
|
|
@@ -955,7 +1046,7 @@ var SPClient = class {
|
|
|
955
1046
|
path: data.path,
|
|
956
1047
|
pending_domains: data.pendingDomains,
|
|
957
1048
|
tool: data.tool,
|
|
958
|
-
|
|
1049
|
+
content,
|
|
959
1050
|
execution_context: data.executionContext,
|
|
960
1051
|
// Phase 6
|
|
961
1052
|
...data.pendingApprovers && data.pendingApprovers.length > 0 ? { pending_approvers: data.pendingApprovers } : {},
|
|
@@ -963,11 +1054,12 @@ var SPClient = class {
|
|
|
963
1054
|
})
|
|
964
1055
|
});
|
|
965
1056
|
if (!res.ok) {
|
|
966
|
-
const
|
|
967
|
-
throw new Error(
|
|
1057
|
+
const body2 = await res.json().catch(() => ({}));
|
|
1058
|
+
throw new Error(body2.error ?? `SP proposal submission failed: ${res.status}`);
|
|
968
1059
|
}
|
|
969
1060
|
void notifyControlPlane("proposal-added");
|
|
970
|
-
|
|
1061
|
+
const body = await res.json();
|
|
1062
|
+
return { proposal: body.proposal, unreadableFor };
|
|
971
1063
|
}
|
|
972
1064
|
/**
|
|
973
1065
|
* Phase 6: Fetch the authorization summary by its per-ceremony id.
|
|
@@ -1066,6 +1158,48 @@ var AttestationCache = class {
|
|
|
1066
1158
|
* this fact to begin with.
|
|
1067
1159
|
*/
|
|
1068
1160
|
reapprovalNeeded = /* @__PURE__ */ new Set();
|
|
1161
|
+
/**
|
|
1162
|
+
* WS3 defence-in-depth (work-plan "Added 2026-10-11 — an account is
|
|
1163
|
+
* Personal OR in one team"): the account's current active workspace's
|
|
1164
|
+
* group id — its active team, else its Personal group. `null` = not yet
|
|
1165
|
+
* known (before the first {@link refreshActiveWorkspace}, or every
|
|
1166
|
+
* lookup it tried failed) — filtering is then SKIPPED rather than
|
|
1167
|
+
* refusing everything, because the Authority Server itself is the
|
|
1168
|
+
* fail-closed authority at ticket time (decision 6, WS2); this is a
|
|
1169
|
+
* second, narrower check for a window a buggy/stale cache entry could
|
|
1170
|
+
* otherwise slip through in, not a replacement for that enforcement.
|
|
1171
|
+
*/
|
|
1172
|
+
activeWorkspaceGroupId = null;
|
|
1173
|
+
/**
|
|
1174
|
+
* Re-resolve {@link activeWorkspaceGroupId} from the Authority Server.
|
|
1175
|
+
* Called by the resync-gates flow (http.ts's `/internal/resync-gates`),
|
|
1176
|
+
* never on the hot tool-call path — it makes up to two AS calls. Leaves
|
|
1177
|
+
* the previous value in place on any failure (network error, unexpected
|
|
1178
|
+
* shape): losing a known-good value on a transient blip would turn this
|
|
1179
|
+
* defence-in-depth check into a false refusal of every cached mandate,
|
|
1180
|
+
* which is worse than leaving it momentarily stale.
|
|
1181
|
+
*/
|
|
1182
|
+
async refreshActiveWorkspace() {
|
|
1183
|
+
try {
|
|
1184
|
+
const activeTeam = await this.spClient.getActiveTeamGroupId();
|
|
1185
|
+
if (activeTeam) {
|
|
1186
|
+
this.activeWorkspaceGroupId = activeTeam;
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
const personalId = await this.spClient.getPersonalGroupId();
|
|
1190
|
+
if (personalId) this.activeWorkspaceGroupId = personalId;
|
|
1191
|
+
} catch {
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* True only when `groupId` is KNOWN (not undefined) and the active
|
|
1196
|
+
* workspace is ALSO known (not null) and they differ. Either side
|
|
1197
|
+
* unknown ⇒ false: this check only ever narrows what gets selected, it
|
|
1198
|
+
* never substitutes for the Authority Server's own enforcement.
|
|
1199
|
+
*/
|
|
1200
|
+
isForeignWorkspace(groupId) {
|
|
1201
|
+
return groupId !== void 0 && this.activeWorkspaceGroupId !== null && groupId !== this.activeWorkspaceGroupId;
|
|
1202
|
+
}
|
|
1069
1203
|
/**
|
|
1070
1204
|
* Get the SP public key, fetching from SP if not cached or expired, then —
|
|
1071
1205
|
* when a pin exists for this exact AS URL — verifying the live key still
|
|
@@ -1114,6 +1248,10 @@ var AttestationCache = class {
|
|
|
1114
1248
|
getAuthorization(path) {
|
|
1115
1249
|
const auth = this.authorizations.get(path);
|
|
1116
1250
|
if (!auth) return null;
|
|
1251
|
+
if (this.isForeignWorkspace(auth.groupId)) {
|
|
1252
|
+
this.authorizations.delete(path);
|
|
1253
|
+
return null;
|
|
1254
|
+
}
|
|
1117
1255
|
const now = Math.floor(Date.now() / 1e3);
|
|
1118
1256
|
const hasValid = auth.attestations.some((a) => a.expiresAt > now);
|
|
1119
1257
|
if (!hasValid) {
|
|
@@ -1138,6 +1276,13 @@ var AttestationCache = class {
|
|
|
1138
1276
|
);
|
|
1139
1277
|
}
|
|
1140
1278
|
const bounds = result.bounds ?? result.frame ?? {};
|
|
1279
|
+
let groupId;
|
|
1280
|
+
try {
|
|
1281
|
+
const summary = await this.spClient.getAuthorizationSummary(authorizationId);
|
|
1282
|
+
groupId = summary?.group_id ?? void 0;
|
|
1283
|
+
} catch {
|
|
1284
|
+
groupId = void 0;
|
|
1285
|
+
}
|
|
1141
1286
|
let signedCommitmentMode;
|
|
1142
1287
|
let subjects;
|
|
1143
1288
|
const firstBlob = result.attestations[0]?.blob;
|
|
@@ -1172,7 +1317,8 @@ var AttestationCache = class {
|
|
|
1172
1317
|
deferredCommitmentDomains: result.deferred_commitment_domains ?? [],
|
|
1173
1318
|
signedCommitmentMode,
|
|
1174
1319
|
subjects,
|
|
1175
|
-
complete: result.complete
|
|
1320
|
+
complete: result.complete,
|
|
1321
|
+
groupId
|
|
1176
1322
|
};
|
|
1177
1323
|
this.authorizations.set(auth.authorizationId, auth);
|
|
1178
1324
|
return auth;
|
|
@@ -1184,6 +1330,10 @@ var AttestationCache = class {
|
|
|
1184
1330
|
const now = Math.floor(Date.now() / 1e3);
|
|
1185
1331
|
const results = [];
|
|
1186
1332
|
for (const [path, auth] of this.authorizations) {
|
|
1333
|
+
if (this.isForeignWorkspace(auth.groupId)) {
|
|
1334
|
+
this.authorizations.delete(path);
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1187
1337
|
const hasValid = auth.attestations.some((a) => a.expiresAt > now);
|
|
1188
1338
|
if (hasValid) {
|
|
1189
1339
|
results.push(auth);
|
|
@@ -3179,6 +3329,11 @@ function asUrlChangedNotice(what) {
|
|
|
3179
3329
|
|
|
3180
3330
|
TELL THE USER: open ${uiUrl()} and sign in again to pair with the new Authority Server.`;
|
|
3181
3331
|
}
|
|
3332
|
+
function managedWrongTeamNotice(what) {
|
|
3333
|
+
return `${what} Suveren gateway is LOCKED because it is set up by IT for one specific team, and the signed-in account is no longer in that team (it left, or was removed). This is NOT an ended sign-in \u2014 signing in again will be refused until the account is back in that team.
|
|
3334
|
+
|
|
3335
|
+
TELL THE USER: ask your IT administrator, or sign in with an account that is in the team this gateway is set up for, at ${uiUrl()}. Nothing ran while unsigned.`;
|
|
3336
|
+
}
|
|
3182
3337
|
function lockedNotice(action, reason = "restart") {
|
|
3183
3338
|
const what = action ? `Cannot ${action}: the` : "The";
|
|
3184
3339
|
switch (reason) {
|
|
@@ -3190,6 +3345,8 @@ function lockedNotice(action, reason = "restart") {
|
|
|
3190
3345
|
return asTlsMismatchNotice(what);
|
|
3191
3346
|
case "as-url-changed":
|
|
3192
3347
|
return asUrlChangedNotice(what);
|
|
3348
|
+
case "managed-wrong-team":
|
|
3349
|
+
return managedWrongTeamNotice(what);
|
|
3193
3350
|
default:
|
|
3194
3351
|
return bootLockedNotice(what);
|
|
3195
3352
|
}
|
|
@@ -3643,6 +3800,20 @@ async function verifyTicket(cache, receipt, expected) {
|
|
|
3643
3800
|
}
|
|
3644
3801
|
}
|
|
3645
3802
|
|
|
3803
|
+
// src/lib/who-label.ts
|
|
3804
|
+
var UNKNOWN_DECIDER = "the person";
|
|
3805
|
+
async function resolveWhoLabel(archive, domainKey, userId) {
|
|
3806
|
+
if (!archive) return UNKNOWN_DECIDER;
|
|
3807
|
+
try {
|
|
3808
|
+
const dir = await getIdentityDirectory(archive);
|
|
3809
|
+
const did = dir.domainDids.get(domainKey) ?? dir.domainDids.get(userId);
|
|
3810
|
+
const name = did ? dir.names.get(did) : void 0;
|
|
3811
|
+
return name ?? UNKNOWN_DECIDER;
|
|
3812
|
+
} catch {
|
|
3813
|
+
return UNKNOWN_DECIDER;
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
|
|
3646
3817
|
// src/lib/arg-encoding.ts
|
|
3647
3818
|
function rfc2047Encode(value) {
|
|
3648
3819
|
if (!/[^\x00-\x7F]/.test(value)) return value;
|
|
@@ -3962,6 +4133,14 @@ async function attachImagePreview(args) {
|
|
|
3962
4133
|
return args;
|
|
3963
4134
|
}
|
|
3964
4135
|
}
|
|
4136
|
+
async function unreadableForNote(unreadableFor, state2) {
|
|
4137
|
+
if (unreadableFor.length === 0) return "";
|
|
4138
|
+
const labels = await Promise.all(
|
|
4139
|
+
unreadableFor.map((id) => resolveWhoLabel(state2.receiptArchive, id, id))
|
|
4140
|
+
);
|
|
4141
|
+
return `
|
|
4142
|
+
Note: ${labels.join(", ")} cannot read this proposal's content yet \u2014 no device of theirs has registered an end-to-end key.`;
|
|
4143
|
+
}
|
|
3965
4144
|
function applyMapping(m, value, execution) {
|
|
3966
4145
|
if (m.divisor) {
|
|
3967
4146
|
const numValue = typeof value === "number" ? value : Number(value);
|
|
@@ -4536,14 +4715,22 @@ function createGatedToolHandlerInner(tool, integrationManager2, state2) {
|
|
|
4536
4715
|
try {
|
|
4537
4716
|
const enrichedArgs = await attachImagePreview(args);
|
|
4538
4717
|
const wireExecCtx = wireExecutionContext(auth.profileId, execution);
|
|
4539
|
-
|
|
4718
|
+
let recipientUserIds = [...auth.deferredCommitmentDomains];
|
|
4719
|
+
try {
|
|
4720
|
+
const summary = await state2.spClient.getAuthorizationSummary(authzId);
|
|
4721
|
+
if (summary?.created_by) recipientUserIds = [summary.created_by, ...recipientUserIds];
|
|
4722
|
+
} catch {
|
|
4723
|
+
}
|
|
4724
|
+
recipientUserIds = [...new Set(recipientUserIds)];
|
|
4725
|
+
const { proposal, unreadableFor } = await state2.spClient.submitProposal({
|
|
4540
4726
|
authorizationId: authzId,
|
|
4541
4727
|
profileId: auth.profileId,
|
|
4542
4728
|
path: auth.path,
|
|
4543
4729
|
pendingDomains: auth.deferredCommitmentDomains,
|
|
4544
4730
|
tool: tool.namespacedName,
|
|
4545
4731
|
toolArgs: enrichedArgs,
|
|
4546
|
-
executionContext: wireExecCtx
|
|
4732
|
+
executionContext: wireExecCtx,
|
|
4733
|
+
recipientUserIds
|
|
4547
4734
|
});
|
|
4548
4735
|
const previewHash = await computeSubmissionPreviewHash(
|
|
4549
4736
|
integrationManager2,
|
|
@@ -4564,7 +4751,7 @@ function createGatedToolHandlerInner(tool, integrationManager2, state2) {
|
|
|
4564
4751
|
content: [{
|
|
4565
4752
|
type: "text",
|
|
4566
4753
|
text: `Awaiting commitment from domain${auth.deferredCommitmentDomains.length > 1 ? "s" : ""} "${auth.deferredCommitmentDomains.join('", "')}" for tool ${tool.originalName}.
|
|
4567
|
-
Proposal ID: ${proposal.id}. Check status with check-pending-commitments(proposal_id: "${proposal.id}").`
|
|
4754
|
+
Proposal ID: ${proposal.id}. Check status with check-pending-commitments(proposal_id: "${proposal.id}").` + await unreadableForNote(unreadableFor ?? [], state2)
|
|
4568
4755
|
}]
|
|
4569
4756
|
};
|
|
4570
4757
|
} catch (err) {
|
|
@@ -4714,7 +4901,7 @@ Proposal ID: ${proposal.id}. Check status with check-pending-commitments(proposa
|
|
|
4714
4901
|
try {
|
|
4715
4902
|
const enrichedArgs = await attachImagePreview(args);
|
|
4716
4903
|
const wireExecCtx = wireExecutionContext(auth.profileId, execution);
|
|
4717
|
-
const { proposal } = await state2.spClient.submitProposal({
|
|
4904
|
+
const { proposal, unreadableFor } = await state2.spClient.submitProposal({
|
|
4718
4905
|
authorizationId: authzId,
|
|
4719
4906
|
profileId: auth.profileId,
|
|
4720
4907
|
path: auth.path,
|
|
@@ -4722,7 +4909,8 @@ Proposal ID: ${proposal.id}. Check status with check-pending-commitments(proposa
|
|
|
4722
4909
|
tool: tool.namespacedName,
|
|
4723
4910
|
toolArgs: enrichedArgs,
|
|
4724
4911
|
executionContext: wireExecCtx,
|
|
4725
|
-
pendingApprovers: uniqueApprovers
|
|
4912
|
+
pendingApprovers: uniqueApprovers,
|
|
4913
|
+
recipientUserIds: uniqueApprovers
|
|
4726
4914
|
});
|
|
4727
4915
|
const previewHash = await computeSubmissionPreviewHash(
|
|
4728
4916
|
integrationManager2,
|
|
@@ -4743,7 +4931,7 @@ Proposal ID: ${proposal.id}. Check status with check-pending-commitments(proposa
|
|
|
4743
4931
|
content: [{
|
|
4744
4932
|
type: "text",
|
|
4745
4933
|
text: `Action exceeds team cap. Approval required from ${uniqueApprovers.length} reviewer(s).
|
|
4746
|
-
Proposal ID: ${proposal.id}. Use check-pending-commitments to track status.`
|
|
4934
|
+
Proposal ID: ${proposal.id}. Use check-pending-commitments to track status.` + await unreadableForNote(unreadableFor ?? [], state2)
|
|
4747
4935
|
}]
|
|
4748
4936
|
};
|
|
4749
4937
|
} catch (proposalErr) {
|
|
@@ -5310,17 +5498,54 @@ function listIntegrationsHandler(state2, integrationManager2) {
|
|
|
5310
5498
|
// src/tools/commitments.ts
|
|
5311
5499
|
import { ContentBindingError as ContentBindingError2 } from "@hap/core";
|
|
5312
5500
|
|
|
5313
|
-
// src/lib/
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5501
|
+
// src/lib/proposal-content.ts
|
|
5502
|
+
import { createHash as createHash3 } from "crypto";
|
|
5503
|
+
import { canonicalize } from "@hap/core";
|
|
5504
|
+
var ProposalContentError = class extends Error {
|
|
5505
|
+
};
|
|
5506
|
+
function computeProposalContentHash(toolArgs) {
|
|
5507
|
+
return `sha256:${createHash3("sha256").update(canonicalize(toolArgs)).digest("hex")}`;
|
|
5508
|
+
}
|
|
5509
|
+
async function resolveProposalToolArgs(proposal) {
|
|
5510
|
+
if (proposal.toolArgs) return proposal.toolArgs;
|
|
5511
|
+
if (!proposal.content) {
|
|
5512
|
+
throw new ProposalContentError(`Proposal ${proposal.id} carries neither tool_args nor content \u2014 cannot execute.`);
|
|
5513
|
+
}
|
|
5514
|
+
let plaintext;
|
|
5317
5515
|
try {
|
|
5318
|
-
|
|
5319
|
-
|
|
5320
|
-
|
|
5321
|
-
|
|
5516
|
+
plaintext = await cpDecryptContent(proposal.content);
|
|
5517
|
+
} catch (err) {
|
|
5518
|
+
throw new ProposalContentError(`Could not decrypt the approved content on this device: ${err instanceof Error ? err.message : String(err)}`);
|
|
5519
|
+
}
|
|
5520
|
+
let parsed;
|
|
5521
|
+
try {
|
|
5522
|
+
parsed = JSON.parse(plaintext);
|
|
5322
5523
|
} catch {
|
|
5323
|
-
|
|
5524
|
+
throw new ProposalContentError(`Proposal ${proposal.id}: decrypted content is not valid JSON.`);
|
|
5525
|
+
}
|
|
5526
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
5527
|
+
throw new ProposalContentError(`Proposal ${proposal.id}: decrypted content is not a JSON object.`);
|
|
5528
|
+
}
|
|
5529
|
+
const toolArgs = parsed;
|
|
5530
|
+
if (proposal.content.hash) {
|
|
5531
|
+
const actual = computeProposalContentHash(toolArgs);
|
|
5532
|
+
if (actual !== proposal.content.hash) {
|
|
5533
|
+
throw new ProposalContentError(
|
|
5534
|
+
`Proposal ${proposal.id}: decrypted content does not match its own fingerprint \u2014 refusing to execute.`
|
|
5535
|
+
);
|
|
5536
|
+
}
|
|
5537
|
+
}
|
|
5538
|
+
return toolArgs;
|
|
5539
|
+
}
|
|
5540
|
+
async function decryptRejectionNote(by) {
|
|
5541
|
+
if (!by) return void 0;
|
|
5542
|
+
if (typeof by.note === "string") return by.note;
|
|
5543
|
+
if (!by.noteEncrypted) return void 0;
|
|
5544
|
+
try {
|
|
5545
|
+
return await cpDecryptContent(by.noteEncrypted);
|
|
5546
|
+
} catch (err) {
|
|
5547
|
+
console.error("[Suveren MCP] Failed to decrypt a change-needed note:", err);
|
|
5548
|
+
return "(could not decrypt this note on this device)";
|
|
5324
5549
|
}
|
|
5325
5550
|
}
|
|
5326
5551
|
|
|
@@ -5332,6 +5557,7 @@ var installedExecutor = null;
|
|
|
5332
5557
|
function installCommittedExecutor(executor) {
|
|
5333
5558
|
installedExecutor = executor;
|
|
5334
5559
|
}
|
|
5560
|
+
var warnedSkippedProposalIds = /* @__PURE__ */ new Set();
|
|
5335
5561
|
function runCommitted(proposal, state2, integrationManager2) {
|
|
5336
5562
|
return installedExecutor ? installedExecutor.execute(proposal) : executeCommitted(proposal, state2, integrationManager2);
|
|
5337
5563
|
}
|
|
@@ -5355,15 +5581,28 @@ async function executeCommitted(proposal, state2, integrationManager2) {
|
|
|
5355
5581
|
const cachedAuth = state2.cache.getAllAuthorizations().find((a) => a.authorizationId === proposal.authorizationId);
|
|
5356
5582
|
const submitted = state2.proposalSubmissions.get(proposal.id);
|
|
5357
5583
|
if (!submitted) {
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
5361
|
-
|
|
5584
|
+
if (!warnedSkippedProposalIds.has(proposal.id)) {
|
|
5585
|
+
warnedSkippedProposalIds.add(proposal.id);
|
|
5586
|
+
console.error(
|
|
5587
|
+
`[Suveren MCP] Proposal ${proposal.id} is committed but has no local submission record on this gateway \u2014 leaving it for whichever gateway submitted it (this is normal for a different device of the same operator, or the AS lists a proposal we never submitted).`
|
|
5588
|
+
);
|
|
5589
|
+
}
|
|
5590
|
+
return { text: buildSkippedProposalNote(proposal.id), skipped: true };
|
|
5591
|
+
}
|
|
5592
|
+
let toolArgs;
|
|
5593
|
+
try {
|
|
5594
|
+
toolArgs = await resolveProposalToolArgs(proposal);
|
|
5595
|
+
} catch (err) {
|
|
5596
|
+
const msg = err instanceof ProposalContentError ? err.message : `Could not resolve the approved content on this device: ${err instanceof Error ? err.message : String(err)}`;
|
|
5597
|
+
return {
|
|
5598
|
+
text: `Proposal ${proposal.id}: blocked \u2014 ${msg} The action was not executed.`,
|
|
5599
|
+
isError: true
|
|
5600
|
+
};
|
|
5362
5601
|
}
|
|
5363
5602
|
{
|
|
5364
5603
|
const mismatches = [];
|
|
5365
5604
|
if (submitted.tool !== proposal.tool) mismatches.push("tool");
|
|
5366
|
-
if (submitted.toolArgsHash !== hashToolArgs(
|
|
5605
|
+
if (submitted.toolArgsHash !== hashToolArgs(toolArgs)) mismatches.push("arguments");
|
|
5367
5606
|
if (submitted.executionContextHash !== hashToolArgs(proposal.executionContext)) mismatches.push("executionContext");
|
|
5368
5607
|
if (submitted.authorizationId !== proposal.authorizationId) mismatches.push("authorizationId");
|
|
5369
5608
|
if (submitted.profileId !== proposal.profileId) mismatches.push("profileId");
|
|
@@ -5381,7 +5620,7 @@ async function executeCommitted(proposal, state2, integrationManager2) {
|
|
|
5381
5620
|
const binding = computeContentBinding(
|
|
5382
5621
|
proposal.profileId,
|
|
5383
5622
|
discovered,
|
|
5384
|
-
|
|
5623
|
+
toolArgs,
|
|
5385
5624
|
proposalActionType
|
|
5386
5625
|
);
|
|
5387
5626
|
const { receipt, idempotent } = await state2.spClient.postReceipt({
|
|
@@ -5395,7 +5634,12 @@ async function executeCommitted(proposal, state2, integrationManager2) {
|
|
|
5395
5634
|
executionContext: proposal.executionContext,
|
|
5396
5635
|
amount: typeof proposal.executionContext.amount === "number" ? proposal.executionContext.amount : void 0,
|
|
5397
5636
|
proposalId: proposal.id,
|
|
5398
|
-
toolArgs
|
|
5637
|
+
toolArgs,
|
|
5638
|
+
// PE1 — the fingerprint of what THIS gateway resolved (decrypted, or
|
|
5639
|
+
// read plain on a legacy row) and is about to execute. The AS compares
|
|
5640
|
+
// it against its own stored content_hash and refuses without echoing
|
|
5641
|
+
// content on a mismatch.
|
|
5642
|
+
proposalContentHash: computeProposalContentHash(toolArgs),
|
|
5399
5643
|
// Deliberately NO idempotencyKey: the spec has review-mode commits carry
|
|
5400
5644
|
// a proposalId INSTEAD of a key — the proposal's committed→executed
|
|
5401
5645
|
// transition is their replay protection, and the AS replays the ORIGINAL
|
|
@@ -5429,7 +5673,11 @@ async function executeCommitted(proposal, state2, integrationManager2) {
|
|
|
5429
5673
|
attestations: cachedAuth?.attestations,
|
|
5430
5674
|
// What the human actually approved, kept next to the receipt that cites
|
|
5431
5675
|
// it — otherwise `proposalId` is a pointer to a server we may not have.
|
|
5432
|
-
|
|
5676
|
+
// PE1: the archived copy carries the RESOLVED (decrypted on an
|
|
5677
|
+
// encrypted row) toolArgs, never the sealed `content` — this archive
|
|
5678
|
+
// is this gateway's own local evidence and has always stored it
|
|
5679
|
+
// readable, same as every legacy row.
|
|
5680
|
+
proposal: { ...proposal, toolArgs },
|
|
5433
5681
|
boundContent: binding?.boundContent
|
|
5434
5682
|
});
|
|
5435
5683
|
} catch (err) {
|
|
@@ -5488,7 +5736,7 @@ async function executeCommitted(proposal, state2, integrationManager2) {
|
|
|
5488
5736
|
ticketId: receiptId,
|
|
5489
5737
|
proposalId: proposal.id,
|
|
5490
5738
|
tool: proposal.tool,
|
|
5491
|
-
argsHash: hashToolArgs(
|
|
5739
|
+
argsHash: hashToolArgs(toolArgs)
|
|
5492
5740
|
});
|
|
5493
5741
|
if (!begun.ok) {
|
|
5494
5742
|
const prior = begun.existing;
|
|
@@ -5513,7 +5761,7 @@ async function executeCommitted(proposal, state2, integrationManager2) {
|
|
|
5513
5761
|
integrationManager2,
|
|
5514
5762
|
integrationId,
|
|
5515
5763
|
toolName,
|
|
5516
|
-
|
|
5764
|
+
toolArgs,
|
|
5517
5765
|
submitted.previewHash
|
|
5518
5766
|
);
|
|
5519
5767
|
if (changed) {
|
|
@@ -5525,7 +5773,7 @@ async function executeCommitted(proposal, state2, integrationManager2) {
|
|
|
5525
5773
|
}
|
|
5526
5774
|
}
|
|
5527
5775
|
try {
|
|
5528
|
-
let outgoingArgs =
|
|
5776
|
+
let outgoingArgs = toolArgs;
|
|
5529
5777
|
if (discovered && receiptId) {
|
|
5530
5778
|
if (shouldAttachFooter()) {
|
|
5531
5779
|
outgoingArgs = appendVerificationFooter(discovered, outgoingArgs, receiptId, cachedAuth?.subjects?.[0]);
|
|
@@ -5631,10 +5879,11 @@ Result: ${JSON.stringify(match.executionResult, null, 2)}` : " The action ran; t
|
|
|
5631
5879
|
by.userId
|
|
5632
5880
|
) : "a person";
|
|
5633
5881
|
if (outcome === "change_needed") {
|
|
5882
|
+
const note = await decryptRejectionNote(by);
|
|
5634
5883
|
return {
|
|
5635
5884
|
content: [{
|
|
5636
5885
|
type: "text",
|
|
5637
|
-
text: `Proposal ${match.id} was SENT BACK FOR A CHANGE by ${who}: "${
|
|
5886
|
+
text: `Proposal ${match.id} was SENT BACK FOR A CHANGE by ${who}: "${note ?? ""}". The action did not run. You may adjust it and propose again \u2014 a new proposal the person approves again. Do not resend the same values.`
|
|
5638
5887
|
}]
|
|
5639
5888
|
};
|
|
5640
5889
|
}
|
|
@@ -7719,13 +7968,13 @@ var reportBuiltin = (deps) => ({
|
|
|
7719
7968
|
});
|
|
7720
7969
|
|
|
7721
7970
|
// src/lib/cp-mandate.ts
|
|
7722
|
-
var
|
|
7723
|
-
var
|
|
7971
|
+
var CP_PORT3 = process.env.SUVEREN_CP_PORT ?? "3402";
|
|
7972
|
+
var CP_BASE3 = process.env.SUVEREN_CP_INTERNAL_URL ?? `http://127.0.0.1:${CP_PORT3}`;
|
|
7724
7973
|
async function controlPlaneMandate(dryRun, request) {
|
|
7725
7974
|
const secret = process.env.SUVEREN_INTERNAL_SECRET ?? "";
|
|
7726
7975
|
if (!secret) return { ok: false, message: "the gateway is not fully started (no control plane connection)." };
|
|
7727
7976
|
try {
|
|
7728
|
-
const res = await fetch(`${
|
|
7977
|
+
const res = await fetch(`${CP_BASE3}/internal/mandate`, {
|
|
7729
7978
|
method: "POST",
|
|
7730
7979
|
headers: { "Content-Type": "application/json", "X-Internal-Secret": secret },
|
|
7731
7980
|
body: JSON.stringify({ dryRun, request }),
|
|
@@ -8033,7 +8282,6 @@ function resolveTargetProfile(input) {
|
|
|
8033
8282
|
var CREATE_MANDATE_INPUT_SCHEMA = {
|
|
8034
8283
|
type: "object",
|
|
8035
8284
|
properties: {
|
|
8036
|
-
team: { type: "string", description: 'Team name or id, or "personal". Omit to use the workspace of your Delegation mandate.' },
|
|
8037
8285
|
// Short name only (e.g. "sales") — a full profile id is refused here, at input
|
|
8038
8286
|
// validation, before the tool ever runs (DL1 Interface contract).
|
|
8039
8287
|
profile: { type: "string", pattern: "^[a-z][a-z0-9-]*$", description: 'Profile short name, e.g. "sales" (newest version).' },
|
|
@@ -8123,7 +8371,6 @@ function setupBuiltin(deps) {
|
|
|
8123
8371
|
hideUnlessAuthorized: true,
|
|
8124
8372
|
// Shown like the mandate screen: limits and scope by the profile's own names.
|
|
8125
8373
|
approvalView: {
|
|
8126
|
-
team: { label: "Team" },
|
|
8127
8374
|
profile: { label: "Profile", kind: "profile" },
|
|
8128
8375
|
limits: { label: "Limits", kind: "profile-limits", profileArg: "profile" },
|
|
8129
8376
|
scope: { label: "Scope", kind: "profile-scope", profileArg: "profile" },
|
|
@@ -8132,7 +8379,7 @@ function setupBuiltin(deps) {
|
|
|
8132
8379
|
duration_hours: { label: "Valid for (hours)" },
|
|
8133
8380
|
title: { label: "Title" }
|
|
8134
8381
|
},
|
|
8135
|
-
preview: { tool: "describe_mandate_proposal", args: {
|
|
8382
|
+
preview: { tool: "describe_mandate_proposal", args: { profile: "profile", limits: "limits", scope: "scope", intent: "intent", mode: "mode", duration_hours: "duration_hours", title: "title" } }
|
|
8136
8383
|
},
|
|
8137
8384
|
// Internal preview tools (DL1): never listed, never callable by an
|
|
8138
8385
|
// agent (BuiltinTool.internal — enforced in index.ts and
|
|
@@ -8205,7 +8452,7 @@ ${body}`);
|
|
|
8205
8452
|
},
|
|
8206
8453
|
{
|
|
8207
8454
|
name: "create_mandate",
|
|
8208
|
-
description: "Propose a new mandate for the person running this gateway \u2014 the authority another tool needs (e.g. sales quotes up to a value). A person approves or rejects the proposal; only after approval is the mandate created, exactly as if they had signed it on the mandate screen. Checked against the profile before the proposal: unknown limits, a mode the profile does not allow, or a team where the person may not give this mandate are refused at once. Outside simulation mode, the profile and mode you may propose are themselves limited by your Delegation mandate's scope \u2014 Delegation itself can never be proposed. Create only.",
|
|
8455
|
+
description: "Propose a new mandate for the person running this gateway \u2014 the authority another tool needs (e.g. sales quotes up to a value). Always goes to the person's current workspace (their team, or Personal if they are not in one) \u2014 there is no way to name a different one. A person approves or rejects the proposal; only after approval is the mandate created, exactly as if they had signed it on the mandate screen. Checked against the profile before the proposal: unknown limits, a mode the profile does not allow, or a team where the person may not give this mandate are refused at once. Outside simulation mode, the profile and mode you may propose are themselves limited by your Delegation mandate's scope \u2014 Delegation itself can never be proposed. Create only.",
|
|
8209
8456
|
inputSchema: CREATE_MANDATE_INPUT_SCHEMA,
|
|
8210
8457
|
validate: async (args) => {
|
|
8211
8458
|
const fixed = delegationItselfRefusal(args);
|
|
@@ -8251,8 +8498,8 @@ ${body}`);
|
|
|
8251
8498
|
}
|
|
8252
8499
|
const { profile, shortName: targetShortName } = resolved;
|
|
8253
8500
|
const displayName = profile.name ?? targetShortName;
|
|
8254
|
-
const
|
|
8255
|
-
const workspace =
|
|
8501
|
+
const check = await controlPlaneMandate(true, mandateRequest(args));
|
|
8502
|
+
const workspace = check.ok && check.groupName ? check.groupName : "Personal";
|
|
8256
8503
|
const mode = typeof args.mode === "string" ? args.mode : "";
|
|
8257
8504
|
const structured = {
|
|
8258
8505
|
proposal: `New mandate \xB7 ${displayName}`,
|
|
@@ -8291,7 +8538,6 @@ ${body}`);
|
|
|
8291
8538
|
}
|
|
8292
8539
|
function mandateRequest(args) {
|
|
8293
8540
|
return {
|
|
8294
|
-
team: typeof args.team === "string" && args.team.trim() ? args.team.trim() : void 0,
|
|
8295
8541
|
profile: args.profile,
|
|
8296
8542
|
limits: args.limits,
|
|
8297
8543
|
scope: args.scope,
|
|
@@ -8324,6 +8570,10 @@ import { decodeMandateBlob as decodeMandateBlob3 } from "@hap/core";
|
|
|
8324
8570
|
// src/lib/committed-executor.ts
|
|
8325
8571
|
import { existsSync as existsSync20, mkdirSync as mkdirSync15, readFileSync as readFileSync21, unlinkSync as unlinkSync7, writeFileSync as writeFileSync15 } from "fs";
|
|
8326
8572
|
import { dirname as dirname15, join as join20 } from "path";
|
|
8573
|
+
function describeAutoExecutionLog(proposalId, tool, result) {
|
|
8574
|
+
if (result.skipped) return null;
|
|
8575
|
+
return result.isError ? `[Suveren MCP] Auto-exec proposal ${proposalId}: ${result.text}` : `[Suveren MCP] Auto-executed proposal ${proposalId}: ${tool}`;
|
|
8576
|
+
}
|
|
8327
8577
|
var CommittedExecutor = class {
|
|
8328
8578
|
constructor(run) {
|
|
8329
8579
|
this.run = run;
|
|
@@ -8524,7 +8774,7 @@ app.post("/internal/configure", internalOnly, (req, res) => {
|
|
|
8524
8774
|
});
|
|
8525
8775
|
app.post("/internal/clear-session", internalOnly, (req, res) => {
|
|
8526
8776
|
const reason = req.body?.reason;
|
|
8527
|
-
const validReason = reason === "as-key-mismatch" || reason === "as-tls-mismatch" || reason === "as-url-changed" ? reason : void 0;
|
|
8777
|
+
const validReason = reason === "as-key-mismatch" || reason === "as-tls-mismatch" || reason === "as-url-changed" || reason === "managed-wrong-team" ? reason : void 0;
|
|
8528
8778
|
state.spClient.clearSession(validReason);
|
|
8529
8779
|
console.error(`[Suveren MCP] Session cleared by control-plane (${validReason ?? "expired"})`);
|
|
8530
8780
|
res.json({ ok: true });
|
|
@@ -8598,6 +8848,7 @@ app.post("/internal/run-committed", internalOnly, (_req, res) => {
|
|
|
8598
8848
|
res.json({ ok: true });
|
|
8599
8849
|
});
|
|
8600
8850
|
app.post("/internal/resync-gates", internalOnly, async (_req, res) => {
|
|
8851
|
+
await state.cache.refreshActiveWorkspace();
|
|
8601
8852
|
const gates = state.gateStore.getAll();
|
|
8602
8853
|
if (gates.length === 0) {
|
|
8603
8854
|
res.json({ ok: true, synced: 0 });
|
|
@@ -9179,7 +9430,13 @@ app.listen(port, bindHost, () => {
|
|
|
9179
9430
|
"domain" in by ? by.domain : by.userId,
|
|
9180
9431
|
by.userId
|
|
9181
9432
|
);
|
|
9182
|
-
const
|
|
9433
|
+
const note = await decryptRejectionNote(by);
|
|
9434
|
+
const proposalWithDecryptedNote = {
|
|
9435
|
+
...proposal,
|
|
9436
|
+
rejectedBy: proposal.rejectedBy ? { ...proposal.rejectedBy, note } : null,
|
|
9437
|
+
approverRejectedBy: proposal.approverRejectedBy ? { ...proposal.approverRejectedBy, note } : void 0
|
|
9438
|
+
};
|
|
9439
|
+
const entry = outcomeEntryFor(proposalWithDecryptedNote, submittedHere, who, Date.now());
|
|
9183
9440
|
if (entry) state.outcomeOutbox.recordIfNew(entry);
|
|
9184
9441
|
}
|
|
9185
9442
|
} catch {
|
|
@@ -9195,10 +9452,9 @@ app.listen(port, bindHost, () => {
|
|
|
9195
9452
|
const committed = await state.spClient.getCommittedProposals();
|
|
9196
9453
|
for (const proposal of committed) {
|
|
9197
9454
|
try {
|
|
9198
|
-
const
|
|
9199
|
-
|
|
9200
|
-
|
|
9201
|
-
);
|
|
9455
|
+
const result = await committedExecutor.execute(proposal);
|
|
9456
|
+
const line = describeAutoExecutionLog(proposal.id, proposal.tool, result);
|
|
9457
|
+
if (line) console.error(line);
|
|
9202
9458
|
} catch (err) {
|
|
9203
9459
|
console.error(`[Suveren MCP] Failed to execute proposal ${proposal.id}:`, err);
|
|
9204
9460
|
}
|