@sema-agent/server 1.316.0 → 1.317.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 (50) hide show
  1. package/README.md +9 -0
  2. package/README.zh-CN.md +7 -0
  3. package/dist/config-types.d.ts +1 -1
  4. package/dist/config.d.ts +14 -0
  5. package/dist/config.js +148 -45
  6. package/dist/elicitation.js +2 -2
  7. package/dist/http/route-ctx.d.ts +51 -2
  8. package/dist/http/routes/approvals-assistant.d.ts +11 -0
  9. package/dist/http/routes/approvals-assistant.js +530 -0
  10. package/dist/http/routes/attachments.js +7 -7
  11. package/dist/http/routes/fleet.d.ts +4 -0
  12. package/dist/http/routes/fleet.js +147 -0
  13. package/dist/http/routes/images.js +29 -29
  14. package/dist/http/routes/leader.d.ts +4 -0
  15. package/dist/http/routes/leader.js +48 -0
  16. package/dist/http/routes/memory-policy.js +10 -10
  17. package/dist/http/routes/notify-wake.d.ts +4 -0
  18. package/dist/http/routes/notify-wake.js +133 -0
  19. package/dist/http/routes/observability.js +5 -5
  20. package/dist/http/routes/runs.d.ts +19 -0
  21. package/dist/http/routes/runs.js +967 -0
  22. package/dist/http/routes/session-sync.js +28 -28
  23. package/dist/http/routes/sessions-list.js +8 -8
  24. package/dist/http/routes/sessions.js +47 -47
  25. package/dist/http/routes/side-query.d.ts +4 -0
  26. package/dist/http/routes/side-query.js +88 -0
  27. package/dist/http/routes/tasks.d.ts +4 -0
  28. package/dist/http/routes/tasks.js +632 -0
  29. package/dist/http/routes/trace-usage.d.ts +4 -0
  30. package/dist/http/routes/trace-usage.js +239 -0
  31. package/dist/http/routes/workflows.d.ts +5 -0
  32. package/dist/http/routes/workflows.js +337 -0
  33. package/dist/http/run-meta.d.ts +11 -0
  34. package/dist/http/run-meta.js +16 -0
  35. package/dist/http/send.d.ts +1 -0
  36. package/dist/http/send.js +15 -0
  37. package/dist/http/server.d.ts +6 -5
  38. package/dist/http/server.js +241 -3166
  39. package/dist/http/sse-log.js +2 -2
  40. package/dist/http/wire-types.d.ts +6 -0
  41. package/dist/leader/endpoint.js +4 -4
  42. package/dist/main.js +5 -5
  43. package/dist/plugins/remote-env-host.js +4 -3
  44. package/dist/question.js +2 -2
  45. package/dist/run-local.js +1 -1
  46. package/dist/tool-approval.js +2 -2
  47. package/dist/trace/ledger-sink.js +1 -1
  48. package/dist/trace/project.d.ts +1 -0
  49. package/dist/trace/project.js +3 -0
  50. package/package.json +1 -1
