@sema-agent/server 7.85.0 → 7.87.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 (73) hide show
  1. package/USAGE.md +19 -1
  2. package/dist/approval-hmac.d.ts +77 -2
  3. package/dist/approval-hmac.js +16 -1
  4. package/dist/boot/stage-01-config.js +2 -2
  5. package/dist/capabilities/execution-lane.d.ts +22 -0
  6. package/dist/capabilities/execution-lane.js +8 -0
  7. package/dist/capabilities/memory-notice.d.ts +4 -1
  8. package/dist/config-catalog.js +2 -1
  9. package/dist/execution-lane-caps.d.ts +13 -0
  10. package/dist/execution-lane-caps.js +1 -0
  11. package/dist/fleet/fleet-bus.d.ts +23 -3
  12. package/dist/fleet/fleet-reconciler.d.ts +35 -0
  13. package/dist/fleet/fleet-reconciler.js +52 -9
  14. package/dist/http/approval-sweeps.js +1 -1
  15. package/dist/http/resume-legs.d.ts +2 -1
  16. package/dist/http/resume-legs.js +24 -8
  17. package/dist/http/route-ctx.d.ts +51 -17
  18. package/dist/http/route-ctx.js +0 -3
  19. package/dist/http/routes/a2a-serve.js +1 -1
  20. package/dist/http/routes/approvals-assistant.d.ts +46 -23
  21. package/dist/http/routes/approvals-assistant.js +167 -45
  22. package/dist/http/routes/capabilities.js +2 -0
  23. package/dist/http/routes/images.js +38 -8
  24. package/dist/http/routes/mcp-reconnect.d.ts +1 -1
  25. package/dist/http/routes/mcp-reconnect.js +2 -2
  26. package/dist/http/routes/memory-policy.js +3 -6
  27. package/dist/http/routes/notify-wake.js +11 -3
  28. package/dist/http/routes/run-memory-capture.js +3 -5
  29. package/dist/http/routes/runs.js +21 -23
  30. package/dist/http/routes/sessions.js +10 -2
  31. package/dist/http/routes/shared-memory.js +5 -1
  32. package/dist/http/routes/tasks.js +2 -2
  33. package/dist/http/routes/workflows.js +1 -1
  34. package/dist/http/server.d.ts +2 -2
  35. package/dist/http/server.js +3 -3
  36. package/dist/leader/diffout.js +1 -1
  37. package/dist/leader/endpoint.js +1 -1
  38. package/dist/leader/fanout.js +1 -1
  39. package/dist/leader/leader.js +1 -1
  40. package/dist/leader/merge.js +1 -1
  41. package/dist/leader/planner.js +1 -1
  42. package/dist/leader/repair-oracle.js +1 -1
  43. package/dist/leader/repair-wire.js +1 -1
  44. package/dist/leader/wire.js +1 -1
  45. package/dist/memory-scope.d.ts +16 -0
  46. package/dist/observability/fail-open.d.ts +8 -0
  47. package/dist/observability/fail-open.js +8 -0
  48. package/dist/observability/metrics.d.ts +12 -0
  49. package/dist/observability/metrics.js +17 -0
  50. package/dist/observability/{run-terminal-log.d.ts → run-terminal.d.ts} +50 -10
  51. package/dist/observability/{run-terminal-log.js → run-terminal.js} +27 -8
  52. package/dist/plugins/checkpoint-store-sql.d.ts +8 -2
  53. package/dist/plugins/file-run-store.js +2 -2
  54. package/dist/plugins/memory-run-store.js +2 -2
  55. package/dist/plugins/remote-scratchpad.js +1 -1
  56. package/dist/plugins/run-store-sql.js +8 -7
  57. package/dist/plugins/store-contracts.d.ts +25 -2
  58. package/dist/plugins/store-contracts.js +3 -0
  59. package/dist/plugins/workflow-run-store-sql.js +1 -1
  60. package/dist/principal-jwt.d.ts +15 -7
  61. package/dist/principal-jwt.js +1 -1
  62. package/dist/runs.js +1 -1
  63. package/dist/session-titler.js +9 -5
  64. package/dist/task-settings.d.ts +35 -0
  65. package/dist/task-settings.js +7 -0
  66. package/dist/tool-approval.d.ts +22 -2
  67. package/dist/tool-approval.js +2 -0
  68. package/dist/trace/core-keyset-guard.d.ts +1 -1
  69. package/dist/trace/ledger-events.d.ts +1 -1
  70. package/dist/trace/project.d.ts +1 -1
  71. package/dist/trace/project.js +1 -1
  72. package/dist/trace/redact.d.ts +1 -1
  73. package/package.json +2 -2
