@sema-agent/server 7.92.1 → 7.93.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.
Files changed (60) hide show
  1. package/README.md +1 -0
  2. package/README.zh-CN.md +1 -0
  3. package/dist/approval-ask-machine.d.ts +1 -1
  4. package/dist/approval-card.d.ts +10 -7
  5. package/dist/approval-card.js +3 -3
  6. package/dist/bench/s1/arms.js +5 -6
  7. package/dist/bench/s1/live-deps.js +2 -2
  8. package/dist/boot/resolve-spec.js +1 -0
  9. package/dist/boot/stage-06-runners.js +3 -2
  10. package/dist/boot/stores.js +9 -0
  11. package/dist/capabilities/mcp-probe.d.ts +290 -0
  12. package/dist/capabilities/mcp-probe.js +318 -0
  13. package/dist/http/resume-legs.js +12 -10
  14. package/dist/http/route-ctx.d.ts +3 -0
  15. package/dist/http/routes/capabilities.d.ts +17 -0
  16. package/dist/http/routes/capabilities.js +93 -1
  17. package/dist/http/routes/memory-origin.d.ts +1 -1
  18. package/dist/http/routes/rules.js +49 -13
  19. package/dist/http/routes/runs.js +3 -2
  20. package/dist/http/routes/session-sync.js +24 -12
  21. package/dist/http/routes/sessions.js +12 -8
  22. package/dist/http/routes/tasks.js +13 -13
  23. package/dist/http/send.d.ts +11 -0
  24. package/dist/http/send.js +9 -0
  25. package/dist/http/server.js +3 -2
  26. package/dist/http/wire-types.d.ts +17 -1
  27. package/dist/leader/fanout.js +3 -3
  28. package/dist/leader/leader.js +4 -4
  29. package/dist/leader/repair-wire.js +2 -2
  30. package/dist/leader/wire.js +2 -2
  31. package/dist/observability/metrics.js +2 -0
  32. package/dist/permission-rule-vocab.d.ts +9 -9
  33. package/dist/plugins/file-run-store.js +3 -3
  34. package/dist/plugins/memory-run-store.js +3 -3
  35. package/dist/plugins/remote-env-host.d.ts +5 -0
  36. package/dist/plugins/remote-env-host.js +39 -37
  37. package/dist/plugins/run-store-sql.js +3 -3
  38. package/dist/plugins/schema-consistency.d.ts +88 -0
  39. package/dist/plugins/schema-consistency.js +202 -0
  40. package/dist/plugins/schema-ensure-legs.d.ts +55 -0
  41. package/dist/plugins/schema-ensure-legs.js +87 -0
  42. package/dist/plugins/session-policy-store-sql.js +23 -5
  43. package/dist/request-key-closure.js +1 -1
  44. package/dist/rules-consent.d.ts +42 -4
  45. package/dist/rules-consent.js +23 -7
  46. package/dist/run-local.js +2 -2
  47. package/dist/runs.js +14 -14
  48. package/dist/task-mcp.d.ts +32 -0
  49. package/dist/task-mcp.js +7 -0
  50. package/dist/terminal.d.ts +58 -4
  51. package/dist/terminal.js +28 -6
  52. package/dist/tool-approval.d.ts +22 -1
  53. package/dist/tool-approval.js +1 -0
  54. package/dist/trace/core-keyset-guard.d.ts +3 -3
  55. package/dist/trace/ledger-sink.js +3 -2
  56. package/dist/trace/project.d.ts +24 -0
  57. package/dist/trace/project.js +40 -37
  58. package/dist/trace/wire-projection-faces.d.ts +209 -0
  59. package/dist/trace/wire-projection-faces.js +62 -0
  60. package/package.json +3 -3