@@ -0,0 +1,530 @@
1
+ import { canonicalToolName } from "@sema-agent/core";
2
+ import { principalFrom, verifyDirectDoorProof, MAX_APPROVAL_REASON_CHARS } from "../../security.js";
3
+ import { redactedPreview } from "../../trace/redact.js";
4
+ import { sleep } from "../sse-log.js";
5
+ import { sendJson, sendError, sseHeaders } from "../send.js";
6
+ import { gatedPrincipal, explicitOperatorOk, isOperator, explicitOperator } from "../principal-gate.js";
7
+ export const ASSISTANT_PREEMPT_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/preempt$/;
8
+ export const ASSISTANT_RESUME_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/resume$/;
9
+ export const ASSISTANT_PLAN_REVIEW_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/plan_review$/;
10
+ export function isQuestionAnswer(v) {
11
+ if (!v || typeof v !== "object" || Array.isArray(v))
12
+ return false;
13
+ const a = v.answers;
14
+ if (!Array.isArray(a) || a.length === 0)
15
+ return false;
16
+ return a.every((it) => {
17
+ if (!it || typeof it !== "object" || Array.isArray(it))
18
+ return false;
19
+ const { header, selected, note } = it;
20
+ return (typeof header === "string" &&
21
+ Array.isArray(selected) &&
22
+ selected.length > 0 &&
23
+ selected.every((s) => typeof s === "string") &&
24
+ (note === undefined || typeof note === "string"));
25
+ });
26
+ }
27
+ export async function handleApprovalsAssistant(req, res, url, ctx) {
28
+ const miss = { fell: false };
29
+ await handleApprovalsAssistantBody(req, res, url, ctx, miss);
30
+ return !miss.fell;
31
+ }
32
+ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
33
+ const { deps } = ctx;
34
+ const { preemptableRuns } = ctx.registry;
35
+ const { readJson, rateLimited, quotaExceeded } = ctx.helpers;
36
+ const { resumeCheckpoint, resumePreempted, resumePlanReview } = ctx.legs;
37
+ if (deps.checkpointStore && (url.startsWith("/v1/approvals") || url.startsWith("/v1/assistant"))) {
38
+ const cs = deps.checkpointStore;
39
+ const principal = gatedPrincipal(req, deps.config);
40
+ const operator = isOperator(principal, deps.config.operatorPrincipals);
41
+ if (req.method === "GET" && url === "/v1/approvals/stream") {
42
+ const scope = operator
43
+ ? (new URL(req.url ?? "", "http://x").searchParams.get("owner") ?? undefined)
44
+ : (principal ?? "__none__");
45
+ await streamApprovals(req, res, cs, scope);
46
+ return;
47
+ }
48
+ if (req.method === "GET" && url === "/v1/approvals") {
49
+ const scope = operator
50
+ ? (new URL(req.url ?? "", "http://x").searchParams.get("owner") ?? undefined)
51
+ : (principal ?? "__none__");
52
+ sendJson(res, 200, { pending: await cs.listPending(scope) });
53
+ return;
54
+ }
55
+ {
56
+ const em = /^\/v1\/approvals\/([^/]+)\/exemptions(?:\/([^/]+))?$/.exec(url);
57
+ if (em && (req.method === "GET" || req.method === "DELETE")) {
58
+ if (!deps.approvalExemptionStore) {
59
+ sendError(res, 501, "capability.store_required", "approval exemptions need a store backend");
60
+ return;
61
+ }
62
+ let exSessionId;
63
+ let exToolName;
64
+ try {
65
+ exSessionId = decodeURIComponent(em[1]);
66
+ exToolName = em[2] !== undefined ? decodeURIComponent(em[2]) : undefined;
67
+ }
68
+ catch {
69
+ sendError(res, 400, "request.path_malformed", "malformed id (invalid percent-encoding)");
70
+ return;
71
+ }
72
+ const operators = deps.config.operatorPrincipals;
73
+ const isExplicitOperator = operators.length > 0 && principal !== undefined && operators.includes(principal);
74
+ if (!isExplicitOperator) {
75
+ const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
76
+ if (!ownerOf) {
77
+ sendError(res, 501, "capability.session_store_required", "approval exemptions require a session-store backend");
78
+ return;
79
+ }
80
+ const owner = await ownerOf(exSessionId);
81
+ if (owner === undefined || (owner !== null && owner !== principal)) {
82
+ sendError(res, 404, "not_found.session", "not found");
83
+ return;
84
+ }
85
+ }
86
+ if (req.method === "GET") {
87
+ if (exToolName !== undefined) {
88
+ sendError(res, 404, "request.route_unsupported", "not found");
89
+ return;
90
+ }
91
+ sendJson(res, 200, { exemptions: await deps.approvalExemptionStore.list(exSessionId) });
92
+ return;
93
+ }
94
+ if (exToolName === undefined) {
95
+ sendError(res, 400, "request.route_unsupported", "DELETE needs /exemptions/:toolName");
96
+ return;
97
+ }
98
+ const revoked = await deps.approvalExemptionStore.revoke(exSessionId, canonicalToolName(exToolName));
99
+ sendJson(res, revoked ? 200 : 404, revoked ? { revoked: true } : { error: "not found", errorCode: "not_found.exemption" });
100
+ return;
101
+ }
102
+ }
103
+ if (req.method === "GET" && url === "/v1/assistant/inbox") {
104
+ const ownerParam = new URL(req.url ?? "", "http://x").searchParams.get("owner") || undefined;
105
+ const scope = operator && ownerParam ? ownerParam : (principal ?? "__none__");
106
+ const summaries = await cs.listByScope(scope);
107
+ const inbox = (await Promise.all(summaries.map(async (s) => {
108
+ const ctx = await cs.getCtx(s.sessionId).catch(() => null);
109
+ const { token: _token, toolInput: _rawToolInput, ...safe } = s;
110
+ const input = s.toolInput != null ? redactedPreview(s.toolInput) : null;
111
+ return { ...safe, objective: ctx?.body?.objective ?? null, input };
112
+ }))).sort((a, b) => (b.severity ?? 0) - (a.severity ?? 0));
113
+ sendJson(res, 200, { inbox });
114
+ return;
115
+ }
116
+ if (req.method === "GET" && url === "/v1/assistant/tasks") {
117
+ const ownerParam = new URL(req.url ?? "", "http://x").searchParams.get("owner") || undefined;
118
+ const scope = operator && ownerParam ? ownerParam : (principal ?? "__none__");
119
+ if (!deps.runStore) {
120
+ sendJson(res, 200, { tasks: [] });
121
+ return;
122
+ }
123
+ const summaries = await cs.listByScope(scope);
124
+ const byGate = new Map(summaries.map((s) => [s.sessionId, s]));
125
+ const runs = await deps.runStore.listRuns({ owner: scope, limit: 100 });
126
+ const tasks = runs
127
+ .filter((r) => r.status === "running" || r.status === "suspended" || r.status === "needs_review")
128
+ .map((r) => {
129
+ const s = byGate.get(r.sessionId);
130
+ const gate = s ? { kind: s.gateKind, severity: s.severity ?? null, spentMicroUsd: s.spentMicroUsd ?? null, deadline: s.deadline ?? null } : null;
131
+ return { taskId: r.taskId, sessionId: r.sessionId, status: r.status, needsAttention: !!gate, gate, createdAt: r.createdAt, updatedAt: r.updatedAt };
132
+ })
133
+ .sort((a, b) => Number(b.needsAttention) - Number(a.needsAttention) || (b.gate?.severity ?? 0) - (a.gate?.severity ?? 0) || (b.gate?.spentMicroUsd ?? 0) - (a.gate?.spentMicroUsd ?? 0));
134
+ sendJson(res, 200, { tasks });
135
+ return;
136
+ }
137
+ const preemptMatch = req.method === "POST" ? ASSISTANT_PREEMPT_RE.exec(url) : null;
138
+ if (preemptMatch) {
139
+ if (rateLimited(req, res) || quotaExceeded(req, res))
140
+ return;
141
+ if (!deps.runStore) {
142
+ sendError(res, 501, "capability.run_store_required", "preemption requires the TiDB run store");
143
+ return;
144
+ }
145
+ if (deps.config.requirePrincipal && principal === undefined) {
146
+ sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
147
+ return;
148
+ }
149
+ if (!deps.config.resourceSuspend || !deps.checkpointStore) {
150
+ sendError(res, 501, "feature.preemption_disabled", "preemption not enabled on this worker (needs RESOURCE_SUSPEND=true + a durable checkpoint store)");
151
+ return;
152
+ }
153
+ const taskId = preemptMatch[1];
154
+ const run = await deps.runStore.getRun(taskId);
155
+ if (!run) {
156
+ sendError(res, 404, "not_found.run", "task not found");
157
+ return;
158
+ }
159
+ const operators = deps.config.operatorPrincipals;
160
+ const explicitOperator = operators.length > 0 && principal !== undefined && operators.includes(principal);
161
+ if (!explicitOperator && run.owner !== null && run.owner !== principal) {
162
+ sendError(res, 404, "not_found.run", "task not found");
163
+ return;
164
+ }
165
+ if (run.status === "running") {
166
+ const ownedHere = run.instanceId != null && run.instanceId === deps.instanceId;
167
+ if (ownedHere && !preemptableRuns.has(taskId)) {
168
+ sendJson(res, 202, { taskId, status: run.status, note: "task is not preempt-eligible (verify/cascade leg) — preempt is a no-op" });
169
+ return;
170
+ }
171
+ const flagged = await deps.runStore.requestPreempt(taskId, run.owner);
172
+ if (!flagged) {
173
+ const now = await deps.runStore.getRun(taskId);
174
+ sendJson(res, 202, { taskId, status: now?.status ?? "failed", note: "task no longer running — preempt is a no-op" });
175
+ return;
176
+ }
177
+ preemptableRuns.get(taskId)?.abort();
178
+ sendJson(res, 202, { taskId, status: "preempting", note: "graceful durable yield — the task suspends at the next clean turn boundary if preempt-eligible; resume via POST /v1/assistant/tasks/:id/resume" });
179
+ }
180
+ else if (run.status === "suspended") {
181
+ sendError(res, 409, "conflict.already_suspended", "task is already suspended", { taskId, status: "suspended" });
182
+ }
183
+ else {
184
+ sendJson(res, 202, { taskId, status: run.status, note: "task already terminal — preempt is a no-op" });
185
+ }
186
+ return;
187
+ }
188
+ const resumeMatch = req.method === "POST" ? ASSISTANT_RESUME_RE.exec(url) : null;
189
+ if (resumeMatch) {
190
+ if (rateLimited(req, res) || quotaExceeded(req, res))
191
+ return;
192
+ if (!deps.runStore) {
193
+ sendError(res, 501, "capability.run_store_required", "resume requires the TiDB run store");
194
+ return;
195
+ }
196
+ if (deps.config.requirePrincipal && principal === undefined) {
197
+ sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
198
+ return;
199
+ }
200
+ const taskId = resumeMatch[1];
201
+ const run = await deps.runStore.getRun(taskId);
202
+ if (!run) {
203
+ sendError(res, 404, "not_found.run", "task not found");
204
+ return;
205
+ }
206
+ const operators = deps.config.operatorPrincipals;
207
+ const explicitOperator = operators.length > 0 && principal !== undefined && operators.includes(principal);
208
+ if (!explicitOperator && run.owner !== null && run.owner !== principal) {
209
+ sendError(res, 404, "not_found.run", "task not found");
210
+ return;
211
+ }
212
+ const out = await resumePreempted(run.sessionId, req);
213
+ sendJson(res, out.status, out.body);
214
+ return;
215
+ }
216
+ const planReviewMatch = req.method === "POST" ? ASSISTANT_PLAN_REVIEW_RE.exec(url) : null;
217
+ if (planReviewMatch) {
218
+ if (rateLimited(req, res) || quotaExceeded(req, res))
219
+ return;
220
+ if (!deps.runStore) {
221
+ sendError(res, 501, "capability.run_store_required", "plan_review requires the TiDB run store");
222
+ return;
223
+ }
224
+ if (deps.config.requirePrincipal && principal === undefined) {
225
+ sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
226
+ return;
227
+ }
228
+ const taskId = planReviewMatch[1];
229
+ const body = (await readJson(req));
230
+ const decision = body?.decision === "approve" ? "approve" : body?.decision === "edit" ? "edit" : body?.decision === "reject" ? "reject" : undefined;
231
+ if (!decision) {
232
+ sendError(res, 400, "request.body_shape", "body must be { decision: 'approve' | 'edit' | 'reject', editedPlan?, reason? }");
233
+ return;
234
+ }
235
+ if (decision === "edit" && (typeof body.editedPlan !== "string" || body.editedPlan.length === 0)) {
236
+ sendError(res, 400, "request.field_conflict", "decision 'edit' requires a non-empty editedPlan (the operator's revised plan)");
237
+ return;
238
+ }
239
+ if (decision !== "edit" && body.editedPlan !== undefined) {
240
+ sendError(res, 400, "request.field_conflict", `editedPlan is only valid with decision 'edit' (got '${decision}')`);
241
+ return;
242
+ }
243
+ if (body.reason !== undefined && typeof body.reason !== "string") {
244
+ sendError(res, 400, "request.field_invalid", "reason must be a string");
245
+ return;
246
+ }
247
+ if (typeof body.reason === "string" && body.reason.length > MAX_APPROVAL_REASON_CHARS) {
248
+ sendError(res, 413, "reason_too_large", `reason too large (max ${MAX_APPROVAL_REASON_CHARS} chars)`);
249
+ return;
250
+ }
251
+ const run = await deps.runStore.getRun(taskId);
252
+ if (!run) {
253
+ if (deps.config.directDoorActive) {
254
+ sendError(res, 401, "principal_unverified", "principal proof required");
255
+ return;
256
+ }
257
+ sendError(res, 404, "not_found.run", "task not found");
258
+ return;
259
+ }
260
+ let deciderPrincipal = principal;
261
+ if (deps.config.directDoorActive) {
262
+ const hdr = (n) => { const h = req.headers[n]; return Array.isArray(h) ? h[0] : h; };
263
+ const proof = verifyDirectDoorProof({ jwt: hdr("x-approval-principal-token"), mac: hdr("x-approval-mac"), kid: hdr("x-approval-mac-kid") }, { sessionId: run.sessionId, decision, reason: typeof body.reason === "string" ? body.reason : null }, deps.config, { actionBinding: false });
264
+ if (!proof.ok) {
265
+ sendError(res, proof.status, proof.errorCode, proof.error);
266
+ return;
267
+ }
268
+ deciderPrincipal = proof.principal;
269
+ }
270
+ const operators = deps.config.operatorPrincipals;
271
+ const explicitOperator = operators.length > 0 && deciderPrincipal !== undefined && operators.includes(deciderPrincipal);
272
+ if (!explicitOperator && run.owner !== null && run.owner !== deciderPrincipal) {
273
+ sendError(res, 404, "not_found.run", "task not found");
274
+ return;
275
+ }
276
+ const out = await resumePlanReview(run.sessionId, decision, decision === "edit" ? body.editedPlan : undefined, typeof body.reason === "string" ? body.reason : undefined, req);
277
+ sendJson(res, out.status, out.body);
278
+ return;
279
+ }
280
+ const m = /^\/v1\/approvals\/([^/]+?)(?:\/decide)?$/.exec(url);
281
+ if (m && req.method === "POST") {
282
+ if (rateLimited(req, res) || quotaExceeded(req, res))
283
+ return;
284
+ let sessionId;
285
+ try {
286
+ sessionId = decodeURIComponent(m[1]);
287
+ }
288
+ catch {
289
+ sendError(res, 400, "request.path_malformed", "malformed approval id (invalid percent-encoding)");
290
+ return;
291
+ }
292
+ const body = (await readJson(req));
293
+ const decision = body?.decision === "approve" ? "approve" : body?.decision === "deny" ? "deny" : undefined;
294
+ if (!decision) {
295
+ sendError(res, 400, "request.body_shape", "body must be { decision: 'approve' | 'deny', reason?, answer?, checkpointToken?, boundCallId?, boundInputHash?, updatedInput?, remember? }");
296
+ return;
297
+ }
298
+ if (body.reason !== undefined && typeof body.reason !== "string") {
299
+ sendError(res, 400, "request.field_invalid", "reason must be a string");
300
+ return;
301
+ }
302
+ if (typeof body.reason === "string" && body.reason.length > MAX_APPROVAL_REASON_CHARS) {
303
+ sendError(res, 413, "reason_too_large", `reason too large (max ${MAX_APPROVAL_REASON_CHARS} chars)`);
304
+ return;
305
+ }
306
+ let remember = false;
307
+ if (body.remember !== undefined) {
308
+ if (body.remember !== "session") {
309
+ sendError(res, 400, "request.field_invalid", `remember must be "session"`);
310
+ return;
311
+ }
312
+ if (decision !== "approve") {
313
+ sendError(res, 400, "request.field_conflict", "remember requires decision 'approve'");
314
+ return;
315
+ }
316
+ if (deps.config.directDoorActive) {
317
+ sendError(res, 400, "remember_not_in_proof", "remember is not supported on a direct-door worker (not covered by the decision proof)");
318
+ return;
319
+ }
320
+ if (!deps.approvalExemptionStore) {
321
+ sendError(res, 501, "capability.store_required", "approval exemptions need a store backend (DB_BACKEND / LOCAL lane)");
322
+ return;
323
+ }
324
+ if (!body.checkpointToken && !body.boundCallId) {
325
+ sendError(res, 400, "remember_requires_binding", "remember requires the decision binding (echo checkpointToken and/or boundCallId from the pending approval)");
326
+ return;
327
+ }
328
+ remember = true;
329
+ }
330
+ let answer;
331
+ if (body.answer !== undefined) {
332
+ if (!isQuestionAnswer(body.answer)) {
333
+ sendError(res, 400, "request.body_shape", "malformed answer — expected { answers: [{ header, selected: string[], note? }] }");
334
+ return;
335
+ }
336
+ answer = body.answer;
337
+ }
338
+ for (const f of ["checkpointToken", "boundCallId", "boundInputHash"]) {
339
+ if (body[f] !== undefined && typeof body[f] !== "string") {
340
+ sendError(res, 400, "request.field_invalid", `${f} must be a string (the value surfaced on the pending approval record)`);
341
+ return;
342
+ }
343
+ }
344
+ if (deps.config.directDoorActive && body.updatedInput !== undefined) {
345
+ sendError(res, 400, "updated_input_not_in_proof", "updatedInput is not supported on a direct-door worker (not covered by the decision proof)");
346
+ return;
347
+ }
348
+ const binding = {
349
+ checkpointToken: body.checkpointToken,
350
+ boundCallId: body.boundCallId,
351
+ boundInputHash: body.boundInputHash,
352
+ updatedInput: body.updatedInput,
353
+ };
354
+ let deciderPrincipal = principal;
355
+ if (deps.config.directDoorActive) {
356
+ const hdr = (n) => {
357
+ const h = req.headers[n];
358
+ return Array.isArray(h) ? h[0] : h;
359
+ };
360
+ const proof = verifyDirectDoorProof({ jwt: hdr("x-approval-principal-token"), mac: hdr("x-approval-mac"), kid: hdr("x-approval-mac-kid") }, { sessionId, boundCallId: binding.boundCallId, boundInputHash: binding.boundInputHash, decision, reason: typeof body.reason === "string" ? body.reason : null }, deps.config);
361
+ if (!proof.ok) {
362
+ sendError(res, proof.status, proof.errorCode, proof.error);
363
+ return;
364
+ }
365
+ deciderPrincipal = proof.principal;
366
+ }
367
+ const operators = deps.config.operatorPrincipals;
368
+ const explicitOperator = operators.length > 0 && deciderPrincipal !== undefined && operators.includes(deciderPrincipal);
369
+ if (!explicitOperator) {
370
+ const cpScope = await cs.peekPendingScope(sessionId);
371
+ const ownsIt = cpScope == null || cpScope === "_" || cpScope === deciderPrincipal;
372
+ if (!ownsIt) {
373
+ sendError(res, 404, "not_found.approval", "approval not found");
374
+ return;
375
+ }
376
+ }
377
+ let rememberToolName = null;
378
+ if (remember) {
379
+ const tok = await cs.findPendingTokenBySession(sessionId);
380
+ const cp0 = tok ? await cs.get(tok) : null;
381
+ const pa0 = cp0?.pendingAction;
382
+ const tn = pa0 && pa0.kind === "tool_approval" ? pa0.toolName : null;
383
+ rememberToolName = tn && tn !== "AskUserQuestion" ? tn : null;
384
+ }
385
+ let rememberApplied = false;
386
+ const grantOnCommit = remember && deps.approvalExemptionStore && rememberToolName
387
+ ? async (overrideSessionId) => {
388
+ const grantSessionId = overrideSessionId ?? sessionId;
389
+ try {
390
+ const canonical = canonicalToolName(rememberToolName);
391
+ await deps.approvalExemptionStore.grant(grantSessionId, canonical, deciderPrincipal ?? null);
392
+ rememberApplied = true;
393
+ deps.logger?.info?.("approval_exemption_granted", { sessionId: grantSessionId, toolName: canonical, grantedBy: deciderPrincipal ?? null });
394
+ }
395
+ catch (e) {
396
+ deps.logger?.warn?.("approval_exemption_grant_failed", { sessionId: grantSessionId, toolName: rememberToolName, error: String(e).slice(0, 200) });
397
+ }
398
+ }
399
+ : undefined;
400
+ const out = await resumeCheckpoint(sessionId, decision, body.reason ?? undefined, req, answer, binding, grantOnCommit);
401
+ if (remember && deps.approvalExemptionStore) {
402
+ sendJson(res, out.status, { ...out.body, rememberApplied });
403
+ return;
404
+ }
405
+ sendJson(res, out.status, out.body);
406
+ return;
407
+ }
408
+ sendError(res, 404, "not_found.route", "not found");
409
+ return;
410
+ }
411
+ if (deps.approvalStore && url.startsWith("/v1/approvals")) {
412
+ const store = deps.approvalStore;
413
+ if (deps.config.directApprovalDoor) {
414
+ sendError(res, 503, "approval_door_unconfigured", "approvals are served by the direct door on this worker");
415
+ return;
416
+ }
417
+ if (req.method === "GET" && url === "/v1/approvals") {
418
+ const principal = principalFrom(req, deps.config);
419
+ if (isOperator(principal, deps.config.operatorPrincipals)) {
420
+ const owner = new URL(req.url ?? "", "http://x").searchParams.get("owner") ?? undefined;
421
+ sendJson(res, 200, { pending: owner ? await store.listPending(owner, owner) : await store.listPendingAll() });
422
+ }
423
+ else {
424
+ sendJson(res, 200, { pending: principal ? await store.listPending(principal, principal) : [] });
425
+ }
426
+ return;
427
+ }
428
+ const m = /^\/v1\/approvals\/([^/]+)$/.exec(url);
429
+ if (m) {
430
+ const id = m[1];
431
+ if (req.method === "GET") {
432
+ const principal = principalFrom(req, deps.config);
433
+ if (deps.config.requirePrincipal && !principal) {
434
+ sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
435
+ return;
436
+ }
437
+ const operator = isOperator(principal, deps.config.operatorPrincipals);
438
+ const row = operator ? await store.getById(id) : principal != null ? await store.get(id, principal) : undefined;
439
+ if (!row || (!operator && row.owner !== null && row.owner !== principal)) {
440
+ sendError(res, 404, "not_found.approval", "approval not found");
441
+ return;
442
+ }
443
+ sendJson(res, 200, row);
444
+ return;
445
+ }
446
+ if (req.method === "POST") {
447
+ const principal = principalFrom(req, deps.config);
448
+ const operatorOk = deps.config.requirePrincipal
449
+ ? explicitOperatorOk(gatedPrincipal(req, deps.config), deps.config.operatorPrincipals)
450
+ : isOperator(principal, deps.config.operatorPrincipals);
451
+ if (!operatorOk) {
452
+ sendError(res, 403, "auth.operator_only", "only an operator may decide an approval (set OPERATOR_PRINCIPALS)");
453
+ return;
454
+ }
455
+ const body = (await readJson(req));
456
+ const decision = body?.decision === "approve" ? "approved" : body?.decision === "deny" ? "denied" : undefined;
457
+ if (!decision) {
458
+ sendError(res, 400, "request.body_shape", "body must be { decision: 'approve' | 'deny', reason? }");
459
+ return;
460
+ }
461
+ const target = await store.getById(id);
462
+ if (!target) {
463
+ sendError(res, 409, "conflict.approval_settled", "approval already decided or not found");
464
+ return;
465
+ }
466
+ const applied = await store.decide(id, target.scope, decision, body.reason ?? null, principal ?? null);
467
+ if (applied)
468
+ sendJson(res, 200, { id, status: decision });
469
+ else
470
+ sendError(res, 409, "conflict.approval_settled", "approval already decided or not found");
471
+ return;
472
+ }
473
+ }
474
+ sendError(res, 404, "not_found.route", "not found");
475
+ return;
476
+ }
477
+ miss.fell = true;
478
+ }
479
+ const APPROVALS_STREAM_POLL_MS = 3000;
480
+ export async function streamApprovals(req, res, cs, scope, pollMs = APPROVALS_STREAM_POLL_MS) {
481
+ const keyOf = (p) => JSON.stringify([p.sessionId, p.toolCallId ?? null]);
482
+ sseHeaders(res);
483
+ res.write(`event: meta\ndata: ${JSON.stringify({ type: "meta", version: 1, mode: "approvals-delta", pollMs: APPROVALS_STREAM_POLL_MS })}\n\n`);
484
+ let closed = false;
485
+ req.on("close", () => { closed = true; });
486
+ const start = Date.now();
487
+ const MAX_MS = 15 * 60 * 1000;
488
+ let lastBeat = Date.now();
489
+ let prev = new Map();
490
+ let first = true;
491
+ while (!closed) {
492
+ let pending;
493
+ try {
494
+ pending = await cs.listPending(scope);
495
+ }
496
+ catch {
497
+ if (!res.writableEnded)
498
+ res.write(`event: heartbeat\ndata: ${JSON.stringify({ type: "heartbeat" })}\n\n`);
499
+ await sleep(pollMs);
500
+ continue;
501
+ }
502
+ const cur = new Map(pending.map((p) => [keyOf(p), p]));
503
+ if (first) {
504
+ for (const p of pending)
505
+ res.write(`event: pending\ndata: ${JSON.stringify({ type: "pending", ...p })}\n\n`);
506
+ res.write(`event: synced\ndata: ${JSON.stringify({ type: "synced", count: pending.length })}\n\n`);
507
+ first = false;
508
+ }
509
+ else {
510
+ for (const [k, p] of cur)
511
+ if (!prev.has(k))
512
+ res.write(`event: pending\ndata: ${JSON.stringify({ type: "pending", ...p })}\n\n`);
513
+ for (const [k, p] of prev)
514
+ if (!cur.has(k))
515
+ res.write(`event: resolved\ndata: ${JSON.stringify({ type: "resolved", sessionId: p.sessionId, toolCallId: p.toolCallId })}\n\n`);
516
+ }
517
+ prev = cur;
518
+ if (Date.now() - start > MAX_MS) {
519
+ res.write(`event: error\ndata: ${JSON.stringify({ type: "error", code: "STREAM_MAX_DURATION", message: "approvals stream reached its 15-minute cap — reconnect to continue" })}\n\n`);
520
+ break;
521
+ }
522
+ if (Date.now() - lastBeat > 15_000) {
523
+ res.write(`event: heartbeat\ndata: ${JSON.stringify({ type: "heartbeat" })}\n\n`);
524
+ lastBeat = Date.now();
525
+ }
526
+ await sleep(pollMs);
527
+ }
528
+ res.end();
529
+ }
530
+ //# sourceMappingURL=approvals-assistant.js.map
@@ -18,12 +18,12 @@ async function handleAttachmentsBody(req, res, url, ctx, miss) {
18
18
  return;
19
19
  const store = deps.taskAttachmentStore;
20
20
  if (!store) {
21
- sendJson(res, 501, { error: "attachment store not configured" });
21
+ sendError(res, 501, "capability.attachment_store_required", "attachment store not configured");
22
22
  return;
23
23
  }
24
24
  const principal = gatedPrincipal(req, deps.config);
25
25
  if (deps.config.requirePrincipal && principal === undefined) {
26
- sendJson(res, 401, { error: `missing principal header '${deps.config.principalHeader}'` });
26
+ sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
27
27
  return;
28
28
  }
29
29
  const owner = principal ?? "default";
@@ -33,13 +33,13 @@ async function handleAttachmentsBody(req, res, url, ctx, miss) {
33
33
  const rawName = q.get("name");
34
34
  const base = ((rawName ?? "").split(/[/\\]/).filter(Boolean).pop() ?? "").slice(0, 128);
35
35
  if (!rawName || base === "" || base === "." || base === ".." || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(base)) {
36
- sendJson(res, 400, { error: "query param `name` is required and must reduce to a safe basename ([A-Za-z0-9._-], not starting with a dot)" });
36
+ sendError(res, 400, "request.query_invalid", "query param `name` is required and must reduce to a safe basename ([A-Za-z0-9._-], not starting with a dot)");
37
37
  return;
38
38
  }
39
39
  const name = sanitizePathComponent(base);
40
40
  const mime = String(req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
41
41
  if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(mime) || mime.length > 128) {
42
- sendJson(res, 400, { error: "content-type must be a well-formed mime type (type/subtype)" });
42
+ sendError(res, 400, "request.query_invalid", "content-type must be a well-formed mime type (type/subtype)");
43
43
  return;
44
44
  }
45
45
  const allow = deps.config.attachmentMimeAllowlist;
@@ -77,7 +77,7 @@ async function handleAttachmentsBody(req, res, url, ctx, miss) {
77
77
  const meta = await store.get(owner, attId);
78
78
  const bytes = meta ? await store.getContent(owner, attId) : null;
79
79
  if (!meta || !bytes) {
80
- sendJson(res, 404, { error: "not found" });
80
+ sendError(res, 404, "not_found.attachment", "not found");
81
81
  return;
82
82
  }
83
83
  res.writeHead(200, {
@@ -91,13 +91,13 @@ async function handleAttachmentsBody(req, res, url, ctx, miss) {
91
91
  if (attId !== undefined && attId !== null && req.method === "DELETE") {
92
92
  const ok = await store.delete(owner, attId);
93
93
  if (!ok) {
94
- sendJson(res, 404, { error: "not found" });
94
+ sendError(res, 404, "not_found.attachment", "not found");
95
95
  return;
96
96
  }
97
97
  res.writeHead(204).end();
98
98
  return;
99
99
  }
100
- sendJson(res, 404, { error: "not found" });
100
+ sendError(res, 404, "not_found.route", "not found");
101
101
  return;
102
102
  }
103
103
  }
@@ -0,0 +1,4 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { RouteCtx } from "../route-ctx.js";
3
+ export declare function handleFleet(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
4
+ //# sourceMappingURL=fleet.d.ts.map