@sema-agent/server 7.92.2 → 7.93.1

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 (41) hide show
  1. package/README.md +1 -0
  2. package/README.zh-CN.md +1 -0
  3. package/USAGE.md +23 -1
  4. package/dist/approval-card.d.ts +10 -7
  5. package/dist/approval-card.js +3 -3
  6. package/dist/boot/resolve-spec.js +1 -0
  7. package/dist/capabilities/mcp-probe.d.ts +290 -0
  8. package/dist/capabilities/mcp-probe.js +318 -0
  9. package/dist/config-catalog.js +1 -0
  10. package/dist/config-types.d.ts +7 -1
  11. package/dist/config.js +2 -1
  12. package/dist/http/resume-legs.js +2 -1
  13. package/dist/http/route-ctx.d.ts +3 -0
  14. package/dist/http/routes/capabilities.d.ts +17 -0
  15. package/dist/http/routes/capabilities.js +93 -1
  16. package/dist/http/routes/memory-origin.d.ts +1 -1
  17. package/dist/http/routes/rules.js +42 -12
  18. package/dist/http/routes/runs.js +3 -2
  19. package/dist/http/routes/tasks.js +1 -1
  20. package/dist/http/wire-types.d.ts +17 -1
  21. package/dist/observability/metrics.js +2 -0
  22. package/dist/permission-rule-vocab.d.ts +9 -9
  23. package/dist/plugins/remote-env-host.d.ts +5 -0
  24. package/dist/plugins/remote-env-host.js +39 -37
  25. package/dist/plugins/store-backend.js +3 -2
  26. package/dist/plugins/store-lock-wait.d.ts +23 -0
  27. package/dist/plugins/store-lock-wait.js +39 -0
  28. package/dist/request-key-closure.js +1 -1
  29. package/dist/rules-consent.d.ts +22 -5
  30. package/dist/rules-consent.js +18 -4
  31. package/dist/run-local.js +3 -2
  32. package/dist/task-mcp.d.ts +32 -0
  33. package/dist/task-mcp.js +7 -0
  34. package/dist/tool-approval.d.ts +22 -1
  35. package/dist/tool-approval.js +1 -0
  36. package/dist/trace/core-keyset-guard.d.ts +3 -3
  37. package/dist/trace/project.d.ts +24 -0
  38. package/dist/trace/project.js +40 -37
  39. package/dist/trace/wire-projection-faces.d.ts +209 -0
  40. package/dist/trace/wire-projection-faces.js +62 -0
  41. 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
