@sema-agent/server 7.100.0 → 7.101.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 (102) hide show
  1. package/MIGRATION.md +22 -1
  2. package/README.md +12 -1
  3. package/README.zh-CN.md +9 -1
  4. package/USAGE.md +80 -9
  5. package/dist/approval-card.d.ts +24 -12
  6. package/dist/approval-card.js +4 -3
  7. package/dist/approval-policy-settlement.d.ts +31 -0
  8. package/dist/approval-policy-settlement.js +16 -0
  9. package/dist/approval-reconciler.d.ts +8 -1
  10. package/dist/approval-reconciler.js +5 -4
  11. package/dist/boot/config-center.js +86 -20
  12. package/dist/boot/execution-env.js +20 -2
  13. package/dist/boot/parked-revive-gate.js +1 -0
  14. package/dist/boot/reapers.js +1 -0
  15. package/dist/boot/resolve-spec.js +25 -8
  16. package/dist/boot/runner-deps.js +9 -3
  17. package/dist/boot/stage-02-registry-pull.js +9 -0
  18. package/dist/boot/stores.js +4 -0
  19. package/dist/config-catalog.js +5 -3
  20. package/dist/config-center/apply-effective.d.ts +25 -0
  21. package/dist/config-center/apply-effective.js +144 -69
  22. package/dist/config-center/apply-ledger.d.ts +12 -2
  23. package/dist/config-center/apply-ledger.js +4 -1
  24. package/dist/config-center/facade.d.ts +1 -1
  25. package/dist/config-center/facade.js +1 -1
  26. package/dist/config-center/http-client.d.ts +4 -1
  27. package/dist/config-center/http-client.js +15 -10
  28. package/dist/config-center/read-warnings.d.ts +115 -6
  29. package/dist/config-center/read-warnings.js +115 -5
  30. package/dist/config-center/types.d.ts +6 -0
  31. package/dist/config-invariants.d.ts +2 -2
  32. package/dist/config-invariants.js +15 -0
  33. package/dist/config-provider.d.ts +4 -0
  34. package/dist/config-provider.js +64 -9
  35. package/dist/config-types.d.ts +37 -8
  36. package/dist/config.js +87 -22
  37. package/dist/deployment-governance.d.ts +45 -12
  38. package/dist/deployment-governance.js +37 -9
  39. package/dist/env-name-allowlist-knobs.d.ts +37 -0
  40. package/dist/env-name-allowlist-knobs.js +24 -0
  41. package/dist/fleet-client.d.ts +2 -1
  42. package/dist/hooks/hook-llm.js +13 -4
  43. package/dist/host-lsp-manager.d.ts +14 -2
  44. package/dist/host-lsp-manager.js +7 -6
  45. package/dist/http/admission.js +22 -7
  46. package/dist/http/memory-store-refusal-reply.d.ts +24 -0
  47. package/dist/http/memory-store-refusal-reply.js +10 -0
  48. package/dist/http/routes/approvals-assistant.js +1 -0
  49. package/dist/http/routes/capabilities.js +3 -3
  50. package/dist/http/routes/memory-bundle.js +3 -0
  51. package/dist/http/routes/memory-compliance.js +3 -5
  52. package/dist/http/routes/memory-origin.js +3 -5
  53. package/dist/http/routes/sessions.js +7 -6
  54. package/dist/http/routes/side-query.js +10 -4
  55. package/dist/instruction-source-notice.d.ts +97 -0
  56. package/dist/instruction-source-notice.js +78 -0
  57. package/dist/memory-layer-legacy-lock.d.ts +14 -0
  58. package/dist/memory-layer-legacy-lock.js +74 -0
  59. package/dist/memory-operator-faces.js +1 -17
  60. package/dist/memory-store-refusal.d.ts +37 -0
  61. package/dist/memory-store-refusal.js +14 -0
  62. package/dist/model-entry-refusal.d.ts +130 -0
  63. package/dist/model-entry-refusal.js +157 -0
  64. package/dist/model-provider.d.ts +9 -3
  65. package/dist/model-route-endpoint.d.ts +1 -1
  66. package/dist/model-select.d.ts +49 -4
  67. package/dist/model-select.js +48 -17
  68. package/dist/observability/fail-open.d.ts +9 -1
  69. package/dist/observability/fail-open.js +9 -1
  70. package/dist/observability/metrics.js +1 -0
  71. package/dist/observability/secret-env-scrub.d.ts +27 -4
  72. package/dist/observability/secret-env-scrub.js +22 -7
  73. package/dist/plugins/caching-session-store.d.ts +6 -0
  74. package/dist/plugins/caching-session-store.js +6 -0
  75. package/dist/plugins/local-session-store.d.ts +20 -25
  76. package/dist/plugins/local-session-store.js +111 -64
  77. package/dist/plugins/pg-session-storage.d.ts +7 -0
  78. package/dist/plugins/pg-session-storage.js +19 -0
  79. package/dist/plugins/remote-env-host.d.ts +3 -1
  80. package/dist/plugins/remote-env-host.js +3 -6
  81. package/dist/plugins/tidb-pool.js +5 -0
  82. package/dist/plugins/tidb-session-store.d.ts +13 -0
  83. package/dist/plugins/tidb-session-store.js +14 -0
  84. package/dist/plugins/web-search.d.ts +2 -1
  85. package/dist/plugins/web-search.js +43 -24
  86. package/dist/project-memory.d.ts +10 -0
  87. package/dist/project-memory.js +28 -14
  88. package/dist/run-local.d.ts +17 -15
  89. package/dist/run-local.js +115 -15
  90. package/dist/sealed-key.d.ts +5 -2
  91. package/dist/server-secret-env.d.ts +23 -1
  92. package/dist/server-secret-env.js +9 -0
  93. package/dist/tool-approval.d.ts +65 -31
  94. package/dist/tool-approval.js +96 -49
  95. package/dist/trace/core-keyset-guard.d.ts +27 -5
  96. package/dist/trace/engine-notice-wire.d.ts +27 -1
  97. package/dist/trace/engine-notice-wire.js +8 -0
  98. package/dist/trace/wire-projection-faces.d.ts +2 -2
  99. package/dist/trace/wire-projection-faces.js +2 -2
  100. package/package.json +2 -2
  101. package/dist/memory-layer-preflight.d.ts +0 -46
  102. package/dist/memory-layer-preflight.js +0 -121