@@ -6,10 +6,11 @@ import { APPROVAL_GATE_KINDS } from "../../tool-approval.js";
6
6
  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
- import { MAX_APPROVAL_REASON_CHARS } from "../../approval-hmac.js";
9
+ import { MAX_APPROVAL_REASON_CHARS, projectQuestionAnswer, questionAnswerFromProjection } 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
- import { redactRiskDescriptor } from "../../observability/run-terminal-log.js";
13
+ import { redactRiskDescriptor } from "../../observability/run-terminal.js";
13
14
  import { fleetRunLabels } from "../../fleet/fleet-bus.js";
14
15
  import { sleep } from "../sse-log.js";
15
16
  import { bindSseLifecycle } from "../sse-lifecycle.js";
@@ -22,6 +23,8 @@ import { MAX_KEY_ECHO, keyClosureClause, scanKeyClosure } from "../../request-ke
22
23
  const DECIDE_BODY_DECLARED_KEYS = [
23
24
  "answer", "boundCallId", "boundInputHash", "checkpointToken", "decision", "reason", "remember", "updatedInput",
24
25
  ];
26
+ const DECIDE_PROOF_COVERED_KEYS = ["answer", "boundCallId", "boundInputHash", "decision", "reason"];
27
+ const DECIDE_NOT_IN_PROOF_KEYS = ["checkpointToken", "remember", "updatedInput"];
25
28
  const ACCEPTED_DECIDE_BODY = new Set(DECIDE_BODY_DECLARED_KEYS);
26
29
  const UNSUPPORTED_DECIDE_BODY = new Set(["editedPlan", "sessionId", "taskId"]);
27
30
  export function decideBodyKeyIssue(raw) {
@@ -46,16 +49,76 @@ export function decideBodyKeyIssue(raw) {
46
49
  unsupportedKeys: buckets.unsupported.slice(0, MAX_KEY_ECHO),
47
50
  };
48
51
  }
52
+ const PLAN_REVIEW_BODY_DECLARED_KEYS = [
53
+ "boundCallId", "decision", "editedPlan", "permissionModeAfter", "reason",
54
+ ];
55
+ const PLAN_REVIEW_PROOF_COVERED_KEYS = ["boundCallId", "decision", "editedPlan", "permissionModeAfter", "reason"];
56
+ const PLAN_REVIEW_NOT_IN_PROOF_KEYS = [];
57
+ const PROOF_SLOT_OF = {
58
+ answer: "answer",
59
+ boundCallId: "boundCallId",
60
+ boundInputHash: "boundInputHash",
61
+ decision: "decision",
62
+ editedPlan: "boundInputHash",
63
+ permissionModeAfter: "permissionModeAfter",
64
+ reason: "reason",
65
+ };
66
+ function buildApprovalEnvelope(sessionId, covered, reading) {
67
+ const envelope = { sessionId, decision: reading.decision };
68
+ for (const key of covered)
69
+ Object.assign(envelope, { [PROOF_SLOT_OF[key]]: reading[key] });
70
+ return envelope;
71
+ }
72
+ const ACCEPTED_PLAN_REVIEW_BODY = new Set(PLAN_REVIEW_BODY_DECLARED_KEYS);
73
+ const UNSUPPORTED_PLAN_REVIEW_BODY = new Set([
74
+ "answer", "boundInputHash", "checkpointToken", "permissionMode", "remember", "sessionId", "taskId", "updatedInput",
75
+ ]);
76
+ export function planReviewBodyKeyIssue(raw) {
77
+ if (raw === null || raw === undefined || typeof raw !== "object" || Array.isArray(raw))
78
+ return null;
79
+ const buckets = { unknown: [], unsupported: [] };
80
+ scanKeyClosure(raw, "", ACCEPTED_PLAN_REVIEW_BODY, UNSUPPORTED_PLAN_REVIEW_BODY, buckets);
81
+ if (buckets.unknown.length === 0 && buckets.unsupported.length === 0)
82
+ return null;
83
+ buckets.unknown.sort();
84
+ buckets.unsupported.sort();
85
+ const parts = [
86
+ ...(buckets.unknown.length > 0 ? [keyClosureClause(buckets.unknown, "unknown (not a plan-review-request key — check the spelling)")] : []),
87
+ ...(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)")] : []),
88
+ ];
89
+ return {
90
+ message: `body: unrecognized top-level key(s) — refused rather than dropped, because a dropped key reads as "in effect" to the caller ` +
91
+ `(a misspelled \`permissionModeAfter\` silently leaves this task read-only after you approved its plan). ` +
92
+ `${parts.join("; ")}. ` +
93
+ `Accepted: ${[...ACCEPTED_PLAN_REVIEW_BODY].sort().join(",")}`,
94
+ unknownKeys: buckets.unknown.slice(0, MAX_KEY_ECHO),
95
+ unsupportedKeys: buckets.unsupported.slice(0, MAX_KEY_ECHO),
96
+ };
97
+ }
49
98
  export function decisionReasonOf(body) {
50
99
  return typeof body.reason === "string" && body.reason !== "" ? body.reason : undefined;
51
100
  }
