@sema-agent/server 7.113.0 → 7.115.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 (87) hide show
  1. package/MIGRATION.md +109 -2
  2. package/USAGE.md +36 -3
  3. package/deploy/sema-up/cluster-up.sh +13 -1
  4. package/deploy/sema-up/kube-up.sh +13 -1
  5. package/deploy/sema-up/preflight.sh +13 -1
  6. package/deploy/sema-up/sema-up.sh +13 -1
  7. package/deploy/sema-up/smoke.sh +13 -1
  8. package/dist/adoption/wire.d.ts +0 -3
  9. package/dist/approval-card.d.ts +21 -24
  10. package/dist/approval-card.js +12 -0
  11. package/dist/approval.js +5 -2
  12. package/dist/boot/exit-boundary.d.ts +3 -2
  13. package/dist/boot/exit-boundary.js +3 -0
  14. package/dist/boot/memory-boundary.d.ts +2 -1
  15. package/dist/boot/memory-consolidation.d.ts +1 -1
  16. package/dist/boot/resolve-spec.js +6 -3
  17. package/dist/boot/shutdown.d.ts +19 -0
  18. package/dist/boot/shutdown.js +12 -1
  19. package/dist/capabilities/composite-members.d.ts +1 -0
  20. package/dist/capabilities/composite-members.js +1 -0
  21. package/dist/config-catalog.js +8 -6
  22. package/dist/config-center/apply-effective.js +11 -11
  23. package/dist/config-invariants.d.ts +27 -0
  24. package/dist/config-invariants.js +54 -39
  25. package/dist/config-provider.d.ts +16 -0
  26. package/dist/config-provider.js +11 -2
  27. package/dist/config-types.d.ts +24 -11
  28. package/dist/config-types.js +0 -1
  29. package/dist/config.d.ts +41 -34
  30. package/dist/config.js +78 -81
  31. package/dist/deployment-governance.d.ts +15 -4
  32. package/dist/deployment-governance.js +20 -2
  33. package/dist/env-facts.d.ts +3 -1
  34. package/dist/env-facts.js +5 -1
  35. package/dist/execution-lane-caps.d.ts +54 -30
  36. package/dist/execution-lane-caps.js +48 -20
  37. package/dist/fleet/fleet-bus.d.ts +1 -22
  38. package/dist/hooks/hook-runner.d.ts +112 -6
  39. package/dist/hooks/hook-runner.js +138 -32
  40. package/dist/hosted-posture.d.ts +2 -2
  41. package/dist/http/admission.js +9 -4
  42. package/dist/http/approval-sweeps.js +20 -0
  43. package/dist/http/checkpoint-ctx.d.ts +68 -0
  44. package/dist/http/checkpoint-ctx.js +36 -0
  45. package/dist/http/durable-run-leg.js +2 -1
  46. package/dist/http/resume-legs.js +9 -0
  47. package/dist/http/route-ctx.d.ts +7 -0
  48. package/dist/http/routes/a2a-serve.d.ts +1 -3
  49. package/dist/http/routes/a2a-serve.js +0 -2
  50. package/dist/http/routes/admin-memory-optout.d.ts +0 -2
  51. package/dist/http/routes/admin-memory-optout.js +0 -1
  52. package/dist/http/routes/capabilities.js +3 -2
  53. package/dist/http/routes/diagnostics.d.ts +5 -0
  54. package/dist/http/routes/diagnostics.js +2 -0
  55. package/dist/http/routes/sessions.js +1 -1
  56. package/dist/http/routes/tasks.js +3 -2
  57. package/dist/index.d.ts +1 -1
  58. package/dist/leader/wire.js +2 -1
  59. package/dist/local-data-root.d.ts +1 -1
  60. package/dist/mcp-content-origin.d.ts +0 -8
  61. package/dist/model-entry-refusal.d.ts +10 -2
  62. package/dist/model-entry-refusal.js +19 -3
  63. package/dist/observability/corrupt-read-seat.d.ts +3 -3
  64. package/dist/observability/corrupt-read-seat.js +5 -4
  65. package/dist/observability/fail-open.d.ts +10 -9
  66. package/dist/observability/fail-open.js +5 -1
  67. package/dist/permission-rule-list.d.ts +81 -0
  68. package/dist/permission-rule-list.js +17 -0
  69. package/dist/plugins/remote-env-host.d.ts +0 -35
  70. package/dist/plugins/remote-env-host.js +0 -8
  71. package/dist/plugins/remote-env-local-docker.d.ts +5 -13
  72. package/dist/plugins/remote-env-local-docker.js +2 -4
  73. package/dist/run-local.js +4 -1
  74. package/dist/runtime-governance.d.ts +21 -1
  75. package/dist/runtime-governance.js +6 -1
  76. package/dist/security.js +2 -2
  77. package/dist/session-policy-wire.d.ts +5 -4
  78. package/dist/store-posture.d.ts +7 -7
  79. package/dist/store-posture.js +0 -1
  80. package/dist/task-settings.d.ts +19 -5
  81. package/dist/task-settings.js +19 -30
  82. package/dist/tool-approval.d.ts +16 -4
  83. package/dist/tool-approval.js +2 -1
  84. package/dist/trace/core-keyset-guard.d.ts +9 -4
  85. package/dist/trace/project.js +9 -0
  86. package/package.json +4 -4
  87. package/scripts/{retired-mode-seed-drain-local.mjs → drain-check-local.mjs} +147 -26
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import vm from "node:vm";
2
3
  import { hostShell, resolveHostShell, adoptHostForegroundChild } from "../plugins/host-platform.js";
3
4
  import { HooksConfig, DEFAULT_HOOK_TIMEOUT_SECONDS, HOOK_EVENT_OWNER, HOOK_EVENT_MATCHER_FIELD, } from "@sema-agent/settings-schema/hooks";
4
5
  import { describeToolCatalog, isEngineRefusal, receiveHookResult } from "@sema-agent/core";