@@ -3,6 +3,7 @@ import { TiDBSessionStorage } from "./tidb-session-storage.js";
3
3
  import { decodeSessionEventPayload } from "./session-event-payload.js";
4
4
  import { jsonColumnText } from "./sql-json-column.js";
5
5
  import { escapeLike } from "./sql-escape.js";
6
+ import { BASELINE_IO_DEADLINE_MS, decodeInstructionSource, encodeInstructionSource } from "../instruction-source-notice.js";
6
7
  import { MANAGED_RETENTION } from "./retention-store-sql.js";
7
8
  import { contentForkRelation, fastForwardSharedContentDiverged } from "../session-sync-content.js";
8
9
  import { assertPlacementAdmission, LIST_PLACED_BATCH, PLACEMENT_SELECT_COLUMNS, placedRowFromRow, placementFromRow, placementInsertValues, SQL_SESSION_PLACEMENTS, } from "./session-placement.js";
@@ -183,6 +184,19 @@ export class TiDBSessionStore {
183
184
  const [r] = await this.pool.query("UPDATE session_meta SET title = ? WHERE session_id = ? AND title IS NULL", [title, sessionId]);
184
185
  return r.affectedRows > 0;
185
186
  }
187
+ async readInstructionSource(sessionId) {
188
+ const [rows] = await this.pool.query({ sql: "SELECT instruction_source FROM session_meta WHERE session_id = ?", timeout: BASELINE_IO_DEADLINE_MS }, [sessionId]);
189
+ return decodeInstructionSource(rows[0]?.instruction_source);
190
+ }
191
+ async recordInstructionSource(sessionId, expected, source) {
192
+ const next = encodeInstructionSource(source);
193
+ if (expected.recorded) {
194
+ await this.pool.query({ sql: "UPDATE session_meta SET instruction_source = ? WHERE session_id = ? AND instruction_source = ?", timeout: BASELINE_IO_DEADLINE_MS }, [next, sessionId, encodeInstructionSource(expected.source)]);
195
+ }
196
+ else {
197
+ await this.pool.query({ sql: "UPDATE session_meta SET instruction_source = ? WHERE session_id = ? AND instruction_source IS NULL", timeout: BASELINE_IO_DEADLINE_MS }, [next, sessionId]);
198
+ }
199
+ }
186
200
  async fork(sourceId, owner) {
187
201
  const [metaRows] = await this.pool.query("SELECT leaf_id FROM session_meta WHERE session_id = ?", [sourceId]);
188
202
  if (!metaRows[0])
@@ -139,7 +139,8 @@ export interface WebSearchBackend {
139
139
  export declare function setWebSearchBadPayloadObserver(fn: ((provider: string) => void) | undefined): void;
140
140
  /**
141
141
  * Build the `WebSearchConfig` (a `search` fn + `maxResults`) for `assembleFullBodyTools({ webSearch })`. The API key
142
- * stays captured in this closure — it is never surfaced to the model or the tool args.
142
+ * stays captured in this closure — it is never surfaced to the model or the tool args. Every error a leg throws leaves
143
+ * through {@link redactedBackendFailure} (S-716 ③).
143
144
  */
144
145
  export declare function createWebSearchBackend(cfg: WebSearchBackendConfig): WebSearchBackend;
145
146
  /**
@@ -1,5 +1,5 @@
1
1
  import { createSearxngSearchBackend } from "@sema-agent/core";
2
- import { isHierarchicalUrl } from "../trace/redact.js";
2
+ import { isHierarchicalUrl, redactSecrets } from "../trace/redact.js";
3
3
  export const WEB_SEARCH_PROVIDERS = ["brave", "tavily", "searxng"];
4
4
  function isWebSearchProvider(word) {
5
5
  return WEB_SEARCH_PROVIDERS.includes(word);
@@ -80,7 +80,7 @@ async function tavilySearch(fetchImpl, cfg, query, max, signal, opts) {
80
80
  body.include_domains = opts.allowedDomains.slice(0, 300);
81
81
  if (opts?.blockedDomains?.length)
82
82
  body.exclude_domains = opts.blockedDomains.slice(0, 150);
83
- const res = await fetchImpl(cfg.endpoint || TAVILY_BASE, {
83
+ const res = await fetchImpl(new URL(cfg.endpoint || TAVILY_BASE), {
84
84
  method: "POST",
85
85
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.apiKey}` },
86
86
  body: JSON.stringify(body),
@@ -116,6 +116,11 @@ async function searxngSearch(fetchImpl, cfg, query, max, signal, timeoutMs, opts
116
116
  }
117
117
  return normalize(rows, (r) => ({ title: r.title, url: r.url, snippet: r.snippet }), max, "searxng");
118
118
  }
119
+ function redactedBackendFailure(e) {
120
+ const text = e instanceof Error ? e.message : String(e);
121
+ const safe = redactSecrets(text);
122
+ return safe === text ? e : new Error(safe);
123
+ }
119
124
  export function createWebSearchBackend(cfg) {
120
125
  const fetchImpl = cfg.fetchImpl ?? fetch;
121
126
  const maxResults = Math.max(1, Math.min(20, cfg.maxResults ?? DEFAULT_MAX_RESULTS));
@@ -132,6 +137,9 @@ export function createWebSearchBackend(cfg) {
132
137
  case "searxng": return await searxngSearch(fetchImpl, cfg, q, maxResults, sig, timeoutMs, opts);
133
138
  }
134
139
  }
140
+ catch (e) {
141
+ throw redactedBackendFailure(e);
142
+ }
135
143
  finally {
136
144
  done();
137
145
  }
@@ -153,6 +161,7 @@ function judgeText(raw) {
153
161
  return isBlank(raw) ? ABSENT : { ok: true, value: raw };
154
162
  }
155
163
  const ENDPOINT_FORM = "must be an absolute URL with a scheme and a host, such as https://search.example.com (a bare host or host:port is not one)";
164
+ const ENDPOINT_USERINFO = "must not carry userinfo (user or user:password before the host)";
156
165
  function judgeEndpoint(raw) {
157
166
  if (raw === undefined || raw === null)
158
167
  return ABSENT;
@@ -160,7 +169,10 @@ function judgeEndpoint(raw) {
160
169
  return { ok: false, issue: ENDPOINT_FORM };
161
170
  if (isBlank(raw))
162
171
  return ABSENT;
163
- return isHierarchicalUrl(raw) ? { ok: true, value: raw } : { ok: false, issue: ENDPOINT_FORM };
172
+ if (!isHierarchicalUrl(raw))
173
+ return { ok: false, issue: ENDPOINT_FORM };
174
+ const url = new URL(raw);
175
+ return url.username !== "" || url.password !== "" ? { ok: false, issue: ENDPOINT_USERINFO } : { ok: true, value: raw };
164
176
  }
165
177
  const COUNT_FORM = "must be a number ≥ 1";
166
178
  function judgeCount(raw) {
@@ -173,10 +185,11 @@ function envNumber(raw) {
173
185
  }
174
186
  const SEARXNG_PARAM_NAME = /^[A-Za-z0-9_.-]+$/;
175
187
  const SEARXNG_PARAMS_FORM = 'must be an object of parameter name → non-empty string, or a "name=value;name=value" string';
188
+ const paramIssue = (entry, problem) => ({ ok: false, issue: `${SEARXNG_PARAMS_FORM} — entry #${entry} ${problem}` });
176
189
  function judgeSearxngParams(raw) {
177
190
  if (raw === undefined || raw === null)
178
191
  return ABSENT;
179
- const pairs = [];
192
+ const params = [];
180
193
  if (typeof raw === "string") {
181
194
  const segments = raw.split(";");
182
195
  for (const [i, seg] of segments.entries()) {
@@ -184,32 +197,33 @@ function judgeSearxngParams(raw) {
184
197
  continue;
185
198
  const eq = seg.indexOf("=");
186
199
  if (eq < 0)
187
- return { ok: false, issue: `${SEARXNG_PARAMS_FORM} — segment ${i + 1} has no "="` };
188
- pairs.push([seg.slice(0, eq).trim(), seg.slice(eq + 1).trim()]);
200
+ return paramIssue(i + 1, 'has no "="');
201
+ params.push({ entry: i + 1, name: seg.slice(0, eq).trim(), value: seg.slice(eq + 1).trim() });
189
202
  }
190
203
  }
191
204
  else if (typeof raw === "object" && !Array.isArray(raw)) {
192
205
  const entries = Object.entries(raw);
193
- for (const [k, v] of entries) {
206
+ for (const [i, [k, v]] of entries.entries()) {
194
207
  if (typeof v !== "string")
195
- return { ok: false, issue: `${SEARXNG_PARAMS_FORM} — the value of ${JSON.stringify(k)} is not a string` };
196
- pairs.push([k, v.trim()]);
208
+ return paramIssue(i + 1, "has a value that is not a string");
209
+ params.push({ entry: i + 1, name: k, value: v.trim() });
197
210
  }
198
211
  }
199
212
  else {
200
213
  return { ok: false, issue: `${SEARXNG_PARAMS_FORM} — got ${Array.isArray(raw) ? "an array" : `a ${typeof raw}`}` };
201
214
  }
202
- const seen = new Set();
203
- for (const [k, v] of pairs) {
204
- if (!SEARXNG_PARAM_NAME.test(k))
205
- return { ok: false, issue: `${SEARXNG_PARAMS_FORM} — ${JSON.stringify(k)} is not a parameter name (letters, digits, "_", "-", ".")` };
206
- if (v === "")
207
- return { ok: false, issue: `${SEARXNG_PARAMS_FORM} — ${k} has an empty value` };
208
- if (seen.has(k))
209
- return { ok: false, issue: `${SEARXNG_PARAMS_FORM} — ${k} is given more than once` };
210
- seen.add(k);
215
+ const firstEntryOf = new Map();
216
+ for (const p of params) {
217
+ if (!SEARXNG_PARAM_NAME.test(p.name))
218
+ return paramIssue(p.entry, 'has a name that is not a parameter name (letters, digits, "_", "-", ".")');
219
+ if (p.value === "")
220
+ return paramIssue(p.entry, "has an empty value");
221
+ const first = firstEntryOf.get(p.name);
222
+ if (first !== undefined)
223
+ return paramIssue(p.entry, `repeats the name of entry #${first}`);
224
+ firstEntryOf.set(p.name, p.entry);
211
225
  }
212
- return pairs.length === 0 ? ABSENT : { ok: true, value: Object.fromEntries(pairs) };
226
+ return params.length === 0 ? ABSENT : { ok: true, value: Object.fromEntries(params.map((p) => [p.name, p.value])) };
213
227
  }
214
228
  function assembleBackendConfig(provider, f) {
215
229
  return {
@@ -248,12 +262,17 @@ export function webSearchConfigFromEnv(env = process.env) {
248
262
  });
249
263
  }
250
264
  const SETTINGS_WEB_SEARCH_FORM = "settings.webSearch must be an object { provider, apiKey?, endpoint?, searxngParams?, maxResults? }";
265
+ const providerIssue = (shape) => ({
266
+ ok: false,
267
+ issue: `must name one of ${WEB_SEARCH_PROVIDERS.join(" | ")} (${shape}) — it names where this request's searches go, so an unknown or missing provider is refused rather than replaced by this deployment's own backend`,
268
+ });
251
269
  function judgeRequestProvider(raw) {
252
- const got = raw === undefined || raw === null ? "it is missing" : typeof raw === "string" ? `got ${JSON.stringify(raw.slice(0, 40))}` : `got a ${Array.isArray(raw) ? "array" : typeof raw}`;
253
- const word = typeof raw === "string" ? providerWord(raw) : undefined;
254
- return word !== undefined && isWebSearchProvider(word)
255
- ? { ok: true, value: word }
256
- : { ok: false, issue: `must name one of ${WEB_SEARCH_PROVIDERS.join(" | ")} (${got}) — it names where this request's searches go, so an unknown or missing provider is refused rather than replaced by this deployment's own backend` };
270
+ if (raw === undefined || raw === null)
271
+ return providerIssue("it is missing");
272
+ if (typeof raw !== "string")
273
+ return providerIssue("it is not a string");
274
+ const word = providerWord(raw);
275
+ return isWebSearchProvider(word) ? { ok: true, value: word } : providerIssue("it is not one of them");
257
276
  }
258
277
  export function judgeRequestWebSearch(raw) {
259
278
  if (raw === undefined || raw === null)
@@ -1,5 +1,6 @@
1
1
  import { type PlatformPath } from "node:path";
2
2
  import type { ProjectMemoryLoad } from "@sema-agent/core";
3
+ import { type InstructionSourceSeat } from "./instruction-source-notice.js";
3
4
  interface ProjectMemoryCtx {
4
5
  cwd: string;
5
6
  handsEnabled: boolean;
@@ -46,6 +47,13 @@ export declare function instructionChainCoordinates(absPath: string, p?: Platfor
46
47
  * (mid-run file edits surface via the probe as tail attachments, never a silently re-rendered prefix).
47
48
  * `phase: "post-compact"` forces a re-read (core re-renders the prefix there anyway). No entry for
48
49
  * sessionless one-shot runs. (post-clear never arrives from core — [ref] — so it is not a cache key here.)
50
+ *
51
+ * S-690 — source-change notice: with `instructionSource` wired, every prepare of a non-subagent session that WALKED the
52
+ * instruction chain hands the chain member it selected (`null` = none) to {@link announceInstructionSource}, which
53
+ * compares it with the session's persisted baseline. A prepare stopped by a pre-walk gate (hands-less, a cwd that is not
54
+ * an absolute local directory) is "not applicable", not "none" — it never reaches the mint (O6, see `LoadOutcome`). The member is the one the snapshot carries — a cache hit answers the cached pick, so
55
+ * the notice follows what the model actually reads, not the disk (a same-process edit changes nothing until the
56
+ * snapshot is re-read: restart, post-compact, or a new session).
49
57
  */
50
58
  export declare function makeLoadProjectMemory(opts: {
51
59
  logger?: {
@@ -54,6 +62,8 @@ export declare function makeLoadProjectMemory(opts: {
54
62
  maxBytes?: number;
55
63
  /** TEST-ONLY: shrink the snapshot-cache bound so the settle-time trim is observable without 256 sessions. */
56
64
  cacheMax?: number;
65
+ /** S-690: the baseline store + notice exit. Absent ⇒ no source-change notice (the session store has no baseline face). */
66
+ instructionSource?: InstructionSourceSeat;
57
67
  }): (ctx: ProjectMemoryCtx) => Promise<string | null | ProjectMemoryLoad>;
58
68
  /**
59
69
  * ② The `RunnerDeps.probeInstructionSources` seam: re-fingerprint the declared instruction files mid-run so
@@ -2,6 +2,7 @@ import { promises as fs, constants as fsConstants } from "node:fs";
2
2
  import { execFile } from "node:child_process";
3
3
  import { createHmac, randomBytes } from "node:crypto";
4
4
  import hostPath, { isAbsolute, join } from "node:path";
5
+ import { announceInstructionSource } from "./instruction-source-notice.js";
5
6
  import { recordSecretEnvScrub } from "./observability/secret-env-scrub.js";
6
7
  import { scrubServerSecrets } from "./server-secret-env.js";
7
8
  import { redactSecrets } from "./trace/redact.js";
@@ -41,6 +42,7 @@ function instructionHash(raw) {
41
42
  return `hmac-sha256:${createHmac("sha256", INSTRUCTION_HASH_KEY).update(raw).digest("hex")}`;
42
43
  }
43
44
  const ABSENT_HASH = null;
45
+ const NOT_APPLICABLE = { walked: false, answer: null };
44
46
  export function instructionChainCoordinates(absPath, p = hostPath) {
45
47
  const candidates = INSTRUCTION_FILE_CHAIN.filter((rel) => absPath.endsWith(p.sep + rel.split("/").join(p.sep))).sort((a, b) => b.length - a.length);
46
48
  for (const rel of candidates) {
@@ -72,7 +74,7 @@ function git(root, args, maxBytes) {
72
74
  const safeArgs = ["-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", ...args];
73
75
  const scrubFindings = [];
74
76
  const scrubbedEnv = scrubServerSecrets(process.env, scrubFindings);
75
- recordSecretEnvScrub("server.project-memory.git-subprocess", scrubFindings);
77
+ recordSecretEnvScrub("server.project-memory.git-subprocess", scrubFindings, []);
76
78
  return new Promise((res) => {
77
79
  execFile("git", safeArgs, { cwd: root, timeout: 5000, maxBuffer: maxBytes, env: { ...scrubbedEnv, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" } }, (err, stdout, stderr) => {
78
80
  if (!err)
@@ -90,17 +92,24 @@ export function makeLoadProjectMemory(opts) {
90
92
  const gitCap = 8 * 1024;
91
93
  const cache = new Map();
92
94
  const CACHE_MAX = opts.cacheMax ?? 256;
95
+ const seat = opts.instructionSource;
93
96
  return async (ctx) => {
97
+ const outcome = await loadOutcome(ctx);
98
+ if (seat && ctx.sessionId && !ctx.isSubagent && outcome.walked)
99
+ await announceInstructionSource(seat, ctx.sessionId, outcome.instructionFile);
100
+ return outcome.answer;
101
+ };
102
+ async function loadOutcome(ctx) {
94
103
  if (!ctx.handsEnabled)
95
- return null;
104
+ return NOT_APPLICABLE;
96
105
  if (!isAbsolute(ctx.cwd))
97
- return null;
106
+ return NOT_APPLICABLE;
98
107
  let root;
99
108
  try {
100
109
  root = await fs.realpath(ctx.cwd);
101
110
  }
102
111
  catch {
103
- return null;
112
+ return NOT_APPLICABLE;
104
113
  }
105
114
  const cacheKey = ctx.sessionId && !ctx.isSubagent ? `${ctx.sessionId}\u0000${root}` : undefined;
106
115
  if (cacheKey) {
@@ -144,11 +153,11 @@ export function makeLoadProjectMemory(opts) {
144
153
  });
145
154
  }
146
155
  return load;
147
- };
156
+ }
148
157
  async function doLoad(ctx, root) {
149
158
  const st = await fs.stat(root).catch(() => null);
150
159
  if (!st?.isDirectory())
151
- return null;
160
+ return NOT_APPLICABLE;
152
161
  const claudeTrunc = { v: false };
153
162
  let claudeRaw = null;
154
163
  let hitFile;
@@ -202,28 +211,33 @@ export function makeLoadProjectMemory(opts) {
202
211
  parts.push(`## Working tree\n${status.out.split("\n").slice(0, 40).join("\n")}`);
203
212
  gitNarrative = parts.join("\n\n");
204
213
  }
214
+ const instructionFile = hitFile ?? null;
205
215
  const sections = [];
206
216
  if (claude && hitFile)
207
217
  sections.push(`# ${hitFile}\n${claude}`);
208
218
  if (gitNarrative)
209
219
  sections.push(gitNarrative);
210
220
  if (sections.length === 0)
211
- return null;
221
+ return { walked: true, answer: null, instructionFile };
212
222
  const redacted = redactSecrets(sections.join("\n\n"));
213
223
  const capped = capUtf8(redacted, totalCap);
214
224
  opts.logger?.debug?.("project_memory_loaded", { cwd: root, bytes: Buffer.byteLength(capped, "utf8"), isSubagent: ctx.isSubagent, gitOk, ...(gitDropReason ? { gitDropReason } : {}), claudeTruncated, ...(hitFile ? { instructionFile: hitFile } : {}), ...(ctx.phase ? { phase: ctx.phase } : {}), cacheSize: cache.size });
215
225
  if (!capped)
216
- return null;
226
+ return { walked: true, answer: null, instructionFile };
217
227
  if (hitFile && hitHash) {
218
228
  return {
219
- content: capped,
220
- instructionSources: [
221
- ...INSTRUCTION_FILE_CHAIN.slice(0, hitIndex).map((rel) => ({ path: join(root, rel), contentHash: ABSENT_HASH })),
222
- { path: join(root, hitFile), contentHash: hitHash },
223
- ],
229
+ walked: true,
230
+ answer: {
231
+ content: capped,
232
+ instructionSources: [
233
+ ...INSTRUCTION_FILE_CHAIN.slice(0, hitIndex).map((rel) => ({ path: join(root, rel), contentHash: ABSENT_HASH })),
234
+ { path: join(root, hitFile), contentHash: hitHash },
235
+ ],
236
+ },
237
+ instructionFile,
224
238
  };
225
239
  }
226
- return capped;
240
+ return { walked: true, answer: capped, instructionFile };
227
241
  }
228
242
  }
229
243
  export function makeProbeInstructionSources() {
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { type Brain, type OnAsk, type TaskSpec } from "@sema-agent/core";
2
+ import { type AskRequest, type Brain, type TaskSpec } from "@sema-agent/core";
3
3
  import { type ApprovalBaselineConfigView, type DeploymentGovernanceConfigView } from "./deployment-governance.js";
4
4
  import { type ServiceConfig } from "./config.js";
5
5
  import { type Logger } from "./observability/logger.js";
@@ -77,9 +77,12 @@ export interface LocalGovernanceConfigView extends DeploymentGovernanceConfigVie
77
77
  * 叠上部署治理段(autonomy / commandPolicy / MANUAL_MODE_SHELL_GATE / 守卫集)。本函数一条 policy 都不
78
78
  * 自己合成。
79
79
  *
80
- * · **审批基线 = adjudicated allow-all**:一次性 CLI 没有 durable 审批设施(没有 `/decide` 腿可赎回),
81
- * 所以 `createApprovalBaselinePolicy` 走 durable 缺席那一臂。它是一条**在场的** effect-aware 策略
82
- * (满足 core 的 `hasEffectAwareGate`),运维照旧用 `AUTONOMY` / `commandPolicy` / 守卫集在其上收紧。
80
+ * · **审批基线 = live 席**({@link RUN_LOCAL_APPROVAL_SEAT},S-694):一次性 CLI 没有 durable 审批设施(没有
81
+ * `/decide` 腿可赎回),但**有**一只活 onAsk 席({@link createLocalApprover})。三张审批名单因此与 HTTP 腿
82
+ * **同一只**策略执法(`createDurableAskPolicy`:deny ⇒ 拒;require / never-auto ⇒ ask ⇒ 那只席当场服),
83
+ * 不叠 durable 问答门。修前本腿不传席 ⇒ 落 allow-all 臂 ⇒ 名单在 CLI 上无声空转 —— [ref] 件一把「没有
84
+ * durable 赎回腿」等同成了「ask 无处可服」,可件三同窗装上的正是那只服 ask 的席。运维照旧用
85
+ * `AUTONOMY` / `commandPolicy` / 守卫集在其上收紧。
83
86
  * · **裁决 env 恒 host 形,锚 `workspaceDir`**:run-local 的手就跑在这台机器的这个目录上
84
87
  * (`--workspace` / config.d workdir / cwd 三选一,调用方已解析好),所以 `cwd` 恒在场 —— 相对形写目标
85
88
  * 由 core 按 `rootPath` 解析进真身裁决,构造口的词法补层(沙箱腿那条)在这里自动不铺。
@@ -122,27 +125,26 @@ export interface LocalApproverIo {
122
125
  * ⛔ **不在 toolPolicy 层做 ask→deny 映射**:core 自己就有 headless auto-deny 的语义与文案,在策略层
123
126
  * 复刻一份 = 同源谎(两处判据日后各改各的)。裁决归 core,座位归这里。
124
127
  *
128
+ * 🔴 S-694 起本席也服**三张审批名单**产的 ask(审批基线取 live 席,见 {@link RUN_LOCAL_APPROVAL_SEAT}):
129
+ * `APPROVAL_REQUIRE` / `APPROVAL_NEVER_AUTO` 命中 ⇒ 策略 `ask` ⇒ 落到这里。`AskRequest` 不带是哪条声明产的
130
+ * ask(core 把 `matchedAskRule` 刻意留在闸内、不上请求),所以无 TTY 拒句**列出**可能产门的全部旋钮,
131
+ * `APPROVAL_DENY` 也点名(它当场拒、不经本席 —— 运维排查「为什么这只工具不跑」时要在同一句里看到它)。
132
+ *
125
133
  * 🔴 非 TTY 臂答 `false`(真 deny),**绝不答 `"unavailable"`**:后者被 core 读成「席位够不到」并**改道
126
134
  * `suspendAsk`**(dist `core/hooks.js` 的 `approverUnavailable` 重投)。今天本腿无店 ⇒ 那条改道无处
127
135
  * 可去(fail-closed 拒),但这一条仍是本席的契约:答案是「不批」,不是「找不到人」。
128
136
  */
129
- export declare function createLocalApprover(io: LocalApproverIo): OnAsk;
130
- /** 默认的 TTY 问人器:一问一答,回答写 **stderr**(stdout 是任务结果的专用管道,别污染它)。
131
- * streams 可注入=EOF/abort 两臂测得动(默认 process.stdin/stderr)。
132
- *
133
- * 🔴 决断三臂(codex 验收轮 high,红先修):`readline/promises` 的 `question` 在输入流 EOF/close 时
134
- * **不决**(promise 悬挂)——Ctrl-D、关闭的 stdin、任务取消都会把 runLocal 永久挂住,文件后端的锁
135
- * 跟着被拖住,下一次同 root 调用直接卡死。三臂全部 fail-closed 决 false:
136
- * · `signal` abort(core `OnAsk` 递进来的取消,含预先 aborted);
137
- * · readline `close`(输入流 end/close 时 readline 自关);
138
- * · `question` 自身 reject(abort throw / 流错误)。 */
137
+ /** 本席的形:core `OnAsk` 的函数臂(`"allow"` / `"deny"` 两个字面臂本席从不用),答案恒是布尔 —— 结构上可赋给 `OnAsk`。 */
138
+ export type LocalApprover = (req: AskRequest, signal?: AbortSignal) => Promise<boolean>;
139
+ export declare function createLocalApprover(io: LocalApproverIo): LocalApprover;
139
140
  export declare function promptYesNoOnTty(prompt: string, signal?: AbortSignal, streams?: {
140
141
  input: NodeJS.ReadableStream;
141
142
  output: NodeJS.WritableStream;
142
143
  }): Promise<boolean>;
143
144
  /**
144
145
  * Boot the engine in LOCAL mode, run ONE task to completion, print the result. Returns the process exit
145
- * code (0 = completed, 1 = the task did not complete / a fatal error, 2 = bad usage / unmet env). Pure of
146
+ * code (0 = completed its work, 1 = the task did not complete — including a `completed` run that a person stopped,
147
+ * see {@link personStopOf} — / a fatal error, 2 = bad usage / unmet env). Pure of
146
148
  * `process.exit` so a test can assert the code; `main()` applies it.
147
149
  */
148
150
  export declare function runLocal(argv: string[], deps?: RunLocalDeps): Promise<number>;
package/dist/run-local.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { DEFAULT_MODEL_REF } from "./model-select.js";
2
+ import { DEFAULT_MODEL_REF, resolveTaskModel } from "./model-select.js";
3
+ import { defaultModelRefusal, modelUnavailableMessage } from "./model-entry-refusal.js";
3
4
  import { buildDurableAbsenceNote, createCorruptReadSeat } from "./observability/corrupt-read-seat.js";
4
5
  import { outwardPlane } from "./terminal.js";
5
6
  import { homedir } from "node:os";
@@ -8,7 +9,7 @@ import { realpathSync } from "node:fs";
8
9
  import { createInterface } from "node:readline/promises";
9
10
  import { fileURLToPath } from "node:url";
10
11
  import { loadRemoteExec } from "@sema-agent/settings-schema/node";
11
- import { Runner, uuidv7, parseModelMention, FileStorageBackend, NodeExecutionEnv, TtlSessionStore, CenterPromptSource, FilePromptArtifactStore, FilePromptSourceStateStore, MemoryPromptArtifactStore, MemoryPromptSourceStateStore, defaultTaskRegistry } from "@sema-agent/core";
12
+ import { Runner, uuidv7, FileStorageBackend, NodeExecutionEnv, TtlSessionStore, CenterPromptSource, FilePromptArtifactStore, FilePromptSourceStateStore, MemoryPromptArtifactStore, MemoryPromptSourceStateStore, defaultTaskRegistry } from "@sema-agent/core";
12
13
  import { singleUserAutoAcceptBaselineApplies } from "./approval.js";
13
14
  import { createBrain } from "./brain.js";
14
15
  import { assertGuardPatternsUsable, buildOnlySensitiveBaselineWarning, createApprovalBaselinePolicy, createDeploymentGovernanceInputs, } from "./deployment-governance.js";
@@ -25,6 +26,7 @@ import { applyEffective, applyCenterReadFace, resolveMcpServers, mcpForScenario,
25
26
  import { hostExecutionEnvFactory } from "./plugins/remote-env-host.js";
26
27
  import { executionLaneHomeDir, executionLaneOf } from "./execution-lane-caps.js";
27
28
  import { makeLoadProjectMemory, makeProbeInstructionSources } from "./project-memory.js";
29
+ import { createInstructionSourceSeat } from "./instruction-source-notice.js";
28
30
  import { loadDeploymentSkills } from "./capabilities/skills.js";
29
31
  import { createRepoClient } from "./capabilities/repo-tools.js";
30
32
  import { webSearchConfigFromEnv, createWebSearchBackend } from "./plugins/web-search.js";
@@ -122,22 +124,81 @@ export function missingGatewayRequirements(config) {
122
124
  }
123
125
  return missing;
124
126
  }
127
+ const RUN_LOCAL_APPROVAL_SEAT = { kind: "live" };
125
128
  export function applyLocalGovernance(base, config, workspaceDir, engineDataRoot) {
126
- return applyRuntimeGovernance({ ...base, toolPolicy: createApprovalBaselinePolicy(config) }, createDeploymentGovernanceInputs(config, { env: new NodeExecutionEnv({ cwd: workspaceDir }), cwd: workspaceDir }, { engineDataRoot }));
129
+ return applyRuntimeGovernance({ ...base, toolPolicy: createApprovalBaselinePolicy(config, RUN_LOCAL_APPROVAL_SEAT) }, createDeploymentGovernanceInputs(config, { env: new NodeExecutionEnv({ cwd: workspaceDir }), cwd: workspaceDir }, { engineDataRoot }));
127
130
  }
128
131
  export function createLocalApprover(io) {
132
+ let tail = Promise.resolve();
129
133
  return async (req, signal) => {
130
134
  if (!io.isTty) {
131
- io.printErr(`denied "${req.toolName}" (${req.message}) — this run-local invocation has no TTY, so there is nobody to ask. ` +
132
- `The gate comes from this deployment's governance knobs: AUTONOMY, MANUAL_MODE_SHELL_GATE, ` +
133
- `runtime.commandPolicy (config.d/governance.json). Run it in a terminal to answer interactively, or relax the knob.`);
135
+ io.printErr(`denied "${terminalSafeText(req.toolName)}" (${terminalSafeText(req.message)}) — this run-local invocation has no TTY, so there is nobody to ask. ` +
136
+ `The gate comes from this deployment's governance knobs: APPROVAL_REQUIRE / APPROVAL_NEVER_AUTO (the approval lists; ` +
137
+ `APPROVAL_DENY refuses outright without asking), AUTONOMY, MANUAL_MODE_SHELL_GATE, runtime.commandPolicy (config.d/governance.json). ` +
138
+ `Run it in a terminal to answer interactively, or relax the knob (APPROVAL_AUTO_BUDGET auto-approves that many APPROVAL_REQUIRE calls; ` +
139
+ `a separate .env for this CLI keeps a server's lists off it).`);
134
140
  return false;
135
141
  }
136
- return io.askYesNo(`approve "${req.toolName}"? ${req.message} [y/N] `, signal);
142
+ const argsShown = argsDisplayOf(req.args);
143
+ if (!argsShown.ok) {
144
+ io.printErr(`denied "${terminalSafeText(req.toolName)}" (${terminalSafeText(req.message)}) — its arguments cannot be displayed ` +
145
+ `(${terminalSafeText(argsShown.reason)}), so there is nothing a person could review; refusing instead of asking.`);
146
+ return false;
147
+ }
148
+ const prior = tail;
149
+ let release;
150
+ const mine = new Promise((resolve) => {
151
+ release = resolve;
152
+ });
153
+ tail = prior.then(() => mine);
154
+ try {
155
+ if (!(await priorSettledUnlessAborted(prior, signal)))
156
+ return false;
157
+ return await io.askYesNo(buildLocalAskPrompt(req, argsShown.text), signal);
158
+ }
159
+ finally {
160
+ release();
161
+ }
137
162
  };
138
163
  }
139
- export async function promptYesNoOnTty(prompt, signal, streams = { input: process.stdin, output: process.stderr }) {
164
+ function priorSettledUnlessAborted(prior, signal) {
140
165
  if (signal?.aborted)
166
+ return Promise.resolve(false);
167
+ if (signal === undefined)
168
+ return prior.then(() => true);
169
+ return new Promise((resolve) => {
170
+ const onAbort = () => resolve(false);
171
+ signal.addEventListener("abort", onAbort, { once: true });
172
+ void prior.then(() => {
173
+ signal.removeEventListener("abort", onAbort);
174
+ resolve(!signal.aborted);
175
+ });
176
+ });
177
+ }
178
+ function argsDisplayOf(args) {
179
+ let json;
180
+ try {
181
+ json = JSON.stringify(args);
182
+ }
183
+ catch (e) {
184
+ return { ok: false, reason: e instanceof Error ? e.message : String(e) };
185
+ }
186
+ if (json === undefined)
187
+ return { ok: false, reason: "the arguments serialize to nothing" };
188
+ return { ok: true, text: terminalSafeText(json) };
189
+ }
190
+ function buildLocalAskPrompt(req, argsShown) {
191
+ return `approve "${terminalSafeText(req.toolName)}"? ${terminalSafeText(req.message)}\n ${argsShown}\n[y/N] `;
192
+ }
193
+ function terminalSafeText(text) {
194
+ return text.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
195
+ }
196
+ function inputAlreadyEnded(input) {
197
+ const state = input;
198
+ return state.readableEnded === true || state.destroyed === true;
199
+ }
200
+ export async function promptYesNoOnTty(prompt, signal, streams = { input: process.stdin, output: process.stderr }) {
201
+ if (signal?.aborted || inputAlreadyEnded(streams.input))
141
202
  return false;
142
203
  const rl = createInterface({ input: streams.input, output: streams.output });
143
204
  try {
@@ -158,6 +219,27 @@ export async function promptYesNoOnTty(prompt, signal, streams = { input: proces
158
219
  rl.close();
159
220
  }
160
221
  }
222
+ function personStopOf(result) {
223
+ if (result.terminal.kind !== "completed")
224
+ return undefined;
225
+ if (result.haltedOnUserRejection === true)
226
+ return "haltedOnUserRejection";
227
+ if (result.haltedByUser === true)
228
+ return "haltedByUser";
229
+ return undefined;
230
+ }
231
+ function buildPersonStopFooter(stop, sessionId) {
232
+ switch (stop) {
233
+ case "haltedOnUserRejection":
234
+ return `task stopped (haltedOnUserRejection): a tool call was refused, so the run did not finish its work — continue it with --session ${sessionId}`;
235
+ case "haltedByUser":
236
+ return `task stopped (haltedByUser): the run was halted before it finished its work — continue it with --session ${sessionId}`;
237
+ default: {
238
+ const unhandled = stop;
239
+ throw new Error(`person-stop marker "${String(unhandled)}" has no footer`);
240
+ }
241
+ }
242
+ }
161
243
  export async function runLocal(argv, deps = {}) {
162
244
  const print = deps.print ?? ((l) => console.log(l));
163
245
  const printErr = deps.printErr ?? ((l) => console.error(l));
@@ -195,6 +277,8 @@ export async function runLocal(argv, deps = {}) {
195
277
  if (r) {
196
278
  for (const de of r.domainErrors ?? [])
197
279
  logger.warn("config_domain_invalid", { domain: de.domain, error: de.error, note: "domain fell to schema defaults; other domains applied normally" });
280
+ for (const d of r.droppedEntries ?? [])
281
+ logger.warn("config_entry_dropped", { domain: d.domain, entry: d.entry, error: d.error, version: r.effective.version, phase: "boot", note: "this one entry failed its domain schema and was dropped on its own — the rest of the candidate applies (per-entry tolerance); fix the named entry to bring it back" });
198
282
  effective = r.effective;
199
283
  applyEffective(config, r.effective, logger, { leg: "boot" });
200
284
  if (effective?.mcp)
@@ -208,6 +292,9 @@ export async function runLocal(argv, deps = {}) {
208
292
  catch (err) {
209
293
  logger.warn("config_source_unreachable_using_env", { source: configProvider.kind, err: String(err) });
210
294
  }
295
+ const noDefaultModel = defaultModelRefusal(config, "env");
296
+ if (noDefaultModel !== undefined)
297
+ throw new Error(noDefaultModel);
211
298
  const endpointRefusal = modelRouteEndpointRefusal(config);
212
299
  if (endpointRefusal !== undefined)
213
300
  throw new Error(endpointRefusal);
@@ -242,7 +329,7 @@ export async function runLocal(argv, deps = {}) {
242
329
  return 2;
243
330
  }
244
331
  const ungatedCompensation = buildOnlySensitiveBaselineWarning(config, {
245
- durableEnabled: false,
332
+ approvalSeat: RUN_LOCAL_APPROVAL_SEAT.kind,
246
333
  singleUserAutoAcceptBaseline: singleUserAutoAcceptBaselineApplies(config),
247
334
  });
248
335
  if (ungatedCompensation)
@@ -286,7 +373,7 @@ export async function runLocal(argv, deps = {}) {
286
373
  ...(hostRx.commandTimeoutMs != null ? { commandTimeoutMs: hostRx.commandTimeoutMs } : {}),
287
374
  })
288
375
  : undefined;
289
- const lspManager = hostRx && config.lspHostEnabled && config.requirePrincipal !== true ? createHostLspManager((event, fields) => logger.info(event, fields)) : undefined;
376
+ const lspManager = hostRx && config.lspHostEnabled && config.requirePrincipal !== true ? createHostLspManager(config, (event, fields) => logger.info(event, fields)) : undefined;
290
377
  const memoryEngine = memoryEngineBackendFor(config, root, memoryEngineIncident);
291
378
  let memorySync;
292
379
  if (config.memorySync && memoryEngine) {
@@ -417,7 +504,10 @@ export async function runLocal(argv, deps = {}) {
417
504
  onError: engineOnError,
418
505
  onAsk,
419
506
  ...(config.projectMemoryEnabled
420
- ? { loadProjectMemory: makeLoadProjectMemory({ logger }), probeInstructionSources: makeProbeInstructionSources() }
507
+ ? {
508
+ loadProjectMemory: makeLoadProjectMemory({ logger, instructionSource: createInstructionSourceSeat(sessionStore, sharedRunnerDeps.onNotice) }),
509
+ probeInstructionSources: makeProbeInstructionSources(),
510
+ }
421
511
  : {}),
422
512
  };
423
513
  const runner = new Runner(runnerDeps);
@@ -460,7 +550,14 @@ export async function runLocal(argv, deps = {}) {
460
550
  }
461
551
  throw err;
462
552
  }
463
- const mention = parseModelMention(args.objective, Object.keys(config.models));
553
+ const mention = resolveTaskModel(undefined, args.objective, config.models, undefined, config.modelRefusals);
554
+ if (mention.unavailable !== undefined) {
555
+ const { requested, name, refusal } = mention.unavailable;
556
+ printErr(`cannot run: ${modelUnavailableMessage("@-mention", requested, name, refusal)}`);
557
+ void lspManager?.dispose();
558
+ await fileBackend.dispose();
559
+ return 2;
560
+ }
464
561
  const scope = args.user ?? "local";
465
562
  const taskTimeoutSec = Math.floor(parseNumOrFailNonNegative("TASK_TIMEOUT_SEC", process.env.TASK_TIMEOUT_SEC || "0"));
466
563
  const timeoutSec = taskWallClockSec(taskTimeoutSec, false, scenarioName === "discuss");
@@ -474,13 +571,13 @@ export async function runLocal(argv, deps = {}) {
474
571
  ...(maxTokens !== undefined ? { maxTokens } : {}),
475
572
  };
476
573
  const spec = applyLocalGovernance({
477
- objective: mention.cleanedText || args.objective,
574
+ objective: mention.cleanedObjective,
478
575
  sessionId,
479
576
  backgroundScope: "session",
480
577
  oneShot: true,
481
578
  principal: scope,
482
579
  ...(memoryEngine ? { memory: memorySpecForRequest(scope, memoryProjectKeyFor(config, scope, workspaceDir), undefined) } : {}),
483
- model: mention.model ?? "default",
580
+ model: mention.model,
484
581
  getApiKeyAndHeaders: keyResolver,
485
582
  tools: cap.tools,
486
583
  skills: cap.skills,
@@ -527,11 +624,14 @@ export async function runLocal(argv, deps = {}) {
527
624
  await fileBackend.dispose();
528
625
  return 1;
529
626
  }
627
+ const personStop = personStopOf(result);
530
628
  if (args.json) {
531
629
  print(JSON.stringify(result, null, 2));
532
630
  }
533
631
  else if (result.terminal.kind === "completed") {
534
632
  print(result.result);
633
+ if (personStop !== undefined)
634
+ printErr(buildPersonStopFooter(personStop, sessionId));
535
635
  }
536
636
  else {
537
637
  const plane = outwardPlane(result.terminal);
@@ -540,7 +640,7 @@ export async function runLocal(argv, deps = {}) {
540
640
  print(salvage);
541
641
  printErr(`task ${plane.status}${plane.errorCode ? ` (${plane.errorCode})` : ""}${plane.errorMessage ? `: ${plane.errorMessage}` : ""}`);
542
642
  }
543
- if (result.terminal.kind !== "completed")
643
+ if (result.terminal.kind !== "completed" || personStop !== undefined)
544
644
  exitCode = 1;
545
645
  if (memorySync) {
546
646
  try {
@@ -23,8 +23,11 @@ export interface SealedKeyStore extends SealedKeyOpener {
23
23
  createdAt: string;
24
24
  };
25
25
  }
26
- /** Why a model's sealed key could not be unsealed (the poison-pill diagnosis, see SealedKeyPoison). */
27
- export type SealedKeyPoisonReason = "sealed_decrypt_failed" | "stale_ciphertext" | "unsupported_alg" | "no_key_store";
26
+ /** Why a model's sealed key could not be unsealed (the poison-pill diagnosis, see SealedKeyPoison). CUSTODY failures only:
27
+ * a sealedApiKey that is malformed (alg other than the pinned v1 SEALED_KEY_ALG, non-base64 ciphertext) makes the whole
28
+ * catalog entry unavailable instead (S-704 `config.model_entry_invalid`, settings-schema `SealedApiKey` is the shape
29
+ * owner) — the former `unsupported_alg` reason was retired with that move (it could no longer be reached). */
30
+ export type SealedKeyPoisonReason = "sealed_decrypt_failed" | "stale_ciphertext" | "no_key_store";
28
31
  /**
29
32
  * Poison-pill entry for the per-model key map (`config.modelApiKeys`): planted by applyEffective when
30
33
  * a model CARRIES `sealedApiKey` but the ciphertext cannot be unsealed. We deliberately do NOT drop