@@ -423,6 +423,7 @@ export const CONFIG_CATALOG = [
423
423
  r("SSH_PORT", "orchestration", "number", "ssh 端口", { derivedDefaultNote: "缺席 ⇒ 22" }),
424
424
  r("SSH_PRIVATE_KEY", "orchestration", "secret", "ssh 私钥(adapter 持有,绝不进 workspace)", { danger: SEC }),
425
425
  r("SSH_USER", "orchestration", "string", "ssh 用户(必需件)"),
426
+ r("STORE_LOCK_WAIT_MS", "store", "number", "local 数据根 LOCK 撞上活持锁人时的有界等待窗(ms;0=当场拒;到窗仍拿不到 ⇒ 原码拒启,永不放行第二个写者)", { staticDefault: "60000", danger: DATA, configKey: "storeLockWaitMs" }),
426
427
  r("STORE_PROBE_INTERVAL_MS", "store", "number", "/health 活体 store 后台探针间隔(ms;0=显式关;仅 SQL 后端挂环)", { staticDefault: "15000", configKey: "storeProbeIntervalMs" }),
427
428
  r("STREAM_APPROVAL_ADHOC_GRACE_MS", "approval", "number", "流内审批 adhoc 腿窗后宽限(ms;须 window+grace < orphanTtl,破坏形拒启)", { staticDefault: "60000" }),
428
429
  r("STREAM_APPROVAL_ADMIT_MAX_PER_OWNER", "approval", "number", "流内审批 per-owner 未决帽", { staticDefault: "256" }),
@@ -502,6 +502,12 @@ export interface ServiceConfigFlat {
502
502
  /** B1 /health 活体 store 探测:后台缓存探针间隔 ms(STORE_PROBE_INTERVAL_MS;0=显式关,负/非数 boot
503
503
  * 响亮拒)。只对 SQL 后端接线;探针原语=backend.dbNowMs()(S10 时钟探针的真 DB 往返,零新 SQL 面)。 */
504
504
  storeProbeIntervalMs: number;
505
+ /** S-553 —— `DB_BACKEND=local` 数据根 `LOCK` 撞上**活**持锁人时的**有界等待窗** ms
506
+ * (`STORE_LOCK_WAIT_MS`;缺省 60_000,地板 0 = 7.93.0 的当场拒,**不设上界**)。
507
+ * 病 = 壳自 spawn 引擎的单机形:旧引擎排空期间一直抱着锁,新引擎当场 fatal ⇒ 用户「刚关掉就再也起不来」。
508
+ * 失败方向一格不动:到窗仍拿不到 ⇒ 原码 fatal(文案多一句等了多久),**永不**放行第二个写者。
509
+ * 只有 file 后端读它;判据与偏离说明见 `plugins/store-lock-wait.ts` 顶注。 */
510
+ storeLockWaitMs: number;
505
511
  /** Long-term memory ([ref] S1, clay 2026-07-08): the injection-first file-based memory ENGINE
506
512
  * (core `RunnerDeps.memoryBackend` + `MemoryEngine`) is the ONLY memory plane — the legacy MemoryStore
507
513
  * path (MEMORY_BACKEND/EMBEDDING_* SQL/file note stores) was dropped without migration. Default ON;
@@ -1858,7 +1864,7 @@ export interface ServiceConfigFlat {
1858
1864
  configLocalDir?: string;
1859
1865
  }
1860
1866
  /** 组:store(持久化)—— DB 引擎三态、session 后端、SQL coords、快照 BLOB / SendUserFile 对象存储。 */
1861
- export type ServiceStoreConfig = Pick<ServiceConfigFlat, "sessionBackend" | "sessionCacheTtlSec" | "storeProbeIntervalMs" | "dbBackend" | "dbBackendExplicit" | "localDataRoot" | "runStoreStrictHydrate" | "tidb" | "pg" | "dbQueryTimeoutMs" | "snapshotBlobStore" | "snapshotBlobSqlMaxBytes" | "snapshotBlobAllowSql" | "fileHistory" | "sendUserFile">;
1867
+ export type ServiceStoreConfig = Pick<ServiceConfigFlat, "sessionBackend" | "sessionCacheTtlSec" | "storeProbeIntervalMs" | "storeLockWaitMs" | "dbBackend" | "dbBackendExplicit" | "localDataRoot" | "runStoreStrictHydrate" | "tidb" | "pg" | "dbQueryTimeoutMs" | "snapshotBlobStore" | "snapshotBlobSqlMaxBytes" | "snapshotBlobAllowSql" | "fileHistory" | "sendUserFile">;
1862
1868
  /** 组:modelPlane(模型面)—— 网关坐标、Anthropic 路线、韧性旋钮、主/廉价 model entry、role 表、降级梯。 */
1863
1869
  export type ServiceModelPlaneConfig = Pick<ServiceConfigFlat, "gatewayBaseUrl" | "gatewayApiKey" | "gatewayFallbackUrls" | "gatewayMaxRetries" | "anthropic" | "resilience" | "model" | "envModel" | "models" | "modelApiKeyEnv" | "modelApiKeys" | "modelQuotaWeights" | "tiers" | "projects" | "roles" | "atModelAllowlist" | "cascadeLadder" | "degrade">;
1864
1870
  /** 组:approval(审批 / HITL 门)。`directDoorActive` 无 env 解析腿(装配层三域合取的产物),但语义上
package/dist/config.js CHANGED
@@ -725,6 +725,7 @@ function parseStoreDomain(ctx) {
725
725
  sessionBackend,
726
726
  sessionCacheTtlSec: numEnv("SESSION_CACHE_TTL_SEC", "300"),
727
727
  storeProbeIntervalMs: numEnvBounded("STORE_PROBE_INTERVAL_MS", "15000", 0, 3_600_000),
728
+ storeLockWaitMs: clampEnvWithWarn("STORE_LOCK_WAIT_MS", "60000", 0),
728
729
  dbBackend,
729
730
  dbBackendExplicit: dbBackendSet,
730
731
  localDataRoot,
@@ -2030,7 +2031,7 @@ function parseA2aServeSkills(raw) {
2030
2031
  });
2031
2032
  }
2032
2033
  const STORE_GROUP_KEYS = [
2033
- "sessionBackend", "sessionCacheTtlSec", "storeProbeIntervalMs", "dbBackend", "dbBackendExplicit", "localDataRoot",
2034
+ "sessionBackend", "sessionCacheTtlSec", "storeProbeIntervalMs", "storeLockWaitMs", "dbBackend", "dbBackendExplicit", "localDataRoot",
2034
2035
  "runStoreStrictHydrate",
2035
2036
  "tidb", "pg", "dbQueryTimeoutMs", "snapshotBlobStore", "snapshotBlobSqlMaxBytes", "snapshotBlobAllowSql", "fileHistory", "sendUserFile",
2036
2037
  ];
@@ -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";
@@ -1244,7 +1245,7 @@ export function createResumeLegs(ctx) {
1244
1245
  deps,
1245
1246
  registry: { idemCache, inflightRuns, preemptableRuns, cancelledViaVerb, steerableRuns, wakeParkMints, counters },
1246
1247
  helpers: { readJson, readRawBody, rateLimited, quotaExceeded, ownerQuotaDenied, leaseDenied, ownerLeaseDenied, usageWindowDenied, safeDecode, isFleetWide, runOwnerOk, sessionOwnerScope, sessionOwnerScopeForWrite, runSessionAcceptOk },
1247
- local: { images: createImagesLocal(deps), sessions: createSessionsLocal(), sessionSync: createSessionSyncLocal() },
1248
+ local: { images: createImagesLocal(deps), sessions: createSessionsLocal(), sessionSync: createSessionSyncLocal(), capabilities: createCapabilitiesLocal() },
1248
1249
  legs: { prepareSpec, finalizeTaskResult, resumeCheckpoint, driveResumeIntoRunLog, resumePreempted, resumeWake, resumePlanReview },
1249
1250
  };
1250
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;