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