@sema-agent/server 7.84.0 → 7.86.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 (45) hide show
  1. package/USAGE.md +24 -2
  2. package/dist/approval-card.d.ts +7 -0
  3. package/dist/approval-card.js +2 -2
  4. package/dist/approval-hmac.d.ts +28 -1
  5. package/dist/approval-hmac.js +2 -1
  6. package/dist/boot/stage-10-http-server.js +2 -1
  7. package/dist/capabilities/execution-lane.d.ts +22 -0
  8. package/dist/capabilities/execution-lane.js +8 -0
  9. package/dist/capabilities/memory-notice.d.ts +4 -1
  10. package/dist/config-catalog.js +3 -2
  11. package/dist/config-types.d.ts +5 -1
  12. package/dist/execution-lane-caps.d.ts +13 -0
  13. package/dist/execution-lane-caps.js +1 -0
  14. package/dist/fleet/fleet-reconciler.js +2 -2
  15. package/dist/http/resume-legs.d.ts +2 -1
  16. package/dist/http/resume-legs.js +21 -6
  17. package/dist/http/route-ctx.d.ts +51 -1
  18. package/dist/http/route-table.js +2 -0
  19. package/dist/http/routes/approvals-assistant.d.ts +11 -0
  20. package/dist/http/routes/approvals-assistant.js +61 -16
  21. package/dist/http/routes/capabilities.js +3 -0
  22. package/dist/http/routes/images.js +38 -8
  23. package/dist/http/routes/mcp-reconnect.d.ts +56 -0
  24. package/dist/http/routes/mcp-reconnect.js +139 -0
  25. package/dist/http/routes/memory-policy.js +3 -6
  26. package/dist/http/routes/notify-wake.js +11 -3
  27. package/dist/http/routes/run-memory-capture.js +3 -5
  28. package/dist/http/routes/runs.js +21 -23
  29. package/dist/http/routes/sessions.js +11 -2
  30. package/dist/http/routes/shared-memory.js +5 -1
  31. package/dist/http/server.d.ts +14 -0
  32. package/dist/http/wire-types.d.ts +5 -1
  33. package/dist/memory-scope.d.ts +16 -0
  34. package/dist/plugins/checkpoint-store-sql.d.ts +8 -2
  35. package/dist/plugins/store-contracts.d.ts +23 -0
  36. package/dist/plugins/store-contracts.js +3 -0
  37. package/dist/principal-jwt.d.ts +15 -7
  38. package/dist/principal-jwt.js +1 -1
  39. package/dist/read-face-posture.d.ts +6 -2
  40. package/dist/task-settings.d.ts +35 -0
  41. package/dist/task-settings.js +7 -0
  42. package/dist/trace/core-keyset-guard.d.ts +49 -3
  43. package/dist/trace/project.d.ts +12 -0
  44. package/dist/trace/project.js +14 -0
  45. package/package.json +2 -2
@@ -7,6 +7,7 @@ import { isParkedRunStatus } from "../../plugins/store-contracts.js";
7
7
  import { principalFrom, decodeCheckpointScope, PRINCIPAL_TOKEN_HEADER, APPROVAL_MAC_HEADER, APPROVAL_MAC_KID_HEADER } from "../../security.js";
8
8
  import { editedPlanBinding, isWellFormedUnicode, verifyDirectDoorProof } from "../../principal-jwt.js";
9
9
  import { MAX_APPROVAL_REASON_CHARS } from "../../approval-hmac.js";
10
+ import { PLAN_APPROVE_PERMISSION_MODES_AFTER, isPlanApprovePermissionModeAfter, storedBodyPermissionMode } from "../../task-settings.js";
10
11
  import { MAX_APPROVAL_NONCE_CHARS } from "../../plugins/approval-nonce-store.js";
11
12
  import { redactedPreview, redactSecrets } from "../../trace/redact.js";
12
13
  import { redactRiskDescriptor } from "../../observability/run-terminal-log.js";
@@ -46,6 +47,35 @@ export function decideBodyKeyIssue(raw) {
46
47
  unsupportedKeys: buckets.unsupported.slice(0, MAX_KEY_ECHO),
47
48
  };
48
49
  }