@@ -0,0 +1,318 @@
1
+ import { probeMcpServers } from "@sema-agent/core";
2
+ import { createHash } from "node:crypto";
3
+ import { createFixedWindowRateLimiter } from "../device-enrollment.js";
4
+ import { canonicalJson } from "../canonical-json.js";
5
+ import { echoKeyName, keyClosureClause, scanKeyClosure } from "../request-key-closure.js";
6
+ import { CONTENT_ORIGIN_WORDS, describeBadContentOrigin, isContentOriginWord } from "../mcp-content-origin.js";
7
+ import { HTTP_MCP_TRANSPORT_KEYS, MAX_REQUEST_MCP_SERVERS, MCP_ENTRY_EMPTY_ARRAY_KEYS, MCP_ENTRY_EMPTY_RECORD_KEYS, MCP_SERVER_ENTRY_KEYS, MCP_TRANSPORT_EMPTY_ARRAY_KEYS, MCP_TRANSPORT_EMPTY_RECORD_KEYS, STDIO_MCP_TRANSPORT_KEYS, validateRequestMcp, } from "../task-mcp.js";
8
+ import { buildDisplaySafeUrl } from "../trace/redact.js";
9
+ import { projectWiringManifestMcpRows } from "../trace/project.js";
10
+ export const MCP_PROBE_REFUSAL_CODES = [
11
+ "capability.mcp_injection_required",
12
+ "limit.rate_exceeded",
13
+ "request.body_shape",
14
+ "request.field_invalid",
15
+ "state.mcp_probe_incomplete",
16
+ ];
17
+ export const MCP_PROBE_HTTP_STATUS = {
18
+ "capability.mcp_injection_required": 501,
19
+ "limit.rate_exceeded": 429,
20
+ "request.body_shape": 400,
21
+ "request.field_invalid": 400,
22
+ "state.mcp_probe_incomplete": 503,
23
+ };
24
+ export const MCP_PROBE_RATE_POLICY = { limit: 12, windowMs: 60_000 };
25
+ export const MCP_PROBE_CACHE_TTL_MS = 30_000;
26
+ export const MCP_PROBE_CACHE_MAX_ENTRIES = 256;
27
+ export const MCP_PROBE_MAX_INFLIGHT = 32;
28
+ export const MCP_PROBE_VERDICT_TIMEOUT_MS = 10_000;
29
+ export function buildMcpProbeWalkTimeoutMs(declarations) {
30
+ return Math.ceil(Math.max(1, declarations) / MCP_PROBE_CONCURRENCY) * MCP_PROBE_VERDICT_TIMEOUT_MS + MCP_PROBE_RESIDUE_SLACK_MS;
31
+ }
32
+ export const MCP_PROBE_CONCURRENCY = 8;
33
+ export const MCP_PROBE_RESIDUE_SLACK_MS = 4_000;
34
+ export const MCP_PROBE_OUTCOMES = ["probed", "cached", "empty", "gate_closed", "rate_limited", "bad_request", "failed"];
35
+ const PROBE_BODY_KEYS = ["mcpServers"];
36
+ const ACCEPTED_PROBE_BODY = new Set(PROBE_BODY_KEYS);
37
+ const NO_TOLERATED_KEYS = new Set();
38
+ const shapeRefusal = (message, unknownKeys) => ({
39
+ errorCode: "request.body_shape",
40
+ message,
41
+ ...(unknownKeys !== undefined ? { unknownKeys } : {}),
42
+ });
43
+ const fieldRefusal = (message) => ({ errorCode: "request.field_invalid", message });
44
+ function entryKeyIssue(entry, at) {
45
+ const buckets = { unknown: [], unsupported: [] };
46
+ scanKeyClosure(entry, `${at}.`, new Set(MCP_SERVER_ENTRY_KEYS), NO_TOLERATED_KEYS, buckets);
47
+ const t = entry.transport;
48
+ if (t !== null && typeof t === "object" && !Array.isArray(t)) {
49
+ const kind = t.kind;
50
+ const keys = kind === "stdio" ? STDIO_MCP_TRANSPORT_KEYS : kind === "http" ? HTTP_MCP_TRANSPORT_KEYS : undefined;
51
+ if (keys !== undefined)
52
+ scanKeyClosure(t, `${at}.transport.`, new Set(keys), NO_TOLERATED_KEYS, buckets);
53
+ }
54
+ if (buckets.unknown.length === 0)
55
+ return null;
56
+ buckets.unknown.sort();
57
+ return shapeRefusal(`body: unrecognized key(s) on an MCP declaration — refused rather than dropped, because a dropped key reads as "in effect" to the caller. ` +
58
+ `${keyClosureClause(buckets.unknown, "unknown (not an mcpServers[] key — check the spelling)")}. ` +
59
+ `Accepted per entry: ${[...MCP_SERVER_ENTRY_KEYS].sort().join(",")} (transport.kind "stdio": ${[...STDIO_MCP_TRANSPORT_KEYS].sort().join(",")}; "http": ${[...HTTP_MCP_TRANSPORT_KEYS].sort().join(",")})`, buckets.unknown);
60
+ }
61
+ function entryValueIssue(entry, at) {
62
+ const read = validateRequestMcp([entry]);
63
+ if (read === null)
64
+ return fieldRefusal(`${at} is not a readable MCP declaration`);
65
+ const dropped = read.dropped[0];
66
+ if (dropped !== undefined || read.ok.length !== 1) {
67
+ const field = dropped?.field ?? "entry";
68
+ return fieldRefusal(`${at}.${field} is not a usable value${urlEchoFor(entry, field)} — this endpoint refuses the whole list rather than dialing a silently shortened one` +
69
+ ` (a task submission would instead drop just this declaration and mount its siblings; a probe answering "can I use these" must not quietly answer about fewer)`);
70
+ }
71
+ if (read.badContentOrigin.length > 0) {
72
+ return fieldRefusal(`${at}.contentOrigin must be one of ${CONTENT_ORIGIN_WORDS.map((w) => `"${w}"`).join(" | ")} — got ${describeBadContentOrigin(entry.contentOrigin)} (omit the key for the default: this server's tools classify as external content)`);
73
+ }
74
+ return declarationDowngradeIssue(entry, read.ok[0], at);
75
+ }
76
+ function declarationDowngradeIssue(entry, spec, at) {
77
+ if (spec === undefined)
78
+ return fieldRefusal(`${at} is not a readable MCP declaration`);
79
+ const diff = jsonPathDiff(stripEquivalentEmpties(entry), stripEquivalentEmpties(spec), at);
80
+ if (diff.length === 0)
81
+ return null;
82
+ diff.sort();
83
+ return fieldRefusal(`${keyClosureClause(diff, "unusable value(s)")} — this deployment's declaration reader could not use these exactly as written, and a probe must not dial a configuration that differs from the one you declared` +
84
+ " (a task submission would instead keep the server and silently drop just these, which is why the two faces disagree here on purpose; this endpoint refuses, because the reading would then describe a server you did not ask about)");
85
+ }
86
+ function stripEquivalentEmpties(entry) {
87
+ const emptyRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0;
88
+ const emptyArray = (v) => Array.isArray(v) && v.length === 0;
89
+ const keep = (bag, recordKeys, arrayKeys) => {
90
+ const out = {};
91
+ for (const [k, v] of Object.entries(bag)) {
92
+ if (recordKeys.includes(k) && emptyRecord(v))
93
+ continue;
94
+ if (arrayKeys.includes(k) && emptyArray(v))
95
+ continue;
96
+ out[k] = v;
97
+ }
98
+ return out;
99
+ };
100
+ const top = keep(entry, MCP_ENTRY_EMPTY_RECORD_KEYS, MCP_ENTRY_EMPTY_ARRAY_KEYS);
101
+ const t = top.transport;
102
+ if (t !== null && typeof t === "object" && !Array.isArray(t)) {
103
+ top.transport = keep(t, MCP_TRANSPORT_EMPTY_RECORD_KEYS, MCP_TRANSPORT_EMPTY_ARRAY_KEYS);
104
+ }
105
+ return top;
106
+ }
107
+ function jsonPathDiff(a, b, path) {
108
+ if (canonicalJson(a) === canonicalJson(b))
109
+ return [];
110
+ const aRec = a !== null && typeof a === "object" && !Array.isArray(a) ? a : undefined;
111
+ const bRec = b !== null && typeof b === "object" && !Array.isArray(b) ? b : undefined;
112
+ if (aRec !== undefined && bRec !== undefined) {
113
+ const out = [];
114
+ for (const k of new Set([...Object.keys(aRec), ...Object.keys(bRec)]))
115
+ out.push(...jsonPathDiff(aRec[k], bRec[k], `${path}.${k}`));
116
+ return out.length > 0 ? out : [path];
117
+ }
118
+ if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
119
+ const out = [];
120
+ for (let i = 0; i < a.length; i++)
121
+ out.push(...jsonPathDiff(a[i], b[i], `${path}[${i}]`));
122
+ return out.length > 0 ? out : [path];
123
+ }
124
+ return [path];
125
+ }
126
+ function urlEchoFor(entry, field) {
127
+ if (field !== "transport.url")
128
+ return "";
129
+ const t = entry.transport;
130
+ const raw = t !== null && typeof t === "object" ? t.url : undefined;
131
+ return typeof raw === "string" ? ` (got "${buildDisplaySafeUrl(raw)}"; an MCP declaration's address must be an http(s) URL — put credentials in transport.headers, never in the URL)` : "";
132
+ }
133
+ export function readMcpProbeBody(raw) {
134
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
135
+ return { ok: false, refusal: shapeRefusal("body must be a JSON object of the shape { mcpServers: [ … ] } (the same declaration list a task submits)") };
136
+ const buckets = { unknown: [], unsupported: [] };
137
+ scanKeyClosure(raw, "", ACCEPTED_PROBE_BODY, NO_TOLERATED_KEYS, buckets);
138
+ if (buckets.unknown.length > 0) {
139
+ buckets.unknown.sort();
140
+ return {
141
+ ok: false,
142
+ refusal: shapeRefusal(`body: unrecognized top-level key(s) — refused rather than dropped, because a dropped key reads as "in effect" to the caller. ` +
143
+ `${keyClosureClause(buckets.unknown, "unknown (this face takes only the declaration list)")}. ` +
144
+ `Accepted: ${[...ACCEPTED_PROBE_BODY].sort().join(",")}`, buckets.unknown),
145
+ };
146
+ }
147
+ const list = raw.mcpServers;
148
+ if (!Array.isArray(list))
149
+ return { ok: false, refusal: shapeRefusal("mcpServers must be an array of MCP server declarations (send [] to ask about nothing)") };
150
+ if (list.length > MAX_REQUEST_MCP_SERVERS) {
151
+ return { ok: false, refusal: fieldRefusal(`mcpServers carries ${list.length} declarations, above this deployment's ceiling of ${MAX_REQUEST_MCP_SERVERS} — the probe dials every one of them, so the list is refused whole rather than silently truncated to a partial answer`) };
152
+ }
153
+ for (let i = 0; i < list.length; i++) {
154
+ const at = `mcpServers[${i}]`;
155
+ const entry = list[i];
156
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry))
157
+ return { ok: false, refusal: shapeRefusal(`${at} must be an object`) };
158
+ const keyIssue = entryKeyIssue(entry, at);
159
+ if (keyIssue)
160
+ return { ok: false, refusal: keyIssue };
161
+ const valueIssue = entryValueIssue(entry, at);
162
+ if (valueIssue)
163
+ return { ok: false, refusal: valueIssue };
164
+ }
165
+ const validated = validateRequestMcp(list);
166
+ if (validated === null || validated.ok.length !== list.length) {
167
+ return {
168
+ ok: false,
169
+ refusal: fieldRefusal(`mcpServers: this deployment's declaration reader answered differently for the list (${validated === null ? 0 : validated.ok.length}) than it did entry by entry (${list.length}),` +
170
+ " so the probe refuses rather than dialing a silently shortened list. This is a belt on a path that should not be reachable (the reader is deterministic and the list was already capped)," +
171
+ " so nothing in your body needs changing — if you see it, this deployment has a bug worth reporting"),
172
+ };
173
+ }
174
+ return { ok: true, specs: validated.ok };
175
+ }
176
+ export function buildMcpProbeIdentity(principal) {
177
+ return principal !== undefined && principal.length > 0 ? principal : "local";
178
+ }
179
+ export function buildMcpProbeCacheKey(principal, specs) {
180
+ return `${buildMcpProbeIdentity(principal)}::${createHash("sha256").update(canonicalJson(specs), "utf8").digest("hex")}`;
181
+ }
182
+ export function buildMcpProbeRateKey(principal) {
183
+ return `mcp-probe:${buildMcpProbeIdentity(principal)}`;
184
+ }
185
+ export function createMcpProbeLocal() {
186
+ const rate = createFixedWindowRateLimiter(MCP_PROBE_RATE_POLICY);
187
+ const cache = new Map();
188
+ const pruneCache = (now) => {
189
+ const settled = (v) => v.settledAt !== undefined;
190
+ for (const [k, v] of cache)
191
+ if (v.settledAt !== undefined && now - v.settledAt > MCP_PROBE_CACHE_TTL_MS)
192
+ cache.delete(k);
193
+ if (cache.size <= MCP_PROBE_CACHE_MAX_ENTRIES)
194
+ return;
195
+ const victims = [...cache.entries()].filter(([, v]) => settled(v)).sort((a, b) => (a[1].settledAt ?? 0) - (b[1].settledAt ?? 0));
196
+ for (const [k] of victims) {
197
+ if (cache.size <= MCP_PROBE_CACHE_MAX_ENTRIES)
198
+ break;
199
+ cache.delete(k);
200
+ }
201
+ };
202
+ return {
203
+ check: async (key) => await rate.check(key),
204
+ cached: (key, now) => {
205
+ const hit = cache.get(key);
206
+ if (hit === undefined)
207
+ return undefined;
208
+ if (hit.settledAt === undefined)
209
+ return hit.body;
210
+ if (now - hit.settledAt > MCP_PROBE_CACHE_TTL_MS) {
211
+ cache.delete(key);
212
+ return undefined;
213
+ }
214
+ return hit.body;
215
+ },
216
+ remember: (key, at, walk, now = () => Date.now()) => {
217
+ cache.set(key, { settledAt: undefined, body: walk.answer });
218
+ void walk.dials.then(() => {
219
+ const cur = cache.get(key);
220
+ if (cur?.body === walk.answer)
221
+ cur.settledAt = now();
222
+ }, () => {
223
+ const cur = cache.get(key);
224
+ if (cur?.body === walk.answer)
225
+ cache.delete(key);
226
+ });
227
+ pruneCache(at);
228
+ },
229
+ size: () => cache.size,
230
+ inflight: () => [...cache.values()].filter((v) => v.settledAt === undefined).length,
231
+ };
232
+ }
233
+ export function createMcpProbeWalk(specs, principal, now = Date.now) {
234
+ const bound = buildMcpProbeWalkTimeoutMs(specs.length);
235
+ const controller = new AbortController();
236
+ const dials = probeMcpServers(specs, {
237
+ ...(principal !== undefined && principal.length > 0 ? { principal } : {}),
238
+ timeoutMs: MCP_PROBE_VERDICT_TIMEOUT_MS,
239
+ concurrency: MCP_PROBE_CONCURRENCY,
240
+ signal: controller.signal,
241
+ });
242
+ dials.catch(() => undefined);
243
+ let timer;
244
+ const deadline = new Promise((_resolve, reject) => {
245
+ timer = setTimeout(() => {
246
+ controller.abort(new McpProbeIncomplete(bound));
247
+ reject(new McpProbeIncomplete(bound));
248
+ }, bound);
249
+ });
250
+ const answer = (async () => {
251
+ try {
252
+ const result = await Promise.race([dials, deadline]);
253
+ const rows = projectWiringManifestMcpRows(result.entries) ?? [];
254
+ if (rows.length !== specs.length) {
255
+ throw new Error(`mcp probe row count ${rows.length} does not match the ${specs.length} declarations it must align with by index — refusing to publish a list a consumer would mis-pair`);
256
+ }
257
+ return { probedAt: new Date(now()).toISOString(), ttlSec: Math.floor(MCP_PROBE_CACHE_TTL_MS / 1000), servers: rows };
258
+ }
259
+ finally {
260
+ if (timer !== undefined)
261
+ clearTimeout(timer);
262
+ }
263
+ })();
264
+ answer.catch(() => undefined);
265
+ return { answer, dials };
266
+ }
267
+ export class McpProbeIncomplete extends Error {
268
+ boundMs;
269
+ constructor(boundMs) {
270
+ super("mcp probe walk did not settle within this deployment's bound");
271
+ this.boundMs = boundMs;
272
+ this.name = "McpProbeIncomplete";
273
+ }
274
+ }
275
+ export function buildMcpProbeIncompleteRefusal(boundMs) {
276
+ return {
277
+ errorCode: "state.mcp_probe_incomplete",
278
+ message: `the MCP probe did not finish within ${boundMs / 1000}s, so this endpoint has no honest per-server answer to give and will not return a partial one that reads as complete — ` +
279
+ "the usual cause is a declared server that completes its handshake and then stops answering, which the engine cannot cancel (its dial owns no deadline). " +
280
+ "Retrying may well succeed; if it keeps happening, the declaration that never answers is the thing to fix",
281
+ };
282
+ }
283
+ export function buildEmptyMcpProbeFace(now = Date.now()) {
284
+ return { probedAt: new Date(now).toISOString(), ttlSec: Math.floor(MCP_PROBE_CACHE_TTL_MS / 1000), servers: [] };
285
+ }
286
+ export function buildMcpInjectionRefusal() {
287
+ return {
288
+ errorCode: "capability.mcp_injection_required",
289
+ message: "this deployment does not honor caller-supplied MCP declarations, so it will not dial them on your behalf: " +
290
+ "either it is multi-tenant (REQUIRE_PRINCIPAL=true — a tenant must not make the shared worker run an arbitrary command or connect to a body-chosen address), " +
291
+ "or `mcp` is an administratively locked config key, or its compliance posture denies `mcp_servers`. " +
292
+ "Read `capabilities.mcpInjection` to tell before asking; `GET /v1/capabilities/mcp` reports this deployment's OWN declared servers and stays available",
293
+ };
294
+ }
295
+ export function buildMcpProbeRateRefusal() {
296
+ return {
297
+ errorCode: "limit.rate_exceeded",
298
+ message: "MCP probe rate limit exceeded — this face dials every declared server, so it is capped per caller identity " +
299
+ `(${MCP_PROBE_RATE_POLICY.limit} per ${MCP_PROBE_RATE_POLICY.windowMs / 1000}s per replica) independently of every other limit on this deployment; ` +
300
+ `a reading already taken is reused for ${MCP_PROBE_CACHE_TTL_MS / 1000}s and does NOT consume the cap`,
301
+ };
302
+ }
303
+ export function buildMcpProbeInflightRefusal() {
304
+ return {
305
+ errorCode: "limit.rate_exceeded",
306
+ message: `too many MCP probes are still dialing on this replica (${MCP_PROBE_MAX_INFLIGHT} at once) — this endpoint refuses rather than opening one more set of connections, ` +
307
+ "and it deliberately does not drop an in-flight walk to make room (that would remove the one guarantee that keeps a repeated question from re-dialing). " +
308
+ `Retry-After is a HINT, not a promise: each server's verdict is bounded (${MCP_PROBE_VERDICT_TIMEOUT_MS / 1000}s) but a peer that answers the handshake and then stalls holds its dial with no deadline at all`,
309
+ };
310
+ }
311
+ export function buildMcpProbeDeclarationRefusal() {
312
+ return {
313
+ errorCode: "request.field_invalid",
314
+ message: "mcpServers: the engine refused this declaration SET as a whole before dialing anything — the usual cause is two declarations that would mount under one tool-namespace prefix, " +
315
+ "which a task submission refuses the same way, so a probe must not answer as if one of them had been dropped. Give every declared server a name that stays distinct after namespacing",
316
+ };
317
+ }
318
+ //# sourceMappingURL=mcp-probe.js.map
@@ -1,10 +1,10 @@
1
1
  import { redactErrorMessage, redactHead } from "../observability/run-terminal.js";
