@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.
- package/USAGE.md +19 -1
- package/dist/approval-hmac.d.ts +77 -2
- package/dist/approval-hmac.js +16 -1
- package/dist/boot/stage-01-config.js +2 -2
- package/dist/capabilities/execution-lane.d.ts +22 -0
- package/dist/capabilities/execution-lane.js +8 -0
- package/dist/capabilities/memory-notice.d.ts +4 -1
- package/dist/config-catalog.js +2 -1
- package/dist/execution-lane-caps.d.ts +13 -0
- package/dist/execution-lane-caps.js +1 -0
- package/dist/fleet/fleet-bus.d.ts +23 -3
- package/dist/fleet/fleet-reconciler.d.ts +35 -0
- package/dist/fleet/fleet-reconciler.js +52 -9
- package/dist/http/approval-sweeps.js +1 -1
- package/dist/http/resume-legs.d.ts +2 -1
- package/dist/http/resume-legs.js +24 -8
- package/dist/http/route-ctx.d.ts +51 -17
- package/dist/http/route-ctx.js +0 -3
- package/dist/http/routes/a2a-serve.js +1 -1
- package/dist/http/routes/approvals-assistant.d.ts +46 -23
- package/dist/http/routes/approvals-assistant.js +167 -45
- package/dist/http/routes/capabilities.js +2 -0
- package/dist/http/routes/images.js +38 -8
- package/dist/http/routes/mcp-reconnect.d.ts +1 -1
- package/dist/http/routes/mcp-reconnect.js +2 -2
- package/dist/http/routes/memory-policy.js +3 -6
- package/dist/http/routes/notify-wake.js +11 -3
- package/dist/http/routes/run-memory-capture.js +3 -5
- package/dist/http/routes/runs.js +21 -23
- package/dist/http/routes/sessions.js +10 -2
- package/dist/http/routes/shared-memory.js +5 -1
- package/dist/http/routes/tasks.js +2 -2
- package/dist/http/routes/workflows.js +1 -1
- package/dist/http/server.d.ts +2 -2
- package/dist/http/server.js +3 -3
- package/dist/leader/diffout.js +1 -1
- package/dist/leader/endpoint.js +1 -1
- package/dist/leader/fanout.js +1 -1
- package/dist/leader/leader.js +1 -1
- package/dist/leader/merge.js +1 -1
- package/dist/leader/planner.js +1 -1
- package/dist/leader/repair-oracle.js +1 -1
- package/dist/leader/repair-wire.js +1 -1
- package/dist/leader/wire.js +1 -1
- package/dist/memory-scope.d.ts +16 -0
- package/dist/observability/fail-open.d.ts +8 -0
- package/dist/observability/fail-open.js +8 -0
- package/dist/observability/metrics.d.ts +12 -0
- package/dist/observability/metrics.js +17 -0
- package/dist/observability/{run-terminal-log.d.ts → run-terminal.d.ts} +50 -10
- package/dist/observability/{run-terminal-log.js → run-terminal.js} +27 -8
- package/dist/plugins/checkpoint-store-sql.d.ts +8 -2
- package/dist/plugins/file-run-store.js +2 -2
- package/dist/plugins/memory-run-store.js +2 -2
- package/dist/plugins/remote-scratchpad.js +1 -1
- package/dist/plugins/run-store-sql.js +8 -7
- package/dist/plugins/store-contracts.d.ts +25 -2
- package/dist/plugins/store-contracts.js +3 -0
- package/dist/plugins/workflow-run-store-sql.js +1 -1
- package/dist/principal-jwt.d.ts +15 -7
- package/dist/principal-jwt.js +1 -1
- package/dist/runs.js +1 -1
- package/dist/session-titler.js +9 -5
- package/dist/task-settings.d.ts +35 -0
- package/dist/task-settings.js +7 -0
- package/dist/tool-approval.d.ts +22 -2
- package/dist/tool-approval.js +2 -0
- package/dist/trace/core-keyset-guard.d.ts +1 -1
- package/dist/trace/ledger-events.d.ts +1 -1
- package/dist/trace/project.d.ts +1 -1
- package/dist/trace/project.js +1 -1
- package/dist/trace/redact.d.ts +1 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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) },
|
|
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
|
-
|
|
461
|
-
|
|
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
|
-
|
|
516
|
-
|
|
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
|
-
|
|
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) },
|
|
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
|
-
|
|
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
|
-
|
|
631
|
-
|
|
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
|
-
|
|
645
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
139
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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 (
|
|
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 {
|