50
+ const PLAN_REVIEW_BODY_DECLARED_KEYS = [
51
+ "boundCallId", "decision", "editedPlan", "permissionModeAfter", "reason",
52
+ ];
53
+ const ACCEPTED_PLAN_REVIEW_BODY = new Set(PLAN_REVIEW_BODY_DECLARED_KEYS);
54
+ const UNSUPPORTED_PLAN_REVIEW_BODY = new Set([
55
+ "answer", "boundInputHash", "checkpointToken", "permissionMode", "remember", "sessionId", "taskId", "updatedInput",
56
+ ]);
57
+ export function planReviewBodyKeyIssue(raw) {
58
+ if (raw === null || raw === undefined || typeof raw !== "object" || Array.isArray(raw))
59
+ return null;
60
+ const buckets = { unknown: [], unsupported: [] };
61
+ scanKeyClosure(raw, "", ACCEPTED_PLAN_REVIEW_BODY, UNSUPPORTED_PLAN_REVIEW_BODY, buckets);
62
+ if (buckets.unknown.length === 0 && buckets.unsupported.length === 0)
63
+ return null;
64
+ buckets.unknown.sort();
65
+ buckets.unsupported.sort();
66
+ const parts = [
67
+ ...(buckets.unknown.length > 0 ? [keyClosureClause(buckets.unknown, "unknown (not a plan-review-request key — check the spelling)")] : []),
68
+ ...(buckets.unsupported.length > 0 ? [keyClosureClause(buckets.unsupported, "unsupported (a sibling approval door's field, a submit-body key, or a path parameter — never read from this body)")] : []),
69
+ ];
70
+ return {
71
+ message: `body: unrecognized top-level key(s) — refused rather than dropped, because a dropped key reads as "in effect" to the caller ` +
72
+ `(a misspelled \`permissionModeAfter\` silently leaves this task read-only after you approved its plan). ` +
73
+ `${parts.join("; ")}. ` +
74
+ `Accepted: ${[...ACCEPTED_PLAN_REVIEW_BODY].sort().join(",")}`,
75
+ unknownKeys: buckets.unknown.slice(0, MAX_KEY_ECHO),
76
+ unsupportedKeys: buckets.unsupported.slice(0, MAX_KEY_ECHO),
77
+ };
78
+ }
49
79
  export function decisionReasonOf(body) {
50
80
  return typeof body.reason === "string" && body.reason !== "" ? body.reason : undefined;
51
81
  }