2
2
  import { createHash } from "node:crypto";
3
- import { uuidv7, resumeWithVerification, CheckpointError, defaultTaskRegistry, validatePendingSteer, GLOBAL_USAGE_KEY, terminalProjection, PAUSE_REGISTRY } from "@sema-agent/core";
3
+ import { uuidv7, resumeWithVerification, CheckpointError, defaultTaskRegistry, validatePendingSteer, GLOBAL_USAGE_KEY, PAUSE_REGISTRY } from "@sema-agent/core";
4
4
  import { decideParkedAgent, denyWorkflowParksUnreadable, findParkOrigin, planWorkflowParkedDecide } from "../parked-decide.js";
5
5
  import { HttpError, decodeCheckpointScope, CHECKPOINT_PUBLIC_SCOPE } from "../security.js";
6
6
  import { evictIfConflict, parkToolCallId, TurnAnchorCapture, HEARTBEAT_MS, markChildrenStoppedByUserOnAbort } from "../runs.js";
7
- import { failureCodeOf, nestedPauseOf, stripResumeToken } from "../terminal.js";
7
+ import { failureCodeOf, nestedPauseOf, sealResultForExit, outwardPlane } from "../terminal.js";
8
8
  import { mintCancelledResult } from "../run-cancel-context.js";