@@ -57,7 +58,9 @@ function toAssistantTurnError(errorKind) {
57
58
  return "unknown";
58
59
  return Object.prototype.hasOwnProperty.call(BRAIN_TO_TURN_ERROR, errorKind) ? (BRAIN_TO_TURN_ERROR[errorKind] ?? "unknown") : "unknown";
59
60
  }
60
- const NAME_ALTERNATION = /^[A-Za-z0-9_-]+(?:\|[A-Za-z0-9_-]+)*$/;
61
+ export const MAX_MATCHER_EVAL_CHARS = MAX_HOOK_COMMAND_CHARS;
62
+ export const ADMISSION_MATCHER_EVAL_BUDGET_MS = 100;
63
+ const MATCHER_UNJUDGED_TAG = "server.hooks.matcher-unjudged";
61
64
  function matcherReadsWireToolName(event) {
62
65
  return HOOK_EVENT_OWNER[event] === "engine" && HOOK_EVENT_MATCHER_FIELD[event] === "tool_name";
63
66
  }
@@ -77,25 +80,92 @@ function engineToolAliases() {
77
80
  engineToolAliasTable = table;
78
81
  return table;
79
82
  }
80
- function deadAliasInMatcher(event, matcher) {
81
- if (matcher === undefined || !matcherReadsWireToolName(event) || !NAME_ALTERNATION.test(matcher))
83
+ let matcherEvalBox;
84
+ function matcherPatternMatchesWithin(matcher, name, budget) {
85
+ const remaining = budget.deadlineMs - budget.now();
86
+ if (remaining <= 0)
87
+ return "over_budget";
88
+ matcherEvalBox ??= {
89
+ script: new vm.Script("matcherPatternMatches(matcher, name)"),
90
+ context: vm.createContext({ matcherPatternMatches, matcher: "", name: "" }),
91
+ };
92
+ matcherEvalBox.context.matcher = matcher;
93
+ matcherEvalBox.context.name = name;
94
+ try {
95
+ const verdict = matcherEvalBox.script.runInContext(matcherEvalBox.context, { timeout: Math.max(1, Math.ceil(remaining)) });
96
+ return verdict === true ? true : verdict === false ? false : "invalid";
97
+ }
98
+ catch (err) {
99
+ if (typeof err === "object" && err !== null && "code" in err && err.code === "ERR_SCRIPT_EXECUTION_TIMEOUT")
100
+ return "over_budget";
101
+ throw err;
102
+ }
103
+ }
104
+ function deadAliasInMatcher(matcher, depth, budget) {
105
+ if (matcher.length > MAX_MATCHER_EVAL_CHARS)
106
+ return { kind: "unjudged", reason: "oversize" };
107
+ if (matcher === "" || matcher === "*")
108
+ return undefined;
109
+ const names = matcherLiteralNames(matcher);
110
+ if (names !== undefined) {
111
+ for (const [alias, canonical] of engineToolAliases()) {
112
+ if (names.includes(alias) && !names.includes(canonical))
113
+ return { kind: "dead", alias, canonical };
114
+ }
82
115
  return undefined;
83
- const branches = matcher.split("|");
84
- const aliases = engineToolAliases();
85
- for (const branch of branches) {
86
- const canonical = aliases.get(branch);
87
- if (canonical !== undefined && !branches.includes(canonical))
88
- return { alias: branch, canonical };
116
+ }
117
+ switch (depth) {
118
+ case "literal":
119
+ return undefined;
120
+ case "regex":
121
+ break;
122
+ default: {
123
+ const unreachable = depth;
124
+ return unreachable;
125
+ }
126
+ }
127
+ const selects = (name) => {
128
+ const verdict = matcherPatternMatchesWithin(matcher, name, budget);
129
+ return verdict === "invalid" ? "invalid_regex" : verdict;
130
+ };
131
+ for (const [alias, canonical] of engineToolAliases()) {
132
+ const selectsAlias = selects(alias);
133
+ if (typeof selectsAlias === "string")
134
+ return { kind: "unjudged", reason: selectsAlias };
135
+ if (!selectsAlias)
136
+ continue;
137
+ const selectsCanonical = selects(canonical);
138
+ if (typeof selectsCanonical === "string")
139
+ return { kind: "unjudged", reason: selectsCanonical };
140
+ if (!selectsCanonical)
141
+ return { kind: "dead", alias, canonical };
89
142
  }
90
143
  return undefined;
91
144
  }
92
- export function deadToolMatcherIssue(config) {
145
+ export function buildDeadToolMatcherRefusal(issue) {
146
+ return {
147
+ message: `settings.hooks.${issue.event}: matcher "${issue.alias}" is an alias; tool-event matchers match the tool's wire name (PostToolUse / PostToolUseFailure since 7.107.0) — use "${issue.canonical}"`,
148
+ field: "settings.hooks",
149
+ detail: { event: issue.event, alias: issue.alias, canonical: issue.canonical },
150
+ };
151
+ }
152
+ export function deadToolMatcherIssue(config, depth, budget = {}) {
153
+ const now = budget.now ?? (() => performance.now());
154
+ const deadline = { deadlineMs: now() + (budget.budgetMs ?? ADMISSION_MATCHER_EVAL_BUDGET_MS), now };
93
155
  for (const [event, groups] of Object.entries(config)) {
156
+ if (!matcherReadsWireToolName(event))
157
+ continue;
94
158
  for (const group of groups ?? []) {
95
- const dead = deadAliasInMatcher(event, group.matcher);
96
- if (dead) {
97
- return `settings.hooks.${event}: matcher "${dead.alias}" is an alias; tool-event matchers match the tool's wire name (PostToolUse / PostToolUseFailure since 7.107.0) — use "${dead.canonical}"`;
159
+ if (group.matcher === undefined)
160
+ continue;
161
+ const verdict = deadAliasInMatcher(group.matcher, depth, deadline);
162
+ if (verdict === undefined)
163
+ continue;
164
+ if (verdict.kind === "unjudged") {
165
+ recordFailOpen(MATCHER_UNJUDGED_TAG, `event=${event} reason=${verdict.reason} matcherChars=${group.matcher.length}`);
166
+ continue;
98
167
  }
168
+ return { event, alias: verdict.alias, canonical: verdict.canonical };
99
169
  }
100
170
  }
101
171
  return undefined;
@@ -144,9 +214,31 @@ export function parseHooksConfig(raw) {
144
214
  }
145
215
  return { config };
146
216
  }
147
- export function hookMatcherMatches(matcher, value) {
148
- if (matcher === undefined || matcher === "" || matcher === "*")
149
- return true;
217
+ export function matcherLiteralNames(matcher) {
218
+ const names = [];
219
+ let first = -1;
220
+ let last = -1;
221
+ for (let i = 0; i <= matcher.length; i++) {
222
+ const c = i < matcher.length ? matcher.charCodeAt(i) : 0x7c;
223
+ if (c === 0x7c || c === 0x2c) {
224
+ if (first !== -1)
225
+ names.push(matcher.slice(first, last + 1));
226
+ first = -1;
227
+ last = -1;
228
+ continue;
229
+ }
230
+ if (c === 0x20)
231
+ continue;
232
+ const nameChar = (c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a) || c === 0x5f || c === 0x2d;
233
+ if (!nameChar)
234
+ return undefined;
235
+ if (first === -1)
236
+ first = i;
237
+ last = i;
238
+ }
239
+ return names;
240
+ }
241
+ function matcherPatternMatches(matcher, value) {
150
242
  if (matcher === value)
151
243
  return true;
152
244
  try {
@@ -156,6 +248,14 @@ export function hookMatcherMatches(matcher, value) {
156
248
  return "invalid";
157
249
  }
158
250
  }
251
+ export function hookMatcherMatches(matcher, value) {
252
+ if (matcher === undefined || matcher === "" || matcher === "*")
253
+ return true;
254
+ const names = matcherLiteralNames(matcher);
255
+ if (names !== undefined)
256
+ return names.includes(value);
257
+ return matcherPatternMatches(matcher, value);
258
+ }
159
259
  export function hookIfMatches(cond, payload) {
160
260
  if (cond.length > 500 || (cond.match(/\*/g)?.length ?? 0) > 16)
161
261
  return "unevaluable";
@@ -762,6 +862,7 @@ export function createTaskHooks(config, ctx) {
762
862
  let currentInput = input;
763
863
  let rewritten = false;
764
864
  let asked;
865
+ let allowedFor;
765
866
  let unknownDecision = false;
766
867
  const contexts = [];
767
868
  const deadlineAt = Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000;
@@ -775,11 +876,12 @@ export function createTaskHooks(config, ctx) {
775
876
  }
776
877
  if (!m)
777
878
  continue;
879
+ const groupInput = currentInput;
778
880
  const payload = {
779
881
  ...basePayload(ctx, tctx.permissionMode),
780
882
  hook_event_name: "PreToolUse",
781
883
  tool_name: toolName,
782
- tool_input: currentInput,
884
+ tool_input: groupInput,
783
885
  tool_use_id: tctx.toolCallId,
784
886
  };
785
887
  const singles = await runMatchingEntries("PreToolUse", [{ ...group, matcher: undefined }], toolName, payload, ctx, onceFired, deadlineAt, `PreToolUse:${gi}`);
@@ -820,10 +922,13 @@ export function createTaskHooks(config, ctx) {
820
922
  asked = clip(typeof h.permissionDecisionReason === "string" ? h.permissionDecisionReason : "a PreToolUse hook requires approval", MAX_HOOK_FEEDBACK_CHARS);
821
923
  }
822
924
  if (decision === "allow" || decision === undefined) {
823
- if (h.updatedInput !== null && typeof h.updatedInput === "object") {
925
+ const rewrites = h.updatedInput !== null && typeof h.updatedInput === "object";
926
+ if (rewrites) {
824
927
  currentInput = h.updatedInput;
825
928
  rewritten = true;
826
929
  }
930
+ if (decision === "allow")
931
+ allowedFor = { input: rewrites ? h.updatedInput : groupInput };
827
932
  }
828
933
  else if (decision !== "deny" && decision !== "ask") {
829
934
  unknownDecision = true;
@@ -853,13 +958,11 @@ export function createTaskHooks(config, ctx) {
853
958
  ...buildHookContextField(contexts),
854
959
  };
855
960
  }
856
- if (rewritten || contexts.length > 0) {
857
- return {
858
- action: "allow",
859
- ...(rewritten ? { updatedInput: currentInput } : {}),
860
- ...buildHookContextField(contexts),
861
- };
862
- }
961
+ const carried = { ...(rewritten ? { updatedInput: currentInput } : {}), ...buildHookContextField(contexts) };
962
+ if (allowedFor !== undefined && allowedFor.input === currentInput)
963
+ return { action: "allow", ...carried };
964
+ if (rewritten || contexts.length > 0)
965
+ return carried;
863
966
  return undefined;
864
967
  };
865
968
  hooks.preToolUse = preToolUseHook;
@@ -1231,6 +1334,7 @@ export function composeHooks(deployment, task) {
1231
1334
  let current = input;
1232
1335
  const contexts = [];
1233
1336
  let asked;
1337
+ let allowedFor;
1234
1338
  for (const slot of [d.preToolUse?.bind(d), t.preToolUse?.bind(t)]) {
1235
1339
  if (!slot)
1236
1340
  continue;
@@ -1250,6 +1354,10 @@ export function composeHooks(deployment, task) {
1250
1354
  asked = r;
1251
1355
  break;
1252
1356
  case "allow":
1357
+ if (updatedInput !== undefined)
1358
+ current = updatedInput;
1359
+ allowedFor = { input: current };
1360
+ break;
1253
1361
  case undefined:
1254
1362
  if (updatedInput !== undefined)
1255
1363
  current = updatedInput;
@@ -1269,13 +1377,11 @@ export function composeHooks(deployment, task) {
1269
1377
  ...buildHookContextField(contexts),
1270
1378
  };
1271
1379
  }
1272
- if (current !== input || contexts.length > 0) {
1273
- return {
1274
- action: "allow",
1275
- ...(current !== input ? { updatedInput: current } : {}),
1276
- ...buildHookContextField(contexts),
1277
- };
1278
- }
1380
+ const carried = { ...(current !== input ? { updatedInput: current } : {}), ...buildHookContextField(contexts) };
1381
+ if (allowedFor !== undefined && allowedFor.input === current)
1382
+ return { action: "allow", ...carried };
1383
+ if (current !== input || contexts.length > 0)
1384
+ return carried;
1279
1385
  return undefined;
1280
1386
  };
1281
1387
  }
@@ -13,8 +13,8 @@
13
13
  * 比手抄的值更难发现烂掉,[ref] 的第一排查点就是它;
14
14
  * · 能力授予面的赦免挂在 **`requirePrincipal !== true`** 上 —— 但**三条面各有各的谓词,不是一只**
15
15
  * (异源复核实测,别照「它们都一样」写):
16
- * · `settings.hooks` —— `boot/resolve-spec.ts` 的 hooks 臂,判据**只有** `config.requirePrincipal === true`
17
- * ⇒ 忽略(**不看车道**,任何 `REMOTE_EXEC` 都一样);
16
+ * · `settings.hooks` —— `task-settings.ts` 的 `taskHooksHonored`(唯一属主;resolve-spec 装配 / 受理面 / 能力位三处同调),判据**只有**
17
+ * `requirePrincipal !== true` ⇒ 采纳,否则忽略(**不看车道**,任何 `REMOTE_EXEC` 都一样);
18
18
  * · `additionalDirectories` / `additionalReadDirectories` —— **只有** `cwdHonored`
19
19
  * (= `provider === "host" ∧ requirePrincipal !== true`);
20
20
  * · `settings.env` / `cwd` —— `cwdHonored ‖ deviceCwdHonored`,而
@@ -2,12 +2,12 @@ import { isThinkingLevel, expandTiers } from "@sema-agent/core";
2
2
  import { isModelAllowlisted, matchCatalogModel, refusedCatalogEntry, selectableCatalog } from "../model-select.js";
3
3
  import { modelUnavailableMessage } from "../model-entry-refusal.js";
4
4
  import { HttpError } from "../security.js";
5
- import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS, SETTINGS_PERMISSION_MODE_WORDS, isSettingsPermissionMode, parseTaskSettings, settingsPermissionBucketIssue, skillListingBudgetFractionOk, skillListingMaxDescOk, taskSettingsKeyIssue } from "../task-settings.js";
5
+ import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS, SETTINGS_PERMISSION_MODE_WORDS, isSettingsPermissionMode, parseTaskSettings, settingsPermissionBucketIssue, skillListingBudgetFractionOk, skillListingMaxDescOk, taskHooksHonored, taskSettingsKeyIssue } from "../task-settings.js";
6
6
  import { taskBodyKeyIssue } from "../request-key-closure.js";
7
7
  import { FILE_HISTORY_DISABLED_NO_RESTORE, fileHistoryRestoreConflict, inheritEnvFieldIssue, singleMemberOffFieldIssue } from "../request-value-closure.js";
8
8
  import { selfOrchestrationDenial } from "../task-workflow.js";
9
9
  import { centerEntitlementSourceWired } from "../runtime-caps-resolver.js";
10
- import { deadToolMatcherIssue, parseHooksConfig } from "../hooks/hook-runner.js";
10
+ import { buildDeadToolMatcherRefusal, deadToolMatcherIssue, parseHooksConfig } from "../hooks/hook-runner.js";
11
11
  import { judgeRequestWebSearch } from "../plugins/web-search.js";
12
12
  import { normalizeApproachNotice, unknownTaskAgentModelRef, validateTaskAgents } from "../spec-fields.js";
13
13
  import { isValidCwd, MAX_ADDITIONAL_DIRS, MAX_CWD_CHARS } from "../task-cwd.js";
@@ -200,9 +200,10 @@ export function createAdmission(ctx) {
200
200
  sendError(res, 400, "request.field_invalid", hooksParsed.error ?? "settings.hooks: invalid shape");
201
201
  return null;
202
202
  }
203
- const deadMatcher = deadToolMatcherIssue(hooksParsed.config);
203
+ const deadMatcher = deadToolMatcherIssue(hooksParsed.config, taskHooksHonored(deps.config) ? "regex" : "literal");
204
204
  if (deadMatcher !== undefined) {
205
- sendError(res, 400, "request.field_invalid", deadMatcher);
205
+ const refusal = buildDeadToolMatcherRefusal(deadMatcher);
206
+ sendError(res, 400, "request.field_invalid", refusal.message, { field: refusal.field, detail: refusal.detail });
206
207
  return null;
207
208
  }
208
209
  }
@@ -420,6 +421,10 @@ export function createAdmission(ctx) {
420
421
  sendError(res, 400, "request.field_invalid", "limits.budgetStreamCancel is not caller-settable here — the engine default applies (armed whenever a cost budget is present); remove it");
421
422
  return null;
422
423
  }
424
+ if (l.brainCallGuardrailMs !== undefined) {
425
+ sendError(res, 400, "request.field_invalid", "limits.brainCallGuardrailMs is not caller-settable here — the engine's default wall on a single brain call applies (one hour); remove it");
426
+ return null;
427
+ }
423
428
  for (const k of ["maxWalltimeMs", "maxOutputTokens", "maxTurns"]) {
424
429
  const v = l[k];
425
430
  if (v !== undefined && (typeof v !== "number" || !Number.isInteger(v) || v < 1)) {
@@ -7,6 +7,8 @@ import { createWallClockJumpGuard } from "../wall-clock-jump-guard.js";
7
7
  import { hasServiceAuth } from "./dispatch.js";
8
8
  import { gatedPrincipal } from "./principal-gate.js";
9
9
  import { readinessOf, billableRefusalOf } from "./readiness.js";
10
+ import { readResumeCtx } from "./resume-ctx-read.js";
11
+ import { allowListReadingUnstated } from "./checkpoint-ctx.js";
10
12
  const BLOCKED_SCALAR_KEYS = ["errorCode", "blockedBy", "field", "reason", "count", "cap", "index"];
11
13
  function blockedScalars(body) {
12
14
  const out = {};
@@ -64,17 +66,35 @@ export function createApprovalSweeps(ctx) {
64
66
  return false;
65
67
  }
66
68
  };
69
+ const allowListUnservable = async (sessionId) => {
70
+ const read = await readResumeCtx(cs.getCtx(sessionId));
71
+ if (read.kind === "corrupt")
72
+ return false;
73
+ return read.kind === "ok" && allowListReadingUnstated(read.ctx);
74
+ };
67
75
  let parkedSkipped = 0;
76
+ const allowListSkipped = [];
68
77
  for (const { sessionId } of expired) {
69
78
  if (await isParkedOwned(sessionId)) {
70
79
  parkedSkipped += 1;
71
80
  continue;
72
81
  }
82
+ if (await allowListUnservable(sessionId)) {
83
+ allowListSkipped.push(sessionId);
84
+ continue;
85
+ }
73
86
  await resumeCheckpoint(sessionId, "deny", "approval SLA expired — auto-denied", { decidedBy: "sla_timeout" }).then((r) => { if (r.status >= 400)
74
87
  deps.logger?.warn?.("deny_sweep_resume_blocked", { sessionId, status: r.status, ...blockedScalars(r.body) }); }, (e) => deps.logger?.warn?.("deny_sweep_resume_failed", { sessionId, err: redactHead(e instanceof Error ? e.message : String(e), 2000) }));
75
88
  }
76
89
  if (parkedSkipped > 0)
77
90
  deps.logger?.info?.("deny_sweep_parked_skipped", { count: parkedSkipped });
91
+ if (allowListSkipped.length > 0) {
92
+ deps.logger?.warn?.("deny_sweep_allow_list_reading_skipped", {
93
+ count: allowListSkipped.length,
94
+ sessionIds: allowListSkipped,
95
+ note: "these expired approvals belong to tasks suspended by a server version that read settings.permissions.allow as a whitelist; this version does not replay them, so the SLA deny cannot be delivered. They stay suspended (session lock held) until the reaper collects them at their terminal time — cancel them (POST /v1/runs/:id/cancel) to release the sessions now",
96
+ });
97
+ }
78
98
  }
79
99
  async function redeemParkedAsk(req) {
80
100
  const cs = deps.checkpointStore;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 续跑重建输入(`checkpoint_ctx` 的行体,{@link CheckpointCtx})的**写形**,以及持久化体里 `settings.permissions.allow` 的**读法记号** —— S-1034(7.115.0)。
3
+ *
4
+ * ## 病
5
+ * 7.115.0 把 `settings.permissions.allow` 从「名单」(名单外的工具一律拒)改成「allow 规则」(命中 = 带理由的放行,未命中 = 不表态、照常判)。
6
+ * 续跑家族重建 spec 时把**持久化的提交体**原样重放进 `resolveSpec`(`resume-legs.ts` 的 `resolveResumeSpec`)——
7
+ * 一条在 ≤7.114.0 上停驻、体里带非空 `allow` 的行,升级后续跑就被按新读法读:名单外的工具不再被拒。持久面上的静默语义翻转(放宽方向)。
8
+ * 坏形 `allow`(`Task` / `mcp__figma` / `Bash(npm:*)` …)的旧行则每次续跑都 409、SLA 拒扫腿逐拍重试直到收割 —— 一张本来放不开任何门的停驻被砖化。
9
+ *
10
+ * ## 规则(一条;持久数据的「响亮拒 + 一次性迁移」,零双读)
11
+ * 续跑重放持久化体之前问一句:**体里的 `allow` 在生效(非空数组)而本行没有「按表态读」的记号 ⇒ 拒**。
12
+ * 行留停驻、字节不动(拒发生在认领之前,run 行、卡、ctx 一个字节都不写);出路 = 作为新任务重提,这条任务若还停驻着先取消(`task_done` 唤醒停驻与 workflow 宿主那条腿没有可取消的 run)。
13
+ * **没有**「按旧名单语义继续读」的臂:那是第二种读法,正是本规则要消灭的东西。
14
+ * · 记号 = ≥7.115.0 写下的行在体里带非空 `allow` 时,ctx 上的 `permissionsAllowReading: "statement"`(闭集一词)。
15
+ * 写点 = 每一次**新的**持久化写({@link buildCheckpointCtx} 是它们的唯一构造口);续跑腿的写回传的是读进来的那只 ctx(或其展开),记号随行,不重新判。
16
+ * · 体里没有 `allow`(或空数组 —— 7.114.0 的名单判据 `acceptedRuleList` 对空数组同样读成「没有名单」)的行不写记号、不受本规则影响。
17
+ *
18
+ * ## 判据与 7.114.0 的关系(如实)
19
+ * 7.114.0 只在三桶整体合法时才读名单;判据这里只看「`allow` 是非空数组」,比 7.114.0 的「名单生效」宽一格(坏桶的行 7.114.0 也不读名单)——
20
+ * 方向 = 多拒,不会漏拒;而坏桶的体当年就被 fresh 受理门拒在门外,盘上本不该有。
21
+ *
22
+ * ## 何时删(一次性迁移,不是长期双读)
23
+ * 本判据、拒臂、记号写点与 `CheckpointCtx.permissionsAllowReading` 一起删,删除条件 = 所有 ≤7.114.0 停驻的行都已结束或被收割
24
+ * (停驻行的终局时刻 = 落盘起 `TERMINAL_BACKSTOP_MS`,见 `plugins/checkpoint-store-sql.ts`)之后的那个版本。删的时候记号的读者一起删:
25
+ * 届时盘上不会再有没记号却带 `allow` 的行,而有记号的行读不读它都一样。
26
+ */
27
+ import type { CheckpointCtx } from "./route-ctx.js";
28
+ import type { TaskRequestBody } from "./wire-types.js";
29
+ /** 读法记号的闭集(一词):这一行体里的 `settings.permissions.allow` 是按「allow 规则(表态)」写下、也该按它读的。 */
30
+ export declare const PERMISSIONS_ALLOW_READINGS: readonly ["statement"];
31
+ export type PermissionsAllowReading = (typeof PERMISSIONS_ALLOW_READINGS)[number];
32
+ /**
33
+ * 体里的 `settings.permissions.allow` 是否**在生效**:非空数组(与 7.114.0 名单判据 `acceptedRuleList` 同一个「在」——空数组 = 没有)。
34
+ * 入参是持久化的 JSON(可能来自任何旧版本),逐层判形,不信型面。
35
+ */
36
+ export declare function bodyCarriesAllowList(body: TaskRequestBody): boolean;
37
+ /**
38
+ * 一次**新的**持久化写的 ctx(提交腿 / `POST /v1/runs` 族 / leader worker)—— 写记号的唯一口。
39
+ *
40
+ * 调用方是「本进程刚按表态读过这份体」的写点:fresh 提交(受理门按 7.115.0 读过 `allow`),或已经过了续跑拒臂的重放腿
41
+ * (`startFreshHostTurn` 把宿主的存量体交给 `POST /v1/runs` 同一条链 —— 它在那之前调过 `resolveResumeSpecOrBlocked`,没记号却带 `allow` 的体到不了这里)。
42
+ * 续跑腿的**写回**不经本函数:它传读进来的 ctx(或其展开),记号随行;在写回口重新判会把一条没记号的旧行「洗」成新行。
43
+ */
44
+ export declare function buildCheckpointCtx(fields: {
45
+ body: TaskRequestBody;
46
+ memoryScope?: string | undefined;
47
+ unattendedLeg?: true;
48
+ }): CheckpointCtx;
49
+ /**
50
+ * 本行能否被按 7.115.0 的读法重放:体里 `allow` 在生效而行上没有记号 ⇒ **不能**(`true` = 拒)。
51
+ * 记号的值不在闭集里(手改 / 将来的写者)⇒ 同样拒:认不得的记号不是证据。
52
+ */
53
+ export declare function allowListReadingUnstated(ctx: CheckpointCtx): boolean;
54
+ /**
55
+ * 拒的答复 —— 码与落点照「退役 mode-seed 行」的先例(`checkpoint.unsupported_version`,409,`retriable:true` = 那张卡仍 pending;**不是**「原样重试会成功」)。
56
+ * 拒在认领之前,所以没有 `taskId`(行没抢到就不造键)。句子说原因(这条任务停在 `allow` 还是名单的版本上)与出路(作为新任务重提,仍停驻的先取消;要「只许这些工具」用会话规则 `toolAllow` / 每请求 `excludeTools`)。
57
+ *
58
+ * 🔴 形是给冻结门看的(`api-error-text-freeze` 的 resume 家族收集器):`status: <三位数>, body: { error: "<整句双引号字面>", errorCode: "<码>"`,中间只许有空白。
59
+ */
60
+ export declare function allowListReadingRefusal(): {
61
+ status: number;
62
+ body: {
63
+ error: string;
64
+ errorCode: string;
65
+ retriable: true;
66
+ };
67
+ };
68
+ //# sourceMappingURL=checkpoint-ctx.d.ts.map
@@ -0,0 +1,36 @@
1
+ export const PERMISSIONS_ALLOW_READINGS = ["statement"];
2
+ export function bodyCarriesAllowList(body) {
3
+ const settings = body?.settings;
4
+ if (settings === null || typeof settings !== "object" || Array.isArray(settings))
5
+ return false;
6
+ const permissions = settings["permissions"];
7
+ if (permissions === null || typeof permissions !== "object" || Array.isArray(permissions))
8
+ return false;
9
+ const allow = permissions["allow"];
10
+ return Array.isArray(allow) && allow.length > 0;
11
+ }
12
+ export function buildCheckpointCtx(fields) {
13
+ return {
14
+ body: fields.body,
15
+ ...(fields.memoryScope !== undefined ? { memoryScope: fields.memoryScope } : {}),
16
+ ...(fields.unattendedLeg === true ? { unattendedLeg: true } : {}),
17
+ ...(bodyCarriesAllowList(fields.body) ? { permissionsAllowReading: "statement" } : {}),
18
+ };
19
+ }
20
+ export function allowListReadingUnstated(ctx) {
21
+ if (!bodyCarriesAllowList(ctx.body))
22
+ return false;
23
+ const reading = ctx.permissionsAllowReading;
24
+ return !(typeof reading === "string" && PERMISSIONS_ALLOW_READINGS.includes(reading));
25
+ }
26
+ export function allowListReadingRefusal() {
27
+ return {
28
+ status: 409,
29
+ body: {
30
+ error: "this task was suspended by an earlier server version, which read settings.permissions.allow as a whitelist (every tool not on the list was refused); this version reads that list as a statement that the named tools are allowed, not as a whitelist, so continuing would give the task more tools than it was submitted with. Nothing was decided and nothing was resumed, and retrying will be refused the same way: submit the work again as a new task, and if this task is still suspended cancel it first (POST /v1/runs/:id/cancel). To allow only certain tools, use the session rules' toolAllow (PUT /v1/sessions/:id/policy) or the request's excludeTools.",
31
+ errorCode: "checkpoint.unsupported_version",
32
+ retriable: true,
33
+ },
34
+ };
35
+ }
36
+ //# sourceMappingURL=checkpoint-ctx.js.map
@@ -8,6 +8,7 @@ import { commitPlacement, devicePlacementErrorBody } from "./device-placement.js
8
8
  import { commitSessionInheritEnv } from "./session-inherit-env.js";
9
9
  import { clearTurnActivity, readTurnActivityMs } from "../turn-activity.js";
10
10
  import { recordResourceWindow } from "../resource-window.js";
11
+ import { buildCheckpointCtx } from "./checkpoint-ctx.js";
11
12
  export async function mintAndDriveDurableRun(ctx, runStore, args) {
12
13
  const { deps } = ctx;
13
14
  const { inflightRuns, preemptableRuns, steerableRuns } = ctx.registry;
@@ -31,7 +32,7 @@ export async function mintAndDriveDurableRun(ctx, runStore, args) {
31
32
  return { status: placementReject.status, body: devicePlacementErrorBody(placementReject) };
32
33
  commitSessionInheritEnv(deps, args.prepared.spec.sessionId, args.prepared.body);
33
34
  if (deps.checkpointStore) {
34
- await deps.checkpointStore.putCtx(sessionId, { body: args.prepared.body, memoryScope: args.prepared.auth?.memoryScope });
35
+ await deps.checkpointStore.putCtx(sessionId, buildCheckpointCtx({ body: args.prepared.body, memoryScope: args.prepared.auth?.memoryScope }));
35
36
  }
36
37
  const anchorOwner = args.prepared.auth?.principal ?? null;
37
38
  const getLeafId = deps.sessionStorage?.getLeafId?.bind(deps.sessionStorage);
@@ -42,6 +42,7 @@ import { commitPlacement, devicePlacementErrorBody } from "./device-placement.js
42
42
  import { commitSessionInheritEnv } from "./session-inherit-env.js";
43
43
  import { ASK_USER_QUESTION_TOOL_NAME } from "../approval-content-kind.js";
44
44
  import { readResumeCtx, resumeCtxCorruptAnswer } from "./resume-ctx-read.js";
45
+ import { allowListReadingRefusal, allowListReadingUnstated } from "./checkpoint-ctx.js";
45
46
  import { httpErrorCode } from "./send.js";
46
47
  function isCheckpointReopenedFailure(r) {
47
48
  const code = failureCodeOf(r.terminal);
@@ -398,7 +399,13 @@ export function createResumeLegs(ctx) {
398
399
  });
399
400
  return { spec, engineNotices };
400
401
  };
402
+ const refuseAllowListReading = (sessionId) => {
403
+ deps.logger?.warn?.("resume_refused_allow_list_reading", { sessionId, note: "this session's stored request carries settings.permissions.allow written by a server that read it as a whitelist; it is not replayed under the current reading — the task stays suspended until someone cancels it" });
404
+ return allowListReadingRefusal();
405
+ };
401
406
  const resolveResumeSpecOrBlocked = async (ctx, req, auth) => {
407
+ if (allowListReadingUnstated(ctx))
408
+ return { ok: false, answer: refuseAllowListReading(auth.sessionId) };
402
409
  try {
403
410
  const { spec, engineNotices } = await resolveResumeSpec(ctx, req, auth);
404
411
  return { ok: true, spec, engineNotices };
@@ -1332,6 +1339,8 @@ export function createResumeLegs(ctx) {
1332
1339
  return refuse(resumeCtxCorruptAnswer(ctxRead.where));
1333
1340
  if (ctxRead.kind === "absent")
1334
1341
  return refuse({ status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } });
1342
+ if (allowListReadingUnstated(ctxRead.ctx))
1343
+ return refuse(refuseAllowListReading(sessionId));
1335
1344
  return { ok: true, prepared: { sessionId, token, cp, ctx: ctxRead.ctx } };
1336
1345
  }
1337
1346
  async function drivePlanReview(prepared, decision, editedPlan, reason, req, acceptEarly, permissionModeAfter) {
@@ -161,6 +161,13 @@ export interface CheckpointCtx {
161
161
  * 传原 ctx / 其展开 ⇒ 标随行。缺席 = 不是无人值守腿(HTTP 提交腿;7.106.0 前落盘的行 —— 那时这些腿不铸模式,checkpoint 里也没有)。
162
162
  */
163
163
  unattendedLeg?: true;
164
+ /**
165
+ * 🔴 S-1034(7.115.0)—— 体里 `settings.permissions.allow` 的**读法记号**:在场 = 这一行是 ≥7.115.0 写下的、体里带非空 `allow`,
166
+ * 它按「allow 规则(表态)」写、也按它读。**服务端写、不来自请求体**(与 {@link unattendedLeg} 同为 `body` 的兄弟键)。
167
+ * 缺席而体里带非空 `allow` = ≤7.114.0 停驻的行(那时 `allow` 是名单)⇒ 续跑腿响亮拒、行字节不动(规则与删除条件的属主 = `http/checkpoint-ctx.ts` 顶注)。
168
+ * 写点只有 `buildCheckpointCtx`(同模块;新的持久化写);续跑腿的写回传原 ctx / 其展开 ⇒ 记号随行。体里没有 `allow` 的行不写它。
169
+ */
170
+ permissionsAllowReading?: import("./checkpoint-ctx.js").PermissionsAllowReading;
164
171
  }
165
172
  /** B4①(命名化):`driveResumeIntoRunLog` 的入参形(resume 家族共用)——原为 9 成员内联匿名对象。 */
166
173
  export interface DriveResumeArgs {
@@ -1,5 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import { type A2ATaskState } from "@sema-agent/core";
2
+ import type { A2ATaskState } from "@sema-agent/core";
3
3
  import type { A2aServeConfig } from "../../config-types.js";
4
4
  import { type RunRecord } from "../../plugins/store-contracts.js";
5
5
  import type { RouteCtx, RouteMatch, RouteIdsOf } from "../route-ctx.js";
@@ -69,8 +69,6 @@ export declare function a2aStateForRun(run: Pick<RunRecord, "status" | "errorCod
69
69
  * `capabilities.a2aServe` 的子串,好让 `route-shape-roster` 层E 如实认出它们。
70
70
  */
71
71
  export declare function handleA2a(req: IncomingMessage, res: ServerResponse, match: RouteMatch<A2aRpcRouteId>, ctx: RouteCtx): Promise<void>;
72
- /** 九词闭集的对外可见锚(测试用它证明我们只发闭集词;也让「加一个自造词」在这里就被看见)。 */
73
- export declare const A2A_SERVE_TASK_STATES: readonly A2ATaskState[];
74
72
  export declare const A2A_SERVE_ROUTES: readonly [{
75
73
  readonly id: "a2a-agent-card";
76
74
  readonly path: "/.well-known/agent-card.json";
@@ -1,7 +1,6 @@
1
1
  import { persistedPlaneOf } from "../../terminal.js";
2
2
  import { createHash } from "node:crypto";
3
3
  import { memoRedactedRead, redactErrorMessage } from "../../observability/run-terminal.js";
4
- import { A2A_TASK_STATES } from "@sema-agent/core";
5
4
  import { isParkedRunStatus, isTerminalRunStatus } from "../../plugins/store-contracts.js";
6
5
  import { HttpError } from "../../security.js";
7
6
  import { scopedIdempotencyKey } from "../idempotency.js";
@@ -396,7 +395,6 @@ export async function handleA2a(req, res, match, ctx) {
396
395
  function sendRpc(res, body) {
397
396
  sendJson(res, 200, body);
398
397
  }
399
- export const A2A_SERVE_TASK_STATES = A2A_TASK_STATES;
400
398
  export const A2A_SERVE_ROUTES = [
401
399
  { id: "a2a-agent-card", path: "/.well-known/agent-card.json", methods: ["GET"] },
402
400
  ];
@@ -42,8 +42,6 @@
42
42
  import type { IncomingMessage, ServerResponse } from "node:http";
43
43
  import type { RouteCtx, RouteMatch, RouteIdsOf } from "../route-ctx.js";
44
44
  export declare const ADMIN_MEMORY_OPTOUT_PATH = "/v1/admin/memory-optout";
45
- /** 带参口(`:principal`)⇒ 路由声明表(`http/route-table.ts`)里走 `pattern` 行,不是 `path` 行。 */
46
- export declare const ADMIN_MEMORY_OPTOUT_OVERRIDE_RE: RegExp;
47
45
  export declare function handleMemoryOptOut(req: IncomingMessage, res: ServerResponse, match: RouteMatch<MemoryOptOutRouteId>, ctx: RouteCtx): Promise<void>;
48
46
  export declare const MEMORY_OPTOUT_ROUTES: readonly [{
49
47
  readonly id: "memory-optout-grants-list";
@@ -4,7 +4,6 @@ import { gatedPrincipal, explicitOperatorOk, sendPrincipalRefusal } from "../pri
4
4
  import { assertNever } from "../route-ctx.js";
5
5
  import { memoryOptOutPrincipalShapeProblem } from "../../plugins/memory-optout-grant-store-sql.js";
6
6
  export const ADMIN_MEMORY_OPTOUT_PATH = "/v1/admin/memory-optout";
7
- export const ADMIN_MEMORY_OPTOUT_OVERRIDE_RE = /^\/v1\/admin\/memory-optout\/([^/]+)$/;
8
7
  const PutBodySchema = z.object({ allow: z.boolean(), note: z.string().min(1).max(500).optional() }).strict();
9
8
  export async function handleMemoryOptOut(req, res, match, ctx) {
10
9
  const { deps } = ctx;
@@ -24,7 +24,7 @@ import { projectApprovalsStreamLiveCapability } from "../../capabilities/approva
24
24
  import { workflowModelAllowlistFor, selfOrchestrationDenial } from "../../task-workflow.js";
25
25
  import { centerEntitlementSourceWired } from "../../runtime-caps-resolver.js";
26
26
  import { judgeAutoModeArming } from "../../auto-mode-face.js";
27
- import { coercePermissionModeStrict, SETTINGS_PERMISSION_MODE_WORDS } from "../../task-settings.js";
27
+ import { coercePermissionModeStrict, SETTINGS_PERMISSION_MODE_WORDS, taskHooksHonored } from "../../task-settings.js";
28
28
  import { gatedPrincipal, sendPrincipalRefusal } from "../principal-gate.js";
29
29
  import { sessionBackgroundFacesAvailable } from "./background.js";
30
30
  function routePairingOf(deps, name, m) {
@@ -121,7 +121,8 @@ export async function handleCapabilities(req, res, match, ctx) {
121
121
  scratchpadDir: true,
122
122
  subagentSteer: Boolean(deps.subagentSteerRegistry) && Boolean(deps.runStore),
123
123
  subagentResume: Boolean(deps.subagentSteerRegistry) && Boolean(deps.runStore),
124
- taskSettings: { permissions: true, permissionMode: true, model: true, outputStyle: true, env: cwdHonored(deps.config) || deviceCwdHonored(deps.config), hooks: deps.config.requirePrincipal !== true },
124
+ taskSettings: { permissions: true, permissionMode: true, model: true, outputStyle: true, env: cwdHonored(deps.config) || deviceCwdHonored(deps.config), hooks: taskHooksHonored(deps.config) },
125
+ settings: { permissions: { allowGrant: true } },
125
126
  permissionModeAuto: {
126
127
  accepted: true,
127
128
  classifierSeat: deps.autoModeFace?.seatMounted === true,
@@ -32,6 +32,9 @@ export interface ServerWiringGates {
32
32
  streamAskWindowMs: PostureKnobReading<number>;
33
33
  sessionAutoTitle: PostureKnobReading<boolean>;
34
34
  checkpointStore: boolean;
35
+ /** 7.115.0:运维的 allow 规则(`APPROVAL_GRANT`)原样 —— 空表 = 旋钮未设。规则名是工具名不是秘密,所以本面给全名单(条数 = 长度);
36
+ * 本位**只**在运维面(operator-only,门在路由上),不进 `/v1/capabilities` 的客户端能力位:它说的是部署替谁表了态,不是调用方能用什么。 */
37
+ approvalGrant: readonly string[];
35
38
  }
36
39
  /** `buildServerWiringGates` 的入参 = 三个谓词各自的**真输入**(不接受已算好的布尔:那样这里读到的就
37
40
  * 不再是装配现场的事实,而是别处算完可能已漂的复述)。 */
@@ -49,6 +52,8 @@ export interface ServerWiringGateInput {
49
52
  streamAskWindowMsSource: PostureSource;
50
53
  sessionAutoTitle: boolean;
51
54
  sessionAutoTitleSource: PostureSource;
55
+ /** 7.115.0:`config.approvalGrant` 本身(读面不重算)。 */
56
+ approvalGrant: readonly string[];
52
57
  }
53
58
  /** 纯数据产物(`build*`):三个门的当前读数。`streamApproval` 走**单一谓词**
54
59
  * `resolveStreamApprovalGate` —— 与 `/v1/capabilities` 的 `streamApproval` 位、与回决口的 501 门同一个