52
- export function isQuestionAnswer(v) {
101
+ const QUESTION_ANSWER_TOP_KEYS = ["answers"];
102
+ const QUESTION_ANSWER_ITEM_KEYS = ["header", "selected", "note"];
103
+ const ACCEPTED_QUESTION_ANSWER_TOP = new Set(QUESTION_ANSWER_TOP_KEYS);
104
+ const ACCEPTED_QUESTION_ANSWER_ITEM = new Set(QUESTION_ANSWER_ITEM_KEYS);
105
+ const NO_TOLERATED_KEYS = new Set();
106
+ export function checkQuestionAnswer(v) {
53
107
  if (!v || typeof v !== "object" || Array.isArray(v))
54
- return false;
108
+ return { ok: false, kind: "shape" };
55
109
  const a = v.answers;
56
110
  if (!Array.isArray(a) || a.length === 0)
57
- return false;
58
- return a.every((it) => {
111
+ return { ok: false, kind: "shape" };
112
+ const buckets = { unknown: [], unsupported: [] };
113
+ scanKeyClosure(v, "answer.", ACCEPTED_QUESTION_ANSWER_TOP, NO_TOLERATED_KEYS, buckets);
114
+ a.forEach((it, i) => {
115
+ if (!it || typeof it !== "object" || Array.isArray(it))
116
+ return;
117
+ scanKeyClosure(it, `answer.answers[${i}].`, ACCEPTED_QUESTION_ANSWER_ITEM, NO_TOLERATED_KEYS, buckets);
118
+ });
119
+ if (buckets.unknown.length > 0)
120
+ return { ok: false, kind: "unknown_key", keyPaths: buckets.unknown.sort().slice(0, MAX_KEY_ECHO) };
121
+ const shapeOk = a.every((it) => {
59
122
  if (!it || typeof it !== "object" || Array.isArray(it))
60
123
  return false;
61
124
  const { header, selected, note } = it;
@@ -65,6 +128,7 @@ export function isQuestionAnswer(v) {
65
128
  selected.every((s) => typeof s === "string") &&
66
129
  (note === undefined || typeof note === "string"));
67
130
  });
131
+ return shapeOk ? { ok: true, value: v } : { ok: false, kind: "shape" };
68
132
  }
69
133
  async function decideIdempotentReplay(cs, args) {
70
134
  const { sessionId, out, binding, decision, payload, deciderPrincipal, explicitOperator, warn } = args;
@@ -133,7 +197,7 @@ async function decideIdempotentReplay(cs, args) {
133
197
  export async function handleApprovalsAssistant(req, res, match, ctx) {
134
198
  const { deps } = ctx;
135
199
  const { preemptableRuns } = ctx.registry;
136
- const { readJson, rateLimited, quotaExceeded } = ctx.helpers;
200
+ const { readJson, rateLimited, quotaExceeded, safeDecode } = ctx.helpers;
137
201
  const { resumeCheckpoint, resumePreempted, resumePlanReview } = ctx.legs;
138
202
  {
139
203
  const cs = deps.checkpointStore;
@@ -143,7 +207,7 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
143
207
  const scope = operator
144
208
  ? (new URLSearchParams(match.search).get("owner") ?? undefined)
145
209
  : (principal ?? "__none__");
146
- await streamApprovals(req, res, cs, scope, deps.config);
210
+ await streamApprovals(req, res, cs, deps.toolApproval, scope, deps.config);
147
211
  return;
148
212
  }
149
213
  if (match.decl.id === "approvals-list") {
@@ -164,13 +228,9 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
164
228
  sendError(res, 501, "capability.store_required", "approval exemptions need a store backend");
165
229
  return;
166
230
  }
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 {
231
+ const exSessionId = safeDecode(match.params.sessionId);
232
+ const exToolName = match.params.tool !== undefined ? safeDecode(match.params.tool) : undefined;
233
+ if (exSessionId === null || exToolName === null) {
174
234
  sendError(res, 400, "request.path_malformed", "malformed id (invalid percent-encoding)");
175
235
  return;
176
236
  }
@@ -365,9 +425,16 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
365
425
  const body = (await readJson(req));
366
426
  const decision = body?.decision === "approve" ? "approve" : body?.decision === "edit" ? "edit" : body?.decision === "reject" ? "reject" : undefined;
367
427
  if (!decision) {
368
- sendError(res, 400, "request.body_shape", "body must be { decision: 'approve' | 'edit' | 'reject', editedPlan?, reason? }");
428
+ sendError(res, 400, "request.body_shape", "body must be { decision: 'approve' | 'edit' | 'reject', editedPlan?, reason?, permissionModeAfter? }");
369
429
  return;
370
430
  }
431
+ {
432
+ const keyIssue = planReviewBodyKeyIssue(body);
433
+ if (keyIssue) {
434
+ sendError(res, 400, "request.body_shape", keyIssue.message, { unknownKeys: keyIssue.unknownKeys, unsupportedKeys: keyIssue.unsupportedKeys });
435
+ return;
436
+ }
437
+ }
371
438
  if (decision === "edit" && (typeof body.editedPlan !== "string" || body.editedPlan.length === 0)) {
372
439
  sendError(res, 400, "request.field_conflict", "decision 'edit' requires a non-empty editedPlan (the operator's revised plan)");
373
440
  return;
@@ -380,6 +447,14 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
380
447
  sendError(res, 400, "request.field_conflict", `editedPlan is only valid with decision 'edit' (got '${decision}')`);
381
448
  return;
382
449
  }
450
+ if (body.permissionModeAfter !== undefined && !isPlanApprovePermissionModeAfter(body.permissionModeAfter)) {
451
+ 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)`);
452
+ return;
453
+ }
454
+ if (decision !== "approve" && body.permissionModeAfter !== undefined) {
455
+ sendError(res, 400, "request.field_conflict", `permissionModeAfter is only valid with decision 'approve' (got '${decision}')`);
456
+ return;
457
+ }
383
458
  if (body.reason !== undefined && typeof body.reason !== "string") {
384
459
  sendError(res, 400, "request.field_invalid", "reason must be a string");
385
460
  return;
@@ -423,7 +498,13 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
423
498
  }
424
499
  const storeNowMs = await store.nowMs();
425
500
  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) });
501
+ const proof = verifyDirectDoorProof({ jwt: hdr(PRINCIPAL_TOKEN_HEADER), mac: hdr(APPROVAL_MAC_HEADER), kid: hdr(APPROVAL_MAC_KID_HEADER) }, buildApprovalEnvelope(run.sessionId, PLAN_REVIEW_PROOF_COVERED_KEYS, {
502
+ boundCallId: planNonce ?? null,
503
+ decision,
504
+ editedPlan: decision === "edit" ? editedPlanBinding(body.editedPlan) : null,
505
+ permissionModeAfter: isPlanApprovePermissionModeAfter(body.permissionModeAfter) ? body.permissionModeAfter : undefined,
506
+ reason: typeof body.reason === "string" ? body.reason : null,
507
+ }), deps.config, { actionBinding: false, nowSec: Math.floor(storeNowMs / 1000) });
427
508
  if (!proof.ok) {
428
509
  sendError(res, proof.status, proof.errorCode, proof.error);
429
510
  return;
@@ -438,6 +519,13 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
438
519
  sendError(res, 404, "not_found.run", "task not found");
439
520
  return;
440
521
  }
522
+ if (body.permissionModeAfter !== undefined) {
523
+ const storedCtx = await deps.checkpointStore?.getCtx(run.sessionId).catch(() => null);
524
+ if (storedCtx?.body !== undefined && storedBodyPermissionMode(storedCtx.body) !== "plan") {
525
+ 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");
526
+ return;
527
+ }
528
+ }
441
529
  if (singleUse !== undefined) {
442
530
  const { proof: onceOnly, store } = singleUse;
443
531
  const fresh = await store.consume(run.sessionId, onceOnly.nonce, onceOnly.acceptedBeforeMs);
@@ -450,18 +538,15 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
450
538
  return;
451
539
  }
452
540
  }
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 });
541
+ 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
542
  sendResumeOutcome(res, out, deps.logger);
455
543
  return;
456
544
  }
457
545
  if (match.decl.id === "approval-decide") {
458
546
  if (rateLimited(req, res) || quotaExceeded(req, res))
459
547
  return;
460
- let sessionId;
461
- try {
462
- sessionId = decodeURIComponent(match.params.sessionId);
463
- }
464
- catch {
548
+ const sessionId = safeDecode(match.params.sessionId);
549
+ if (sessionId === null) {
465
550
  sendError(res, 400, "request.path_malformed", "malformed approval id (invalid percent-encoding)");
466
551
  return;
467
552
  }
@@ -511,12 +596,19 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
511
596
  remember = true;
512
597
  }
513
598
  let answer;
599
+ let answerProjection;
514
600
  if (body.answer !== undefined) {
515
- if (!isQuestionAnswer(body.answer)) {
516
- sendError(res, 400, "request.body_shape", "malformed answer — expected { answers: [{ header, selected: string[], note? }] }");
601
+ const checked = checkQuestionAnswer(body.answer);
602
+ if (!checked.ok) {
603
+ if (checked.kind === "shape") {
604
+ sendError(res, 400, "request.body_shape", "malformed answer — expected { answers: [{ header, selected: string[], note? }] }");
605
+ return;
606
+ }
607
+ sendError(res, 400, "request.field_invalid", `answer: unrecognized key(s) — refused rather than dropped, because a dropped key reads as "in effect" to the caller (a misspelled selected key silently drops part of the operator's answer). ${keyClosureClause([...checked.keyPaths], "unknown (not a question-answer key — check the spelling)")}. Accepted: answers, answers[].header, answers[].selected, answers[].note`, { unknownKeys: [...checked.keyPaths] });
517
608
  return;
518
609
  }
519
- answer = body.answer;
610
+ answerProjection = projectQuestionAnswer(checked.value);
611
+ answer = questionAnswerFromProjection(answerProjection);
520
612
  }
521
613
  for (const f of ["checkpointToken", "boundCallId", "boundInputHash"]) {
522
614
  if (body[f] !== undefined && typeof body[f] !== "string") {
@@ -528,6 +620,10 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
528
620
  sendError(res, 400, "updated_input_not_in_proof", "updatedInput is not supported on a direct-door worker (not covered by the decision proof)");
529
621
  return;
530
622
  }
623
+ if (deps.config.directDoorActive && body.checkpointToken !== undefined) {
624
+ sendError(res, 400, "checkpoint_token_not_in_proof", "checkpointToken is not supported on a direct-door worker (not covered by the decision proof); bind the decision with boundCallId and boundInputHash instead");
625
+ return;
626
+ }
531
627
  const binding = {
532
628
  checkpointToken: body.checkpointToken,
533
629
  boundCallId: body.boundCallId,
@@ -540,7 +636,13 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
540
636
  const h = req.headers[n];
541
637
  return Array.isArray(h) ? h[0] : h;
542
638
  };
543
- const proof = verifyDirectDoorProof({ jwt: hdr(PRINCIPAL_TOKEN_HEADER), mac: hdr(APPROVAL_MAC_HEADER), kid: hdr(APPROVAL_MAC_KID_HEADER) }, { sessionId, boundCallId: binding.boundCallId, boundInputHash: binding.boundInputHash, decision, reason: typeof body.reason === "string" ? body.reason : null }, deps.config);
639
+ const proof = verifyDirectDoorProof({ jwt: hdr(PRINCIPAL_TOKEN_HEADER), mac: hdr(APPROVAL_MAC_HEADER), kid: hdr(APPROVAL_MAC_KID_HEADER) }, buildApprovalEnvelope(sessionId, DECIDE_PROOF_COVERED_KEYS, {
640
+ answer: answerProjection,
641
+ boundCallId: binding.boundCallId,
642
+ boundInputHash: binding.boundInputHash,
643
+ decision,
644
+ reason: typeof body.reason === "string" ? body.reason : null,
645
+ }), deps.config);
544
646
  if (!proof.ok) {
545
647
  sendError(res, proof.status, proof.errorCode, proof.error);
546
648
  return;
@@ -619,7 +721,24 @@ function projectPendingForWire(row, governance) {
619
721
  const governanceForced = governanceOriginOf({ riskDescriptor: row.riskDescriptor ?? undefined }, governance);
620
722
  return governanceForced ? { ...redacted, governanceForced } : redacted;
621
723
  }
622
- export async function streamApprovals(req, res, cs, scope, governance, pollMs = APPROVALS_STREAM_POLL_MS) {
724
+ function createSseDeltaLane(res, spec) {
725
+ let prev = new Map();
726
+ return {
727
+ tick(rows) {
728
+ const cur = new Map(rows.map((r) => [spec.keyOf(r), { row: r, bytes: spec.bytesOf(r) }]));
729
+ for (const [k, v] of cur) {
730
+ if (prev.get(k)?.bytes !== v.bytes)
731
+ res.write(`event: ${spec.upsertEvent}\ndata: ${JSON.stringify(spec.upsertData(v.row, Date.now()))}\n\n`);
732
+ }
733
+ for (const [k, v] of prev) {
734
+ if (!cur.has(k))
735
+ res.write(`event: ${spec.exitEvent}\ndata: ${JSON.stringify(spec.exitData(v.row, Date.now()))}\n\n`);
736
+ }
737
+ prev = cur;
738
+ },
739
+ };
740
+ }
741
+ export async function streamApprovals(req, res, cs, live, scope, governance, pollMs = APPROVALS_STREAM_POLL_MS) {
623
742
  const keyOf = (p) => JSON.stringify([p.sessionId, p.toolCallId ?? null]);
624
743
  sseHeaders(res);
625
744
  res.write(`event: meta\ndata: ${JSON.stringify({ type: "meta", version: 1, mode: "approvals-delta", pollMs: APPROVALS_STREAM_POLL_MS })}\n\n`);
@@ -627,13 +746,29 @@ export async function streamApprovals(req, res, cs, scope, governance, pollMs =
627
746
  bindSseLifecycle(req, res, () => { closed = true; });
628
747
  const start = Date.now();
629
748
  let lastBeat = Date.now();
630
- let prev = new Map();
631
- let prevFrames = new Map();
749
+ const durableLane = createSseDeltaLane(res, {
750
+ upsertEvent: "pending",
751
+ exitEvent: "resolved",
752
+ keyOf,
753
+ bytesOf: (p) => JSON.stringify({ type: "pending", ...p }),
754
+ upsertData: (p, nowMs) => ({ type: "pending", ...p, serverNowMs: nowMs }),
755
+ exitData: (p, nowMs) => ({ type: "resolved", sessionId: p.sessionId, toolCallId: p.toolCallId, serverNowMs: nowMs }),
756
+ });
757
+ const liveLane = createSseDeltaLane(res, {
758
+ upsertEvent: "live_pending",
759
+ exitEvent: "live_resolved",
760
+ keyOf: (r) => r.approvalId,
761
+ bytesOf: (r) => JSON.stringify({ type: "live_pending", ...r }),
762
+ upsertData: (r, nowMs) => ({ type: "live_pending", ...r, serverNowMs: nowMs }),
763
+ exitData: (r, nowMs) => ({ type: "live_resolved", approvalId: r.approvalId, serverNowMs: nowMs }),
764
+ });
632
765
  let first = true;
633
766
  while (!closed) {
634
767
  let pending;
768
+ let livePending;
635
769
  try {
636
770
  pending = (await cs.listPending(scope)).map((r) => projectPendingForWire(r, governance));
771
+ livePending = live ? live.listLivePending(scope) : [];
637
772
  }
638
773
  catch {
639
774
  if (!res.writableEnded)
@@ -641,25 +776,12 @@ export async function streamApprovals(req, res, cs, scope, governance, pollMs =
641
776
  await sleep(pollMs);
642
777
  continue;
643
778
  }
644
- const cur = new Map(pending.map((p) => [keyOf(p), p]));
645
- const curFrames = new Map([...cur].map(([k, p]) => [k, JSON.stringify({ type: "pending", ...p })]));
646
- const pendingFrame = (k) => JSON.stringify({ type: "pending", ...cur.get(k), serverNowMs: Date.now() });
779
+ durableLane.tick(pending);
780
+ liveLane.tick(livePending);
647
781
  if (first) {
648
- for (const k of curFrames.keys())
649
- res.write(`event: pending\ndata: ${pendingFrame(k)}\n\n`);
650
782
  res.write(`event: synced\ndata: ${JSON.stringify({ type: "synced", count: pending.length })}\n\n`);
651
783
  first = false;
652
784
  }
653
- else {
654
- for (const [k, frame] of curFrames)
655
- if (prevFrames.get(k) !== frame)
656
- res.write(`event: pending\ndata: ${pendingFrame(k)}\n\n`);
657
- for (const [k, p] of prev)
658
- if (!cur.has(k))
659
- res.write(`event: resolved\ndata: ${JSON.stringify({ type: "resolved", sessionId: p.sessionId, toolCallId: p.toolCallId, serverNowMs: Date.now() })}\n\n`);
660
- }
661
- prev = cur;
662
- prevFrames = curFrames;
663
785
  if (Date.now() - start > SSE_MAX_STREAM_MS) {
664
786
  res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "STREAM_MAX_DURATION", message: "approvals stream reached its 15-minute cap — reconnect to continue" })}\n\n`);
665
787
  break;
@@ -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";
@@ -126,6 +127,7 @@ export async function handleCapabilities(req, res, match, ctx) {
126
127
  interactiveTools: true,
127
128
  projectContext: cwdHonored(deps.config),
128
129
  callerCwd: cwdHonored(deps.config) || deviceCwdHonored(deps.config),
130
+ executionLane: projectExecutionLaneCapability(deps.config),
129
131
  mcpInjection: mcpInjectionHonored(deps.config),
130
132
  a2a: true,
131
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;
@@ -33,7 +33,7 @@
33
33
  * 路径是 session,动词挂在 **run 句柄**上 ⇒ 会话属主门(`sessionStorage.ownerOf`,未知与他人**同码同串**
34
34
  * 404 无 oracle,与 `POST /v1/sessions/:id/notify` 逐字同形)→ `getActiveTaskId` 取本会话的活 run →
35
35
  * `steerableRuns` 取本副本活流。非 live 的四臂一码 409 `steering.not_running`,判据取词表属主
36
- * (`isParkedRunStatus` / `isTerminalRunStatus`)与 `runProvenElsewhere`(四处手抄已收编成一只谓词)。
36
+ * (`isParkedRunStatus` / `isTerminalRunStatus`)与 `runProvenElsewhere`(手抄已收编成一只谓词,S-430 起同住 `plugins/store-contracts.ts`)。
37
37
  * 显式 operator 可重拨任何租户的会话(监督动作,与 notify / steer 同臂;**不是** bare `isOperator`)。
38
38
  *
39
39
  * ── 计费/门 ─────────────────────────────────────────────────────────────────────────────────────
@@ -1,9 +1,9 @@
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 { redactSecrets } from "../../trace/redact.js";
4
4
  import { sendJson, sendError } from "../send.js";
5
5
  import { gatedPrincipal, explicitOperatorOk, sendPrincipalRefusal } from "../principal-gate.js";
6
- import { runProvenElsewhere } from "../route-ctx.js";
6
+ import {} from "../route-ctx.js";
7
7
  import { assertNever } from "../route-ctx.js";
8
8
  const BodySchema = z.object({ server: z.string().min(1).max(190) }).strict();
9
9
  const NEUTRALIZED_TEXT_MAX = 200;
@@ -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,8 +1,8 @@
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
- import { RUN_NOT_FOUND_MESSAGE, runNotFoundMessage, runProvenElsewhere } from "../route-ctx.js";
5
+ import { RUN_NOT_FOUND_MESSAGE, runNotFoundMessage } from "../route-ctx.js";
6
6
  import { assertNever } from "../route-ctx.js";
7
7
  const BodySchema = z.object({ reason: z.string().min(1).max(500).optional() }).strict();
8
8
  export async function handleRunMemoryCapture(req, res, match, ctx) {
@@ -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 {