9
9
  import {} from "../host-decision.js";
10
10
  import { readTurnActivityMs, recordTurnActivity } from "../turn-activity.js";
@@ -30,6 +30,7 @@ import { mintAndDriveDurableRun } from "./durable-run-leg.js";
30
30
  import { createSessionsLocal } from "./routes/sessions.js";
31
31
  import { createSessionSyncLocal } from "./routes/session-sync.js";
32
32
  import { createImagesLocal } from "./routes/images.js";
33
+ import { createCapabilitiesLocal } from "./routes/capabilities.js";
33
34
  import { gatedPrincipal, explicitOperatorOk } from "./principal-gate.js";
34
35
  import { buildActiveRunConflict, resumeEntryForGate } from "./active-run-conflict.js";
35
36
  import { commitPlacement, devicePlacementErrorBody } from "./device-placement.js";
@@ -550,7 +551,7 @@ export function createResumeLegs(ctx) {
550
551
  resumeLegLive = true;
551
552
  const vr = await resumeWithVerification(legRunner, token, outcome, { ...resumeTaskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, resumeObjective, verifyRounds, verifyInternals);
552
553
  await commitResumeCtx();
553
- let safe = stripResumeToken(vr);
554
+ let safe = sealResultForExit(vr);
554
555
  const vrNested = nestedPauseOf(vr.terminal);
555
556
  const reParkStatus = nestedParkStatusOf(vr);
556
557
  const reSuspended = vrNested !== undefined;
@@ -679,7 +680,7 @@ export function createResumeLegs(ctx) {
679
680
  if (taskId)
680
681
  steerableRuns.set(taskId, stream);
681
682
  if (!taskId || !rs)
682
- return await stream.result();
683
+ return sealResultForExit(await stream.result());
683
684
  onAccepted?.(acceptedResumingReceipt(taskId, sessionId));
684
685
  for await (const ev of stream) {
685
686
  recordTurnActivity(taskId);
@@ -790,7 +791,7 @@ export function createResumeLegs(ctx) {
790
791
  }
791
792
  await flush();
792
793
  const r = await stream.result();
793
- const rPark = r.terminal.kind === "paused" ? terminalProjection(r.terminal).status : undefined;
794
+ const rPark = r.terminal.kind === "paused" ? outwardPlane(r.terminal).status : undefined;
794
795
  if (rPark === "suspended") {
795
796
  const parkCallId = await parkToolCallId(r, deps.checkpointStore);
796
797
  await append("suspended", { gate: r.terminal.kind === "paused" ? r.terminal.gate : null, ...(parkCallId !== undefined ? { toolCallId: parkCallId } : {}) });
@@ -812,7 +813,7 @@ export function createResumeLegs(ctx) {
812
813
  }
813
814
  return r;
814
815
  }
815
- const safe = await attachModelUsage(stripResumeToken(r), { append, getEvents: (id, a) => rs.getEvents(id, a), modelUsage: deps.modelUsage, taskId });
816
+ const safe = await attachModelUsage(sealResultForExit(r), { append, getEvents: (id, a) => rs.getEvents(id, a), modelUsage: deps.modelUsage, taskId });
816
817
  await append("done", { result: safe });
817
818
  if (taskConfig.suggestNextPrompts && safe.terminal.kind === "completed") {
818
819
  const suggestions = await stream.suggestions().catch(() => []);
@@ -951,16 +952,17 @@ export function createResumeLegs(ctx) {
951
952
  }
952
953
  evictIfConflict(deps.runner, sessionId, result);
953
954
  const checkpointReopened = reopenConfirmedPark;
954
- const resumePlane = terminalProjection(result.terminal);
955
+ const sealed = sealResultForExit(result);
956
+ const resumePlane = outwardPlane(sealed.terminal);
955
957
  if (taskId && deps.runStore) {
956
958
  if (resumePlane.status === "suspended" || checkpointReopened)
957
959
  await deps.runStore.setSuspended(taskId);
958
960
  else if (resumePlane.status === "needs_review")
959
961
  await deps.runStore.setNeedsReview(taskId);
960
962
  else
961
- await deps.runStore.setTerminal(taskId, resumePlane.status, stripResumeToken(result), resumePlane.errorMessage ?? null);
963
+ await deps.runStore.setTerminal(taskId, resumePlane.status, sealed, resumePlane.errorMessage ?? null);
962
964
  }
963
- settleFleet(checkpointReopened ? "suspended" : resumePlane.status, fleetRunResiduals(result));
965
+ settleFleet(checkpointReopened ? "suspended" : resumePlane.status, fleetRunResiduals(sealed));
964
966
  return {
965
967
  status: 200,
966
968
  body: {
@@ -1243,7 +1245,7 @@ export function createResumeLegs(ctx) {
1243
1245
  deps,
1244
1246
  registry: { idemCache, inflightRuns, preemptableRuns, cancelledViaVerb, steerableRuns, wakeParkMints, counters },
1245
1247
  helpers: { readJson, readRawBody, rateLimited, quotaExceeded, ownerQuotaDenied, leaseDenied, ownerLeaseDenied, usageWindowDenied, safeDecode, isFleetWide, runOwnerOk, sessionOwnerScope, sessionOwnerScopeForWrite, runSessionAcceptOk },
1246
- local: { images: createImagesLocal(deps), sessions: createSessionsLocal(), sessionSync: createSessionSyncLocal() },
1248
+ local: { images: createImagesLocal(deps), sessions: createSessionsLocal(), sessionSync: createSessionSyncLocal(), capabilities: createCapabilitiesLocal() },
1247
1249
  legs: { prepareSpec, finalizeTaskResult, resumeCheckpoint, driveResumeIntoRunLog, resumePreempted, resumeWake, resumePlanReview },
1248
1250
  };
1249
1251
  return { routeCtxBase, resumeCheckpoint, driveResumeIntoRunLog, driveResumeLeg, mayDiscloseRowError, resumePreempted, startFreshHostTurn, resumeWake, resumePlanReview };
@@ -21,6 +21,7 @@ import type { FlatServiceDeps, RequestAuth } from "./server.js";
21
21
  import type { TaskRequestBody, DecideBinding } from "./wire-types.js";
22
22
  import type { IdempotencyCache, SubmitOutcome } from "./idempotency.js";
23
23
  import type { ImagesLocal } from "./routes/images.js";
24
+ import type { CapabilitiesLocal } from "./routes/capabilities.js";
24
25
  import type { SessionsLocal } from "./routes/sessions.js";
25
26
  import type { SessionSyncLocal } from "./routes/session-sync.js";
26
27
  import type { PlanApprovePermissionModeAfter } from "../task-settings.js";
@@ -87,6 +88,8 @@ export interface RouteHelpers {
87
88
  * (模块级会把 rate-limiter / 缓存跨实例串味,测试里一个进程建几十个 server)。 */
88
89
  export interface RouteLocals {
89
90
  images: ImagesLocal;
91
+ /** S-481:`GET /v1/capabilities/mcp` / `POST /v1/capabilities/mcp/probe` 的 TTL 缓存与每 principal 限速窗。 */
92
+ capabilities: CapabilitiesLocal;
90
93
  sessions: SessionsLocal;
91
94
  sessionSync: SessionSyncLocal;
92
95
  }
@@ -6,10 +6,19 @@
6
6
  * 本域零可变状态;deps 面宽(能力布尔逐条对应各路由的 501 门,单源不许漂移)但全是存在性只读。
7
7
  */
8
8
  import type { IncomingMessage, ServerResponse } from "node:http";
9
+ import { type McpProbeLocal } from "../../capabilities/mcp-probe.js";
9
10
  import type { RouteCtx, RouteMatch, RouteIdsOf } from "../route-ctx.js";
10
11
  /** 路由体 = 从 `server.ts` 的 `handle()` 里**整段剪切**过来的原文(唯一改动:统一去缩进两格)。裸 `return;`
11
12
  * 的语义原样 =「本域已应答」;走到函数尾才是「没匹配上」,置 `miss.fell` 交回装配器继续下一个域。
12
13
  * 跨域的东西(deps / 助手 / 单域状态 / 每请求量)一律在首行解构出来,于是下面每一行都逐字不变。 */
14
+ /**
15
+ * S-481 —— 本域独占的运行期状态(每 `createHttpServer` 实例一份)。**模块级不行**:测试里一个进程建几十台
16
+ * server,一张模块级缓存会把 A 台的探测读数发给 B 台,一只模块级限速窗会让第二台起来就被第一台的计数挡住。
17
+ */
18
+ export interface CapabilitiesLocal {
19
+ readonly mcpProbe: McpProbeLocal;
20
+ }
21
+ export declare function createCapabilitiesLocal(): CapabilitiesLocal;
13
22
  export declare function handleCapabilities(req: IncomingMessage, res: ServerResponse, match: RouteMatch<CapabilitiesRouteId>, ctx: RouteCtx): Promise<void>;
14
23
  export declare const CAPABILITIES_ROUTES: readonly [{
15
24
  readonly id: "models";
@@ -24,6 +33,14 @@ export declare const CAPABILITIES_ROUTES: readonly [{
24
33
  readonly pattern: RegExp;
25
34
  readonly label: "/v1/capabilities/scenarios/:name";
26
35
  readonly methods: readonly ["GET"];
36
+ }, {
37
+ readonly id: "capability-mcp";
38
+ readonly path: "/v1/capabilities/mcp";
39
+ readonly methods: readonly ["GET"];
40
+ }, {
41
+ readonly id: "capability-mcp-probe";
42
+ readonly path: "/v1/capabilities/mcp/probe";
43
+ readonly methods: readonly ["POST"];
27
44
  }];
28
45
  /** 本域可分派行的 `id` 闭集 —— handler 的 `switch` 按它判穷尽(漏一口 = 编译红)。 */
29
46
  export type CapabilitiesRouteId = RouteIdsOf<typeof CAPABILITIES_ROUTES>;
@@ -6,6 +6,8 @@ import { cwdHonored, deviceCwdHonored } from "../../task-cwd.js";
6
6
  import { DEVICE_PROTOCOL_VERSION, DEVICE_WS_PATH } from "../../device-ws-protocol.js";
7
7
  import { a2aInjectionHonored } from "../../task-a2a.js";
8
8
  import { mcpInjectionHonored } from "../../task-mcp.js";
9
+ import { buildEmptyMcpProbeFace, buildMcpInjectionRefusal, buildMcpProbeCacheKey, buildMcpProbeInflightRefusal, buildMcpProbeRateKey, buildMcpProbeRateRefusal, createMcpProbeLocal, buildMcpProbeDeclarationRefusal, buildMcpProbeIncompleteRefusal, McpProbeIncomplete, MCP_PROBE_HTTP_STATUS, MCP_PROBE_MAX_INFLIGHT, MCP_PROBE_VERDICT_TIMEOUT_MS, createMcpProbeWalk, readMcpProbeBody, } from "../../capabilities/mcp-probe.js";
10
+ import { mcpForScenario } from "../../config-center/facade.js";
9
11
  import { sendJson, sendError } from "../send.js";
10
12
  import { assertNever } from "../route-ctx.js";
11
13
  import { resolveStreamApprovalGate } from "../../tool-approval.js";
@@ -19,7 +21,7 @@ import { workflowModelAllowlistFor, selfOrchestrationDenial } from "../../task-w
19
21
  import { centerEntitlementSourceWired } from "../../runtime-caps-resolver.js";
20
22
  import { judgeAutoModeArming } from "../../auto-mode-face.js";
21
23
  import { coercePermissionModeStrict, SETTINGS_PERMISSION_MODE_WORDS } from "../../task-settings.js";
22
- import { gatedPrincipal } from "../principal-gate.js";
24
+ import { gatedPrincipal, sendPrincipalRefusal } from "../principal-gate.js";
23
25
  function routePairingOf(deps, name, m) {
24
26
  if (isSealedKeyPoison(deps.config.modelApiKeys?.[name]))
25
27
  return "unknown";
@@ -33,6 +35,9 @@ function retentionAdvertisement(config) {
33
35
  return null;
34
36
  return { mode: sweep.mode, maxAgeDays: policy.maxAgeDays };
35
37
  }
38
+ export function createCapabilitiesLocal() {
39
+ return { mcpProbe: createMcpProbeLocal() };
40
+ }
36
41
  export async function handleCapabilities(req, res, match, ctx) {
37
42
  const { deps } = ctx;
38
43
  switch (match.decl.id) {
@@ -138,6 +143,7 @@ export async function handleCapabilities(req, res, match, ctx) {
138
143
  modelUsage: Boolean(deps.runStore && deps.modelUsage),
139
144
  sessionInit: true,
140
145
  mcp: true,
146
+ mcpProbe: true,
141
147
  mcpElicitation: Boolean(deps.elicitation),
142
148
  askUserQuestion: Boolean(deps.question),
143
149
  toolApproval: Boolean(deps.toolApproval),
@@ -188,6 +194,33 @@ export async function handleCapabilities(req, res, match, ctx) {
188
194
  });
189
195
  return;
190
196
  }
197
+ case "capability-mcp": {
198
+ const principal = gatedPrincipal(req, deps.config);
199
+ if (deps.config.requirePrincipal && !principal) {
200
+ sendPrincipalRefusal(res, req, deps.config);
201
+ return;
202
+ }
203
+ await serveMcpProbeFace(res, ctx, principal, mcpForScenario(deps.config.mcpServers, deps.config.defaultScenario) ?? [], "deployment");
204
+ return;
205
+ }
206
+ case "capability-mcp-probe": {
207
+ const principal = gatedPrincipal(req, deps.config);
208
+ if (deps.config.requirePrincipal && !principal) {
209
+ sendPrincipalRefusal(res, req, deps.config);
210
+ return;
211
+ }
212
+ if (!mcpInjectionHonored(deps.config)) {
213
+ sendMcpProbeRefusal(res, ctx, buildMcpInjectionRefusal(), "gate_closed");
214
+ return;
215
+ }
216
+ const parsed = readMcpProbeBody(await ctx.helpers.readJson(req));
217
+ if (!parsed.ok) {
218
+ sendMcpProbeRefusal(res, ctx, parsed.refusal, "bad_request");
219
+ return;
220
+ }
221
+ await serveMcpProbeFace(res, ctx, principal, parsed.specs, "caller");
222
+ return;
223
+ }
191
224
  case "models": {
192
225
  const atCatalog = expandTiers(deps.config.models, deps.config.tiers ?? {}) ?? deps.config.models;
193
226
  const atAllowlist = deps.config.atModelAllowlist ?? [];
@@ -215,9 +248,68 @@ export async function handleCapabilities(req, res, match, ctx) {
215
248
  return assertNever(match.decl.id);
216
249
  }
217
250
  }
251
+ function sendMcpProbeRefusal(res, ctx, refusal, outcome, retryAfterSec) {
252
+ ctx.deps.metrics?.inc("mcp_probe_total", { outcome });
253
+ const status = MCP_PROBE_HTTP_STATUS[refusal.errorCode];
254
+ if (retryAfterSec !== undefined)
255
+ res.setHeader("retry-after", String(retryAfterSec));
256
+ sendError(res, status, refusal.errorCode, refusal.message, {
257
+ ...(refusal.unknownKeys !== undefined ? { unknownKeys: refusal.unknownKeys } : {}),
258
+ ...(retryAfterSec !== undefined ? { retryAfterSec } : {}),
259
+ });
260
+ }
261
+ async function serveMcpProbeFace(res, ctx, principal, specs, declaredBy) {
262
+ const { deps } = ctx;
263
+ const local = ctx.local.capabilities.mcpProbe;
264
+ if (specs.length === 0) {
265
+ deps.metrics?.inc("mcp_probe_total", { outcome: "empty" });
266
+ sendJson(res, 200, buildEmptyMcpProbeFace());
267
+ return;
268
+ }
269
+ const key = buildMcpProbeCacheKey(principal, specs);
270
+ const hit = local.cached(key, Date.now());
271
+ if (hit !== undefined) {
272
+ await answerMcpProbe(res, ctx, hit, "cached", declaredBy);
273
+ return;
274
+ }
275
+ if (local.inflight() >= MCP_PROBE_MAX_INFLIGHT) {
276
+ sendMcpProbeRefusal(res, ctx, buildMcpProbeInflightRefusal(), "rate_limited", Math.ceil(MCP_PROBE_VERDICT_TIMEOUT_MS / 1000));
277
+ return;
278
+ }
279
+ const verdict = await local.check(buildMcpProbeRateKey(principal));
280
+ if (!verdict.allowed) {
281
+ sendMcpProbeRefusal(res, ctx, buildMcpProbeRateRefusal(), "rate_limited", verdict.retryAfterSec);
282
+ return;
283
+ }
284
+ const walk = createMcpProbeWalk(specs, principal);
285
+ local.remember(key, Date.now(), walk);
286
+ await answerMcpProbe(res, ctx, walk.answer, "probed", declaredBy);
287
+ }
288
+ async function answerMcpProbe(res, ctx, answer, outcome, declaredBy) {
289
+ const { deps } = ctx;
290
+ try {
291
+ const body = await answer;
292
+ sendJson(res, 200, body);
293
+ deps.metrics?.inc("mcp_probe_total", { outcome });
294
+ }
295
+ catch (err) {
296
+ deps.metrics?.inc("mcp_probe_total", { outcome: "failed" });
297
+ if (err instanceof McpProbeIncomplete) {
298
+ sendMcpProbeRefusal(res, ctx, buildMcpProbeIncompleteRefusal(err.boundMs), "failed", Math.ceil(err.boundMs / 1000));
299
+ return;
300
+ }
301
+ if (declaredBy === "caller") {
302
+ sendMcpProbeRefusal(res, ctx, buildMcpProbeDeclarationRefusal(), "failed");
303
+ return;
304
+ }
305
+ throw err;
306
+ }
307
+ }
218
308
  export const CAPABILITIES_ROUTES = [
219
309
  { id: "models", path: "/v1/models", methods: ["GET"] },
220
310
  { id: "capabilities", path: "/v1/capabilities", methods: ["GET"] },
221
311
  { id: "capability-scenario", pattern: /^\/v1\/capabilities\/scenarios\/(?<name>.*)$/, label: "/v1/capabilities/scenarios/:name", methods: ["GET"] },
312
+ { id: "capability-mcp", path: "/v1/capabilities/mcp", methods: ["GET"] },
313
+ { id: "capability-mcp-probe", path: "/v1/capabilities/mcp/probe", methods: ["POST"] },
222
314
  ];
223
315
  //# sourceMappingURL=capabilities.js.map
@@ -105,7 +105,7 @@ export declare function buildClearanceRowWire(r: OriginClearanceRow): {
105
105
  requestId: string;
106
106
  reason: string;
107
107
  at: number;
108
- status: "failed" | "pending" | "done";
108
+ status: "pending" | "failed" | "done";
109
109
  originUnknownKeys?: string[] | undefined;
110
110
  clearanceId: string;
111
111
  entryId: string;