@@ -133,7 +163,7 @@ async function decideIdempotentReplay(cs, args) {
133
163
  export async function handleApprovalsAssistant(req, res, match, ctx) {
134
164
  const { deps } = ctx;
135
165
  const { preemptableRuns } = ctx.registry;
136
- const { readJson, rateLimited, quotaExceeded } = ctx.helpers;
166
+ const { readJson, rateLimited, quotaExceeded, safeDecode } = ctx.helpers;
137
167
  const { resumeCheckpoint, resumePreempted, resumePlanReview } = ctx.legs;
138
168
  {
139
169
  const cs = deps.checkpointStore;
@@ -164,13 +194,9 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
164
194
  sendError(res, 501, "capability.store_required", "approval exemptions need a store backend");
165
195
  return;
166
196
  }
167
- let exSessionId;
168
- let exToolName;
169
- try {
170
- exSessionId = decodeURIComponent(match.params.sessionId);
171
- exToolName = match.params.tool !== undefined ? decodeURIComponent(match.params.tool) : undefined;
172
- }
173
- catch {
197
+ const exSessionId = safeDecode(match.params.sessionId);
198
+ const exToolName = match.params.tool !== undefined ? safeDecode(match.params.tool) : undefined;
199
+ if (exSessionId === null || exToolName === null) {
174
200
  sendError(res, 400, "request.path_malformed", "malformed id (invalid percent-encoding)");
175
201
  return;
176
202
  }
@@ -365,9 +391,16 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
365
391
  const body = (await readJson(req));
366
392
  const decision = body?.decision === "approve" ? "approve" : body?.decision === "edit" ? "edit" : body?.decision === "reject" ? "reject" : undefined;
367
393
  if (!decision) {
368
- sendError(res, 400, "request.body_shape", "body must be { decision: 'approve' | 'edit' | 'reject', editedPlan?, reason? }");
394
+ sendError(res, 400, "request.body_shape", "body must be { decision: 'approve' | 'edit' | 'reject', editedPlan?, reason?, permissionModeAfter? }");
369
395
  return;
370
396
  }
397
+ {
398
+ const keyIssue = planReviewBodyKeyIssue(body);
399
+ if (keyIssue) {
400
+ sendError(res, 400, "request.body_shape", keyIssue.message, { unknownKeys: keyIssue.unknownKeys, unsupportedKeys: keyIssue.unsupportedKeys });
401
+ return;
402
+ }
403
+ }
371
404
  if (decision === "edit" && (typeof body.editedPlan !== "string" || body.editedPlan.length === 0)) {
372
405
  sendError(res, 400, "request.field_conflict", "decision 'edit' requires a non-empty editedPlan (the operator's revised plan)");
373
406
  return;
@@ -380,6 +413,14 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
380
413
  sendError(res, 400, "request.field_conflict", `editedPlan is only valid with decision 'edit' (got '${decision}')`);
381
414
  return;
382
415
  }
416
+ if (body.permissionModeAfter !== undefined && !isPlanApprovePermissionModeAfter(body.permissionModeAfter)) {
417
+ sendError(res, 400, "request.field_invalid", `permissionModeAfter must be one of ${PLAN_APPROVE_PERMISSION_MODES_AFTER.map((m) => JSON.stringify(m)).join(" | ")} when present (the permission mode this task continues in once the plan is approved; absent = "default", which keeps asking before each write)`);
418
+ return;
419
+ }
420
+ if (decision !== "approve" && body.permissionModeAfter !== undefined) {
421
+ sendError(res, 400, "request.field_conflict", `permissionModeAfter is only valid with decision 'approve' (got '${decision}')`);
422
+ return;
423
+ }
383
424
  if (body.reason !== undefined && typeof body.reason !== "string") {
384
425
  sendError(res, 400, "request.field_invalid", "reason must be a string");
385
426
  return;
@@ -423,7 +464,7 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
423
464
  }
424
465
  const storeNowMs = await store.nowMs();
425
466
  const hdr = (n) => { const h = req.headers[n]; return Array.isArray(h) ? h[0] : h; };
426
- const proof = verifyDirectDoorProof({ jwt: hdr(PRINCIPAL_TOKEN_HEADER), mac: hdr(APPROVAL_MAC_HEADER), kid: hdr(APPROVAL_MAC_KID_HEADER) }, { sessionId: run.sessionId, boundCallId: planNonce ?? null, boundInputHash: decision === "edit" ? editedPlanBinding(body.editedPlan) : null, decision, reason: typeof body.reason === "string" ? body.reason : null }, deps.config, { actionBinding: false, nowSec: Math.floor(storeNowMs / 1000) });
467
+ const proof = verifyDirectDoorProof({ jwt: hdr(PRINCIPAL_TOKEN_HEADER), mac: hdr(APPROVAL_MAC_HEADER), kid: hdr(APPROVAL_MAC_KID_HEADER) }, { sessionId: run.sessionId, boundCallId: planNonce ?? null, boundInputHash: decision === "edit" ? editedPlanBinding(body.editedPlan) : null, decision, reason: typeof body.reason === "string" ? body.reason : null, ...(isPlanApprovePermissionModeAfter(body.permissionModeAfter) ? { permissionModeAfter: body.permissionModeAfter } : {}) }, deps.config, { actionBinding: false, nowSec: Math.floor(storeNowMs / 1000) });
427
468
  if (!proof.ok) {
428
469
  sendError(res, proof.status, proof.errorCode, proof.error);
429
470
  return;
@@ -438,6 +479,13 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
438
479
  sendError(res, 404, "not_found.run", "task not found");
439
480
  return;
440
481
  }
482
+ if (body.permissionModeAfter !== undefined) {
483
+ const storedCtx = await deps.checkpointStore?.getCtx(run.sessionId).catch(() => null);
484
+ if (storedCtx?.body !== undefined && storedBodyPermissionMode(storedCtx.body) !== "plan") {
485
+ sendError(res, 400, "request.field_conflict", "permissionModeAfter does not apply to this task: it was not submitted with permissionMode \"plan\", so approving the plan lifts no read-only restriction");
486
+ return;
487
+ }
488
+ }
441
489
  if (singleUse !== undefined) {
442
490
  const { proof: onceOnly, store } = singleUse;
443
491
  const fresh = await store.consume(run.sessionId, onceOnly.nonce, onceOnly.acceptedBeforeMs);
@@ -450,18 +498,15 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
450
498
  return;
451
499
  }
452
500
  }
453
- const out = await resumePlanReview(run.sessionId, decision, decision === "edit" ? body.editedPlan : undefined, typeof body.reason === "string" ? body.reason : undefined, req, { taskId, principalPresent: principal !== undefined }, true, { principal: deciderPrincipal, explicitOperator });
501
+ const out = await resumePlanReview(run.sessionId, decision, decision === "edit" ? body.editedPlan : undefined, typeof body.reason === "string" ? body.reason : undefined, req, { taskId, principalPresent: principal !== undefined }, true, { principal: deciderPrincipal, explicitOperator }, isPlanApprovePermissionModeAfter(body.permissionModeAfter) ? body.permissionModeAfter : undefined);
454
502
  sendResumeOutcome(res, out, deps.logger);
455
503
  return;
456
504
  }
457
505
  if (match.decl.id === "approval-decide") {
458
506
  if (rateLimited(req, res) || quotaExceeded(req, res))
459
507
  return;
460
- let sessionId;
461
- try {
462
- sessionId = decodeURIComponent(match.params.sessionId);
463
- }
464
- catch {
508
+ const sessionId = safeDecode(match.params.sessionId);
509
+ if (sessionId === null) {
465
510
  sendError(res, 400, "request.path_malformed", "malformed approval id (invalid percent-encoding)");
466
511
  return;
467
512
  }
@@ -13,6 +13,7 @@ import { projectMemoryEngineCapability } from "../../memory-posture.js";
13
13
  import { projectWriteProtectionCapability } from "../../write-protection.js";
14
14
  import { projectSqlEngineCapability } from "../../sql-engine-posture.js";
15
15
  import { projectWebSearchCapability } from "../../plugins/web-search.js";
16
+ import { projectExecutionLaneCapability } from "../../capabilities/execution-lane.js";
16
17
  import { workflowModelAllowlistFor, selfOrchestrationDenial } from "../../task-workflow.js";
17
18
  import { centerEntitlementSourceWired } from "../../runtime-caps-resolver.js";
18
19
  import { judgeAutoModeArming } from "../../auto-mode-face.js";
@@ -80,6 +81,7 @@ export async function handleCapabilities(req, res, match, ctx) {
80
81
  webSearch: projectWebSearchCapability(deps.webSearchProvider),
81
82
  sessionBackgroundable: Boolean(deps.runStore),
82
83
  runMemoryCaptureOptOut: Boolean(deps.runStore),
84
+ mcpReconnect: Boolean(deps.runStore) && Boolean(deps.sessionStorage?.ownerOf),
83
85
  sharedMemory: Boolean(deps.sharedMemoryStore && deps.orgMemoryDirectory),
84
86
  adoption: Boolean(deps.backend?.adoptionLog) && deps.config.operatorPrincipals.length > 0,
85
87
  memoryBundle: Boolean(deps.memoryBundleExport && deps.memoryBundleImport) && deps.config.operatorPrincipals.length > 0,
@@ -125,6 +127,7 @@ export async function handleCapabilities(req, res, match, ctx) {
125
127
  interactiveTools: true,
126
128
  projectContext: cwdHonored(deps.config),
127
129
  callerCwd: cwdHonored(deps.config) || deviceCwdHonored(deps.config),
130
+ executionLane: projectExecutionLaneCapability(deps.config),
128
131
  mcpInjection: mcpInjectionHonored(deps.config),
129
132
  a2a: true,
130
133
  a2aInjection: a2aInjectionHonored(deps.config),
@@ -41,7 +41,7 @@ const RUNNER_ERROR_CODES = new Set(["band-conflict", "duplicate", "disk", "timeo
41
41
  const SANDBOX_BASE_PROFILE = "sandbox-base";
42
42
  export async function handleImages(req, res, match, ctx) {
43
43
  const { deps } = ctx;
44
- const { readJson } = ctx.helpers;
44
+ const { readJson, safeDecode } = ctx.helpers;
45
45
  const { source } = ctx.req;
46
46
  const { bakeSubmitLimiter } = ctx.local.images;
47
47
  if (deps.imageBakes && isBakeRouteId(match.decl.id)) {
@@ -128,7 +128,11 @@ export async function handleImages(req, res, match, ctx) {
128
128
  sendError(res, 403, "auth.operator_only", "operator only");
129
129
  return;
130
130
  }
131
- const bakeId = decodeURIComponent(match.params.bakeId);
131
+ const bakeId = safeDecode(match.params.bakeId);
132
+ if (bakeId === null) {
133
+ sendError(res, 400, "request.path_malformed", "malformed id (invalid percent-encoding)");
134
+ return;
135
+ }
132
136
  const bake = await bakes.getBake(bakeId);
133
137
  if (!bake) {
134
138
  sendError(res, 404, "not_found.bake", "bake not found");
@@ -142,7 +146,11 @@ export async function handleImages(req, res, match, ctx) {
142
146
  sendError(res, 403, "auth.operator_only", "operator only");
143
147
  return;
144
148
  }
145
- const bakeId = decodeURIComponent(match.params.bakeId);
149
+ const bakeId = safeDecode(match.params.bakeId);
150
+ if (bakeId === null) {
151
+ sendError(res, 400, "request.path_malformed", "malformed id (invalid percent-encoding)");
152
+ return;
153
+ }
146
154
  const bake = await bakes.getBake(bakeId);
147
155
  if (!bake) {
148
156
  sendError(res, 404, "not_found.bake", "bake not found");
@@ -178,7 +186,11 @@ export async function handleImages(req, res, match, ctx) {
178
186
  sendError(res, 403, "auth.bake_runner_only", "bake-runner only");
179
187
  return;
180
188
  }
181
- const bakeId = decodeURIComponent(match.params.bakeId);
189
+ const bakeId = safeDecode(match.params.bakeId);
190
+ if (bakeId === null) {
191
+ sendError(res, 400, "request.path_malformed", "malformed id (invalid percent-encoding)");
192
+ return;
193
+ }
182
194
  const body = (await readJson(req));
183
195
  const runnerId = typeof body.runnerId === "string" && body.runnerId.length > 0 ? body.runnerId : (principal ?? source ?? cfg.runnerPrincipal);
184
196
  const claimed = await bakes.claimBake(bakeId, runnerId, cfg.leaseMs);
@@ -194,7 +206,11 @@ export async function handleImages(req, res, match, ctx) {
194
206
  sendError(res, 403, "auth.bake_runner_only", "bake-runner only");
195
207
  return;
196
208
  }
197
- const bakeId = decodeURIComponent(match.params.bakeId);
209
+ const bakeId = safeDecode(match.params.bakeId);
210
+ if (bakeId === null) {
211
+ sendError(res, 400, "request.path_malformed", "malformed id (invalid percent-encoding)");
212
+ return;
213
+ }
198
214
  const bake = await bakes.getBake(bakeId);
199
215
  if (!bake) {
200
216
  sendError(res, 404, "not_found.bake", "bake not found");
@@ -238,7 +254,11 @@ export async function handleImages(req, res, match, ctx) {
238
254
  sendError(res, 403, "auth.operator_only", "operator only");
239
255
  return;
240
256
  }
241
- const bakeId = decodeURIComponent(match.params.bakeId);
257
+ const bakeId = safeDecode(match.params.bakeId);
258
+ if (bakeId === null) {
259
+ sendError(res, 400, "request.path_malformed", "malformed id (invalid percent-encoding)");
260
+ return;
261
+ }
242
262
  const bake = await bakes.getBake(bakeId);
243
263
  if (!bake) {
244
264
  sendError(res, 404, "not_found.bake", "bake not found");
@@ -335,7 +355,12 @@ export async function handleImages(req, res, match, ctx) {
335
355
  return;
336
356
  }
337
357
  if (match.decl.id === "image-by-digest") {
338
- const entry = await idx.getByDigest(decodeURIComponent(match.params.digest));
358
+ const digest = safeDecode(match.params.digest);
359
+ if (digest === null) {
360
+ sendError(res, 400, "request.path_malformed", "malformed id (invalid percent-encoding)");
361
+ return;
362
+ }
363
+ const entry = await idx.getByDigest(digest);
339
364
  if (!entry || (!viewer.operator && entry.visibility === "tenant" && entry.tenantId !== viewer.tenantId)) {
340
365
  sendError(res, 404, "not_found.image", "not found");
341
366
  return;
@@ -344,7 +369,12 @@ export async function handleImages(req, res, match, ctx) {
344
369
  return;
345
370
  }
346
371
  if (match.decl.id === "image-by-profile") {
347
- const entry = await idx.latestPublished(decodeURIComponent(match.params.profile), viewer);
372
+ const profile = safeDecode(match.params.profile);
373
+ if (profile === null) {
374
+ sendError(res, 400, "request.path_malformed", "malformed id (invalid percent-encoding)");
375
+ return;
376
+ }
377
+ const entry = await idx.latestPublished(profile, viewer);
348
378
  if (!entry) {
349
379
  sendError(res, 404, "not_found.image", "not found");
350
380
  return;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * S-381(core 7.22.0 [ref] 的 server 半场;提货单 [ref] ⓪ @server 第 3 条 / 判据帖 [ref] G1)——
3
+ * **会话内 MCP re-dial** 的 wire 面:
4
+ *
5
+ * `POST /v1/sessions/:id/mcp/reconnect` body `{ server: string }`
6
+ * → 200 `{ taskId, sessionId, server, outcome: "accepted" | "refused" | "unsupported", … }`
7
+ *
8
+ * 语义 = core `TaskStream.reconnectMcpServer(server)`:传输断了的那台 MCP server,在**不结束任务**的前提下
9
+ * 重拨一次号。**一台具名 server,必需,没有全部形**(core 原话:全体形会 CLOSE 掉健康连接,而拥有设备/端口的
10
+ * stdio server 恰恰是这个动词存在的理由)。
11
+ *
12
+ * ── 🔴 一次交易,不是一个「刷新」按钮(core 原话,必须进 UI 文案与契约)───────────────────────────────
13
+ * **失败的重拨让 server 留在断线状态** —— 旧连接在拨新号**之前**就被关(对拥有设备/端口的 stdio server 没有
14
+ * 别的顺序可用),所以一次失败的拨号花掉了一条能用的连接:server 保持断开、**它的工具从模型名册撤走**,在飞
15
+ * 调用随之失败。**在一台健康的 server 上提供「重连」按钮 = 提供一次交易**。契约 §G.7 与本面的 `outcome`
16
+ * 三词都按这句话写;壳不许把它渲成无代价的刷新。
17
+ *
18
+ * ── 三态映射(`unsupported` 是**本仓自己的词**,core 无此词)──────────────────────────────────────
19
+ * · core `reconnected` → `accepted`
20
+ * · core `refused` → `refused`
21
+ * · core `not_declared` → `refused`(未知 server 名是**名册事实**,不是引擎能力事实)
22
+ * · core **抛** `mcp.reconnect_unavailable`(本 run 未声明任何 MCP)→ `unsupported`
23
+ * 🔴 **`outcome` 是唯一判别键**:三态**全部走 200**。HTTP 状态只回答「动词有没有到达引擎」——与 steer 回执的
24
+ * 「branch on `delivery`, not the status」同一条律。把 `refused` 挂到 4xx 会让同一件事有两个判别键,而
25
+ * `unsupported` 挂 409 更会把「这条 run 没有 MCP 面」谎报成一个活性问题。
26
+ *
27
+ * ── 下一步动作按 `status`,不解析 `reason`(core [ref] 键表逐字)────────────────────────────────
28
+ * `status.errorCode` / `status.httpStatus` 是机器面(`http_status` 401 是一扇重新授权的门,`spawn_failed` 是
29
+ * 缺二进制);`reason` 是**人读**的一句话,经本仓 `redactSecrets` + 定长截断后投出(它可能嵌远端文本),
30
+ * 消费端**不许**解析它。名册**按 `tools` 的在场性**投成 `toolNames`(present-iff),**不按 `outcome`**。
31
+ *
32
+ * ── 查找与拒绝形(与 steer / capture-optout **同一只**,不新造)───────────────────────────────────
33
+ * 路径是 session,动词挂在 **run 句柄**上 ⇒ 会话属主门(`sessionStorage.ownerOf`,未知与他人**同码同串**
34
+ * 404 无 oracle,与 `POST /v1/sessions/:id/notify` 逐字同形)→ `getActiveTaskId` 取本会话的活 run →
35
+ * `steerableRuns` 取本副本活流。非 live 的四臂一码 409 `steering.not_running`,判据取词表属主
36
+ * (`isParkedRunStatus` / `isTerminalRunStatus`)与 `runProvenElsewhere`(手抄已收编成一只谓词,S-430 起同住 `plugins/store-contracts.ts`)。
37
+ * 显式 operator 可重拨任何租户的会话(监督动作,与 notify / steer 同臂;**不是** bare `isOperator`)。
38
+ *
39
+ * ── 计费/门 ─────────────────────────────────────────────────────────────────────────────────────
40
+ * 零模型工作(不进 billable;不吃 drain / roster-pending);mutating ⇒ `rateLimited`。能力位 `mcpReconnect`
41
+ * = 持久 run 账面 ∧ 会话属主面(两道 501 的同一批 deps 推导出来,「说 yes 但 501」结构上不可能)。
42
+ */
43
+ import type { IncomingMessage, ServerResponse } from "node:http";
44
+ import { type RouteCtx, type RouteMatch, type RouteIdsOf } from "../route-ctx.js";
45
+ /** 本面的三词闭集 —— **`unsupported` 是本仓的词**(core 的三词是 `reconnected`/`refused`/`not_declared`)。 */
46
+ export type McpReconnectOutcome = "accepted" | "refused" | "unsupported";
47
+ export declare function handleMcpReconnect(req: IncomingMessage, res: ServerResponse, match: RouteMatch<McpReconnectRouteId>, ctx: RouteCtx): Promise<void>;
48
+ export declare const MCP_RECONNECT_ROUTES: readonly [{
49
+ readonly id: "mcp-reconnect";
50
+ readonly pattern: RegExp;
51
+ readonly label: "/v1/sessions/:id/mcp/reconnect";
52
+ readonly methods: readonly ["POST"];
53
+ }];
54
+ /** 本域可分派行的 `id` 闭集 —— handler 的 `switch` 按它判穷尽(漏一口 = 编译红)。 */
55
+ export type McpReconnectRouteId = RouteIdsOf<typeof MCP_RECONNECT_ROUTES>;
56
+ //# sourceMappingURL=mcp-reconnect.d.ts.map
@@ -0,0 +1,139 @@
1
+ import { z } from "zod";
2
+ import { isParkedRunStatus, isTerminalRunStatus, runProvenElsewhere } from "../../plugins/store-contracts.js";
3
+ import { redactSecrets } from "../../trace/redact.js";
4
+ import { sendJson, sendError } from "../send.js";
5
+ import { gatedPrincipal, explicitOperatorOk, sendPrincipalRefusal } from "../principal-gate.js";
6
+ import {} from "../route-ctx.js";
7
+ import { assertNever } from "../route-ctx.js";
8
+ const BodySchema = z.object({ server: z.string().min(1).max(190) }).strict();
9
+ const NEUTRALIZED_TEXT_MAX = 200;
10
+ const humanText = (s) => redactSecrets(s).slice(0, NEUTRALIZED_TEXT_MAX);
11
+ function outcomeOf(coreOutcome) {
12
+ switch (coreOutcome) {
13
+ case "reconnected": return "accepted";
14
+ case "refused": return "refused";
15
+ case "not_declared": return "refused";
16
+ default: return assertNever(coreOutcome);
17
+ }
18
+ }
19
+ function projectStatus(s) {
20
+ return {
21
+ name: s.name,
22
+ status: s.status,
23
+ ...(s.errorCode !== undefined ? { errorCode: s.errorCode } : {}),
24
+ ...(s.httpStatus !== undefined ? { httpStatus: s.httpStatus } : {}),
25
+ ...(s.delivered !== undefined ? { delivered: s.delivered } : {}),
26
+ ...(s.serverInfo !== undefined ? { serverInfo: s.serverInfo } : {}),
27
+ ...(s.error !== undefined ? { error: humanText(String(s.error)) } : {}),
28
+ ...(s.transportClosed !== undefined ? { transportClosed: s.transportClosed } : {}),
29
+ };
30
+ }
31
+ function projectListingIncomplete(l) {
32
+ return {
33
+ reason: l.reason,
34
+ pages: l.pages,
35
+ ...(l.budgetMs !== undefined ? { budgetMs: l.budgetMs } : {}),
36
+ ...(l.error !== undefined ? { error: humanText(l.error) } : {}),
37
+ };
38
+ }
39
+ function projectResult(r) {
40
+ return {
41
+ server: r.server,
42
+ outcome: outcomeOf(r.outcome),
43
+ prefix: r.prefix,
44
+ toolCount: r.toolCount,
45
+ added: r.added,
46
+ removed: r.removed,
47
+ ...(r.tools !== undefined ? { toolNames: r.tools.map((t) => t.name) } : {}),
48
+ ...(r.reason !== undefined ? { reason: humanText(r.reason) } : {}),
49
+ ...(r.status !== undefined ? { status: projectStatus(r.status) } : {}),
50
+ ...(r.listingIncomplete !== undefined ? { listingIncomplete: projectListingIncomplete(r.listingIncomplete) } : {}),
51
+ };
52
+ }
53
+ export async function handleMcpReconnect(req, res, match, ctx) {
54
+ const { deps } = ctx;
55
+ switch (match.decl.id) {
56
+ case "mcp-reconnect": {
57
+ if (ctx.helpers.rateLimited(req, res))
58
+ return;
59
+ if (!deps.runStore) {
60
+ sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg|local)");
61
+ return;
62
+ }
63
+ const principal = gatedPrincipal(req, deps.config);
64
+ if (deps.config.requirePrincipal && principal === undefined) {
65
+ sendPrincipalRefusal(res, req, deps.config);
66
+ return;
67
+ }
68
+ const parsed = BodySchema.safeParse((await ctx.helpers.readJson(req)) ?? {});
69
+ if (!parsed.success) {
70
+ sendError(res, 400, "request.body_shape", "body must be { server: string (non-empty, at most 190 characters) } — one NAMED MCP server, required; there is no all-servers form (re-dialing every server would CLOSE the healthy ones, and a stdio server that owns a device cannot be re-dialed while the old connection still holds it)");
71
+ return;
72
+ }
73
+ const server = parsed.data.server;
74
+ const sessionId = ctx.helpers.safeDecode(match.params.sessionId);
75
+ if (sessionId === null) {
76
+ sendError(res, 400, "request.path_malformed", "session id segment is not valid percent-encoding");
77
+ return;
78
+ }
79
+ if (!deps.sessionStorage?.ownerOf) {
80
+ sendError(res, 501, "capability.session_ownership_required", "the MCP re-dial verb requires the session-ownership face (sessionStorage.ownerOf)");
81
+ return;
82
+ }
83
+ const owner = await deps.sessionStorage.ownerOf(sessionId);
84
+ if (owner === undefined) {
85
+ sendError(res, 404, "not_found.session", "session not found");
86
+ return;
87
+ }
88
+ const isOperator = explicitOperatorOk(principal, deps.config.operatorPrincipals);
89
+ if (!isOperator && !ctx.helpers.runOwnerOk(req, res, owner, "session"))
90
+ return;
91
+ const activeTaskId = await deps.runStore.getActiveTaskId(sessionId);
92
+ const live = activeTaskId === undefined ? undefined : ctx.registry.steerableRuns.get(activeTaskId);
93
+ if (live) {
94
+ try {
95
+ const result = await live.reconnectMcpServer(server);
96
+ sendJson(res, 200, { taskId: activeTaskId, sessionId, ...projectResult(result) });
97
+ return;
98
+ }
99
+ catch (e) {
100
+ const code = e.code;
101
+ const msg = e instanceof Error ? e.message : String(e);
102
+ switch (code) {
103
+ case "mcp.reconnect_unavailable":
104
+ sendJson(res, 200, { taskId: activeTaskId, sessionId, server, outcome: "unsupported", reason: humanText(msg) });
105
+ return;
106
+ case "steering.invalid_content":
107
+ sendError(res, 422, "steering.invalid_content", msg);
108
+ return;
109
+ case "steering.not_running":
110
+ sendError(res, 409, "steering.not_running", "run just finished — the MCP re-dial verb needs a live run; a resumed continuation takes a fresh first listing of every declared server anyway");
111
+ return;
112
+ default: throw e;
113
+ }
114
+ }
115
+ }
116
+ const run = activeTaskId === undefined ? undefined : await deps.runStore.getRun(activeTaskId);
117
+ if (run && isParkedRunStatus(run.status)) {
118
+ sendError(res, 409, "steering.not_running", "run is waiting on a pending decision — the MCP re-dial verb needs a live turn; a resumed continuation dials every declared server afresh (the re-dial applies to the current turn only and is not persisted), so resume this run first — a re-dial cannot be queued behind the decision");
119
+ return;
120
+ }
121
+ if (run && isTerminalRunStatus(run.status)) {
122
+ sendError(res, 409, "steering.not_running", "run already ended — the MCP re-dial verb needs a live run; the next submit of this session materializes every declared MCP server from scratch");
123
+ return;
124
+ }
125
+ if (run && runProvenElsewhere(run, deps.instanceId)) {
126
+ sendError(res, 409, "steering.not_running", "run is live on another replica — the MCP re-dial verb must reach the replica holding the stream (the MCP connections belong to that process); resend it there");
127
+ return;
128
+ }
129
+ sendError(res, 409, "steering.not_running", "no live steerable face for this session on this replica — there is no active run here to re-dial on (a verify or cascade submission exposes none, and an idle session holds no MCP connections at all)");
130
+ return;
131
+ }
132
+ default:
133
+ return assertNever(match.decl.id);
134
+ }
135
+ }
136
+ export const MCP_RECONNECT_ROUTES = [
137
+ { id: "mcp-reconnect", pattern: /^\/v1\/sessions\/(?<sessionId>[^/]+)\/mcp\/reconnect$/, label: "/v1/sessions/:id/mcp/reconnect", methods: ["POST"] },
138
+ ];
139
+ //# sourceMappingURL=mcp-reconnect.js.map
@@ -63,7 +63,7 @@ function sendScopeGateRefusal(res, gate) {
63
63
  }
64
64
  export async function handleMemoryPolicy(req, res, match, ctx) {
65
65
  const { deps } = ctx;
66
- const { readJson } = ctx.helpers;
66
+ const { readJson, safeDecode } = ctx.helpers;
67
67
  switch (match.decl.id) {
68
68
  case "memory-export": {
69
69
  const principal = gatedPrincipal(req, deps.config);
@@ -135,11 +135,8 @@ export async function handleMemoryPolicy(req, res, match, ctx) {
135
135
  sendError(res, 501, "capability.memory_store_required", "memory sync requires a DB memory backend (MEMORY_ENGINE_BACKEND=pg|tidb)");
136
136
  return;
137
137
  }
138
- let scope;
139
- try {
140
- scope = decodeURIComponent(match.params.scope);
141
- }
142
- catch {
138
+ const scope = safeDecode(match.params.scope);
139
+ if (scope === null) {
143
140
  sendError(res, 400, "memory_sync_invalid_scope", "malformed percent-encoding in :scope");
144
141
  return;
145
142
  }
@@ -6,7 +6,7 @@ import { assertNever } from "../route-ctx.js";
6
6
  export async function handleNotifyWake(req, res, match, ctx) {
7
7
  const { deps } = ctx;
8
8
  const { steerableRuns } = ctx.registry;
9
- const { readJson, rateLimited, quotaExceeded, leaseDenied } = ctx.helpers;
9
+ const { readJson, rateLimited, quotaExceeded, leaseDenied, safeDecode } = ctx.helpers;
10
10
  const { resumeWake } = ctx.legs;
11
11
  switch (match.decl.id) {
12
12
  case "session-notify": {
@@ -17,7 +17,11 @@ export async function handleNotifyWake(req, res, match, ctx) {
17
17
  sendPrincipalRefusal(res, req, deps.config);
18
18
  return;
19
19
  }
20
- const notifySession = decodeURIComponent(match.params.sessionId);
20
+ const notifySession = safeDecode(match.params.sessionId);
21
+ if (notifySession === null) {
22
+ sendError(res, 400, "request.path_malformed", "session id segment is not valid percent-encoding");
23
+ return;
24
+ }
21
25
  let nb;
22
26
  try {
23
27
  nb = (await readJson(req));
@@ -111,7 +115,11 @@ export async function handleNotifyWake(req, res, match, ctx) {
111
115
  sendPrincipalRefusal(res, req, deps.config);
112
116
  return;
113
117
  }
114
- const wakeSession = decodeURIComponent(match.params.sessionId);
118
+ const wakeSession = safeDecode(match.params.sessionId);
119
+ if (wakeSession === null) {
120
+ sendError(res, 400, "request.path_malformed", "session id segment is not valid percent-encoding");
121
+ return;
122
+ }
115
123
  let wakeBody;
116
124
  try {
117
125
  wakeBody = (await readJson(req));
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { isParkedRunStatus, isTerminalRunStatus } from "../../plugins/store-contracts.js";
2
+ import { isParkedRunStatus, isTerminalRunStatus, runProvenElsewhere } from "../../plugins/store-contracts.js";
3
3
  import { sendJson, sendError } from "../send.js";
4
4
  import { gatedPrincipal, sendPrincipalRefusal } from "../principal-gate.js";
5
5
  import { RUN_NOT_FOUND_MESSAGE, runNotFoundMessage } from "../route-ctx.js";
@@ -31,10 +31,8 @@ export async function handleRunMemoryCapture(req, res, match, ctx) {
31
31
  sendError(res, 404, "not_found.run", runNotFoundMessage(taskId));
32
32
  return;
33
33
  }
34
- if (run.owner !== null && run.owner !== principal) {
35
- sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
34
+ if (!ctx.helpers.runOwnerOk(req, res, run.owner))
36
35
  return;
37
- }
38
36
  const live = ctx.registry.steerableRuns.get(taskId);
39
37
  if (live) {
40
38
  try {
@@ -79,7 +77,7 @@ export async function handleRunMemoryCapture(req, res, match, ctx) {
79
77
  sendError(res, 409, "steering.not_running", "run already ended — the memory-capture opt-out verb needs a live run; submit the next turn of this session with memoryCapture: 'off' (the declaration writes the same one-way session record)");
80
78
  return;
81
79
  }
82
- const provenElsewhere = typeof run.instanceId === "string" && run.instanceId.length > 0 && deps.instanceId !== undefined && run.instanceId !== deps.instanceId;
80
+ const provenElsewhere = runProvenElsewhere(run, deps.instanceId);
83
81
  if (provenElsewhere) {
84
82
  sendError(res, 409, "steering.not_running", "run is live on another replica — the memory-capture opt-out verb must reach the replica holding the stream; resend it there, or declare memoryCapture: 'off' on the next submit");
85
83
  return;