@sema-agent/server 7.100.0 → 7.101.1

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 (107) 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/stage-10-http-server.js +2 -0
  19. package/dist/boot/stores.js +4 -0
  20. package/dist/config-catalog.js +5 -3
  21. package/dist/config-center/apply-effective.d.ts +25 -0
  22. package/dist/config-center/apply-effective.js +144 -69
  23. package/dist/config-center/apply-ledger.d.ts +12 -2
  24. package/dist/config-center/apply-ledger.js +4 -1
  25. package/dist/config-center/facade.d.ts +1 -1
  26. package/dist/config-center/facade.js +1 -1
  27. package/dist/config-center/http-client.d.ts +4 -1
  28. package/dist/config-center/http-client.js +15 -10
  29. package/dist/config-center/read-warnings.d.ts +115 -6
  30. package/dist/config-center/read-warnings.js +115 -5
  31. package/dist/config-center/types.d.ts +6 -0
  32. package/dist/config-invariants.d.ts +2 -2
  33. package/dist/config-invariants.js +15 -0
  34. package/dist/config-provider.d.ts +4 -0
  35. package/dist/config-provider.js +64 -9
  36. package/dist/config-types.d.ts +37 -8
  37. package/dist/config.js +87 -22
  38. package/dist/deployment-governance.d.ts +45 -12
  39. package/dist/deployment-governance.js +37 -9
  40. package/dist/env-name-allowlist-knobs.d.ts +37 -0
  41. package/dist/env-name-allowlist-knobs.js +24 -0
  42. package/dist/fleet/fleet-bus.d.ts +30 -4
  43. package/dist/fleet/fleet-bus.js +20 -4
  44. package/dist/fleet/fleet-reconciler.d.ts +29 -6
  45. package/dist/fleet/fleet-reconciler.js +109 -79
  46. package/dist/fleet-client.d.ts +2 -1
  47. package/dist/hooks/hook-llm.js +13 -4
  48. package/dist/host-lsp-manager.d.ts +14 -2
  49. package/dist/host-lsp-manager.js +7 -6
  50. package/dist/http/admission.js +22 -7
  51. package/dist/http/memory-store-refusal-reply.d.ts +24 -0
  52. package/dist/http/memory-store-refusal-reply.js +10 -0
  53. package/dist/http/routes/approvals-assistant.js +1 -0
  54. package/dist/http/routes/capabilities.js +3 -3
  55. package/dist/http/routes/memory-bundle.js +3 -0
  56. package/dist/http/routes/memory-compliance.js +3 -5
  57. package/dist/http/routes/memory-origin.js +3 -5
  58. package/dist/http/routes/sessions.js +7 -6
  59. package/dist/http/routes/side-query.js +10 -4
  60. package/dist/instruction-source-notice.d.ts +97 -0
  61. package/dist/instruction-source-notice.js +78 -0
  62. package/dist/memory-layer-legacy-lock.d.ts +14 -0
  63. package/dist/memory-layer-legacy-lock.js +74 -0
  64. package/dist/memory-operator-faces.js +1 -17
  65. package/dist/memory-store-refusal.d.ts +37 -0
  66. package/dist/memory-store-refusal.js +14 -0
  67. package/dist/model-entry-refusal.d.ts +130 -0
  68. package/dist/model-entry-refusal.js +157 -0
  69. package/dist/model-provider.d.ts +9 -3
  70. package/dist/model-route-endpoint.d.ts +1 -1
  71. package/dist/model-select.d.ts +49 -4
  72. package/dist/model-select.js +48 -17
  73. package/dist/observability/fail-open.d.ts +13 -1
  74. package/dist/observability/fail-open.js +13 -1
  75. package/dist/observability/metrics.js +1 -0
  76. package/dist/observability/secret-env-scrub.d.ts +27 -4
  77. package/dist/observability/secret-env-scrub.js +22 -7
  78. package/dist/plugins/caching-session-store.d.ts +6 -0
  79. package/dist/plugins/caching-session-store.js +6 -0
  80. package/dist/plugins/local-session-store.d.ts +20 -25
  81. package/dist/plugins/local-session-store.js +111 -64
  82. package/dist/plugins/pg-session-storage.d.ts +7 -0
  83. package/dist/plugins/pg-session-storage.js +19 -0
  84. package/dist/plugins/remote-env-host.d.ts +3 -1
  85. package/dist/plugins/remote-env-host.js +3 -6
  86. package/dist/plugins/tidb-pool.js +5 -0
  87. package/dist/plugins/tidb-session-store.d.ts +13 -0
  88. package/dist/plugins/tidb-session-store.js +14 -0
  89. package/dist/plugins/web-search.d.ts +2 -1
  90. package/dist/plugins/web-search.js +43 -24
  91. package/dist/project-memory.d.ts +10 -0
  92. package/dist/project-memory.js +28 -14
  93. package/dist/run-local.d.ts +17 -15
  94. package/dist/run-local.js +115 -15
  95. package/dist/sealed-key.d.ts +5 -2
  96. package/dist/server-secret-env.d.ts +23 -1
  97. package/dist/server-secret-env.js +9 -0
  98. package/dist/tool-approval.d.ts +74 -31
  99. package/dist/tool-approval.js +102 -49
  100. package/dist/trace/core-keyset-guard.d.ts +27 -5
  101. package/dist/trace/engine-notice-wire.d.ts +27 -1
  102. package/dist/trace/engine-notice-wire.js +8 -0
  103. package/dist/trace/wire-projection-faces.d.ts +2 -2
  104. package/dist/trace/wire-projection-faces.js +2 -2
  105. package/package.json +2 -2
  106. package/dist/memory-layer-preflight.d.ts +0 -46
  107. package/dist/memory-layer-preflight.js +0 -121
@@ -11,7 +11,7 @@ export const FLEET_RECONCILE_COOLDOWN_MS = 60_000;
11
11
  export const FLEET_RECONCILE_WATCH_MAX = 200;
12
12
  export const FLEET_RECONCILE_WATCH_TTL_MS = 30 * 60_000;
13
13
  const emptyTally = () => ({
14
- scanned: 0, skippedLive: 0, skippedParked: 0, retired: 0, orphaned: 0, childrenSwept: 0, workflowsRetired: 0, reads: 0, cooling: false, failOpen: false,
14
+ scanned: 0, skippedLive: 0, skippedParked: 0, retired: 0, orphaned: 0, childrenSwept: 0, childrenKeptAlive: 0, workflowsRetired: 0, reads: 0, cooling: false, failOpen: false,
15
15
  });
16
16
  function rotate(rows, cursor) {
17
17
  if (cursor === undefined || rows.length === 0)
@@ -22,6 +22,22 @@ function rotate(rows, cursor) {
22
22
  return [...rows.slice(at), ...rows.slice(0, at)];
23
23
  }
24
24
  const SILENT_FOR_A_CYCLE = FLEET_RECONCILE_INTERVAL_MS;
25
+ function bgAgentVouches(status) {
26
+ switch (status) {
27
+ case "running":
28
+ case "parked":
29
+ return true;
30
+ case "completed":
31
+ case "failed":
32
+ case "killed":
33
+ return false;
34
+ default: {
35
+ const unhandled = status;
36
+ recordFailOpen("server.fleet.agent-status-unmapped", String(unhandled));
37
+ return true;
38
+ }
39
+ }
40
+ }
25
41
  export function createFleetReconciler(deps) {
26
42
  const now = deps.now ?? (() => Date.now());
27
43
  const lastFrameAt = new Map();
@@ -86,11 +102,11 @@ export function createFleetReconciler(deps) {
86
102
  coolingUntil = undefined;
87
103
  const deadline = now() + FLEET_RECONCILE_READ_BUDGET_MS;
88
104
  const budgetLeft = () => tally.reads < FLEET_RECONCILE_MAX_ROWS && now() < deadline;
89
- const readRun = async (id) => {
105
+ const budgetedRead = async (read) => {
90
106
  tally.reads++;
91
107
  let t;
92
- const rec = await Promise.race([
93
- deps.runs.getRun(id),
108
+ return await Promise.race([
109
+ read(),
94
110
  new Promise((_r, reject) => {
95
111
  t = setTimeout(() => reject(new ReadAborted(`fleet reconcile read exceeded ${FLEET_RECONCILE_READ_BUDGET_MS}ms`)), Math.max(0, deadline - now()));
96
112
  if (typeof t.unref === "function")
@@ -100,30 +116,43 @@ export function createFleetReconciler(deps) {
100
116
  if (t !== undefined)
101
117
  clearTimeout(t);
102
118
  });
103
- return rec;
104
119
  };
120
+ const readRun = (id) => budgetedRead(() => deps.runs.getRun(id));
105
121
  const snap = deps.bus.snapshot();
106
122
  for (const r of snap.tasks)
107
123
  if (!lastFrameAt.has(r.id))
108
124
  lastFrameAt.set(r.id, nowMs);
109
125
  const idle = (id) => nowMs - (lastFrameAt.get(id) ?? nowMs) >= SILENT_FOR_A_CYCLE;
110
126
  const present = new Set(snap.tasks.map((r) => r.id));
111
- const kids = new Map();
112
- for (const r of snap.tasks) {
113
- if (r.parentId === undefined)
114
- continue;
115
- const bucket = kids.get(r.parentId);
116
- if (bucket)
117
- bucket.push(r);
118
- else
119
- kids.set(r.parentId, [r]);
120
- }
121
- const sweepChildren = (parentId) => {
122
- for (const c of kids.get(parentId) ?? []) {
123
- deps.bus.removeTask(c.id, { removeReason: "orphaned", ...(c.cycleSeq !== undefined ? { cycleSeq: c.cycleSeq } : {}) });
124
- lastFrameAt.delete(c.id);
125
- tally.childrenSwept++;
127
+ const parentsOnBus = new Set(present);
128
+ const stillSilent = (row) => {
129
+ const cur = deps.bus.taskRow(row.id);
130
+ return cur !== undefined && cur.cycleSeq === row.cycleSeq && idle(row.id);
131
+ };
132
+ const keptAlive = (row, why) => {
133
+ tally.childrenKeptAlive++;
134
+ deps.logger?.info("fleet_row_reconciled", { taskId: row.id, disposition: "child_kept_alive", why, instanceId: deps.instanceId ?? null });
135
+ };
136
+ const judgeSilentRow = async (row, exit) => {
137
+ const handle = deps.bus.childOwnerKeys(row).handle;
138
+ if (handle !== undefined && deps.agents !== undefined && row.scope !== undefined) {
139
+ if (!budgetLeft())
140
+ return "over_budget";
141
+ const scope = row.scope;
142
+ const rec = await budgetedRead(() => deps.agents.get(handle, scope));
143
+ if (rec !== null && bgAgentVouches(rec.status)) {
144
+ keptAlive(row, "bg_registry");
145
+ return "settled";
146
+ }
147
+ }
148
+ const hasPendingAsk = deps.hasPendingAsk;
149
+ if (hasPendingAsk !== undefined && deps.bus.childOwnerKeys(row).sessionIds.some((id) => hasPendingAsk(id))) {
150
+ keptAlive(row, "pending_ask");
151
+ return "settled";
126
152
  }
153
+ if (stillSilent(row))
154
+ exit();
155
+ return "settled";
127
156
  };
128
157
  const retire = (row, rec) => {
129
158
  deps.bus.publishTask({
@@ -132,14 +161,14 @@ export function createFleetReconciler(deps) {
132
161
  retiredBy: "reconcile",
133
162
  ...fleetRunResiduals(rec.result),
134
163
  });
135
- sweepChildren(row.id);
164
+ parentsOnBus.delete(row.id);
136
165
  deps.bus.removeTask(row.id, { ...(row.cycleSeq !== undefined ? { cycleSeq: row.cycleSeq } : {}) });
137
166
  lastFrameAt.delete(row.id);
138
167
  tally.retired++;
139
168
  deps.metrics?.inc("fleet_rows_reconciled_total", { disposition: "retired" });
140
169
  };
141
170
  const orphanExit = (row, why) => {
142
- sweepChildren(row.id);
171
+ parentsOnBus.delete(row.id);
143
172
  deps.bus.removeTask(row.id, { removeReason: "orphaned", ...(row.cycleSeq !== undefined ? { cycleSeq: row.cycleSeq } : {}) });
144
173
  lastFrameAt.delete(row.id);
145
174
  if (deps.runs && watch.size < FLEET_RECONCILE_WATCH_MAX)
@@ -148,6 +177,61 @@ export function createFleetReconciler(deps) {
148
177
  deps.metrics?.inc("fleet_rows_reconciled_total", { disposition: "orphaned" });
149
178
  deps.logger?.info("fleet_row_reconciled", { taskId: row.id, disposition: "orphaned", why, instanceId: deps.instanceId ?? null });
150
179
  };
180
+ const childExit = (row, parentId) => {
181
+ deps.bus.removeTask(row.id, { removeReason: "orphaned", ...(row.cycleSeq !== undefined ? { cycleSeq: row.cycleSeq } : {}) });
182
+ lastFrameAt.delete(row.id);
183
+ parentsOnBus.delete(row.id);
184
+ if (present.has(parentId)) {
185
+ tally.childrenSwept++;
186
+ }
187
+ else {
188
+ tally.orphaned++;
189
+ deps.metrics?.inc("fleet_rows_reconciled_total", { disposition: "orphaned_child" });
190
+ }
191
+ };
192
+ const judgeTopLevel = async (row) => {
193
+ tally.scanned++;
194
+ if (deps.isLiveOnThisReplica(row.id)) {
195
+ tally.skippedLive++;
196
+ return "settled";
197
+ }
198
+ if (!deps.runs)
199
+ return "settled";
200
+ if (!budgetLeft())
201
+ return "over_budget";
202
+ const rec = await readRun(row.id);
203
+ if (deps.isLiveOnThisReplica(row.id)) {
204
+ tally.skippedLive++;
205
+ return "settled";
206
+ }
207
+ const nowRow = deps.bus.taskRow(row.id);
208
+ if (nowRow === undefined)
209
+ return "settled";
210
+ if (nowRow.cycleSeq !== row.cycleSeq)
211
+ return "settled";
212
+ if (rec === undefined) {
213
+ if (!idle(row.id))
214
+ return "settled";
215
+ return judgeSilentRow(row, () => orphanExit(row, "durable_row_absent"));
216
+ }
217
+ if (isTerminalRunStatus(rec.status)) {
218
+ retire(row, rec);
219
+ return "settled";
220
+ }
221
+ if (isParkedRunStatus(rec.status)) {
222
+ tally.skippedParked++;
223
+ return "settled";
224
+ }
225
+ orphanExit(row, runProvenElsewhere(rec, deps.instanceId) ? "durable_running_other_replica" : "durable_running_no_local_leg");
226
+ return "settled";
227
+ };
228
+ const judgeChild = async (row, parentId) => {
229
+ if (parentsOnBus.has(parentId))
230
+ return "settled";
231
+ if (!idle(row.id))
232
+ return "settled";
233
+ return judgeSilentRow(row, () => childExit(row, parentId));
234
+ };
151
235
  try {
152
236
  for (const [id, w] of [...watch]) {
153
237
  if (nowMs - w.at > FLEET_RECONCILE_WATCH_TTL_MS) {
@@ -171,53 +255,11 @@ export function createFleetReconciler(deps) {
171
255
  tally.retired++;
172
256
  }
173
257
  for (const row of rotate(snap.tasks, taskCursor)) {
174
- if (row.parentId !== undefined)
175
- continue;
176
- tally.scanned++;
177
- if (deps.isLiveOnThisReplica(row.id)) {
178
- tally.skippedLive++;
179
- continue;
180
- }
181
- if (!deps.runs)
182
- continue;
183
- if (!budgetLeft()) {
258
+ const step = row.parentId === undefined ? await judgeTopLevel(row) : await judgeChild(row, row.parentId);
259
+ if (step === "over_budget") {
184
260
  taskCursor = row.id;
185
261
  break;
186
262
  }
187
- const rec = await readRun(row.id);
188
- if (deps.isLiveOnThisReplica(row.id)) {
189
- tally.skippedLive++;
190
- continue;
191
- }
192
- const nowRow = deps.bus.taskRow(row.id);
193
- if (nowRow === undefined)
194
- continue;
195
- if (nowRow.cycleSeq !== row.cycleSeq)
196
- continue;
197
- if (rec === undefined) {
198
- if (idle(row.id))
199
- orphanExit(row, "durable_row_absent");
200
- continue;
201
- }
202
- if (isTerminalRunStatus(rec.status)) {
203
- retire(row, rec);
204
- continue;
205
- }
206
- if (isParkedRunStatus(rec.status)) {
207
- tally.skippedParked++;
208
- continue;
209
- }
210
- orphanExit(row, runProvenElsewhere(rec, deps.instanceId) ? "durable_running_other_replica" : "durable_running_no_local_leg");
211
- }
212
- for (const row of snap.tasks) {
213
- if (row.parentId === undefined || present.has(row.parentId))
214
- continue;
215
- if (!idle(row.id))
216
- continue;
217
- deps.bus.removeTask(row.id, { removeReason: "orphaned", ...(row.cycleSeq !== undefined ? { cycleSeq: row.cycleSeq } : {}) });
218
- lastFrameAt.delete(row.id);
219
- tally.orphaned++;
220
- deps.metrics?.inc("fleet_rows_reconciled_total", { disposition: "orphaned_child" });
221
263
  }
222
264
  if (wfGet) {
223
265
  for (const wf of rotate(snap.workflows, wfCursor)) {
@@ -227,19 +269,7 @@ export function createFleetReconciler(deps) {
227
269
  wfCursor = wf.id;
228
270
  break;
229
271
  }
230
- tally.reads++;
231
- let wfTimer;
232
- const run = await Promise.race([
233
- wfGet(wf.id),
234
- new Promise((_r, reject) => {
235
- wfTimer = setTimeout(() => reject(new ReadAborted("fleet reconcile workflow read over budget")), Math.max(0, deadline - now()));
236
- if (typeof wfTimer.unref === "function")
237
- wfTimer.unref();
238
- }),
239
- ]).finally(() => {
240
- if (wfTimer !== undefined)
241
- clearTimeout(wfTimer);
242
- });
272
+ const run = await budgetedRead(() => wfGet(wf.id));
243
273
  if (!run || !isTerminalWorkflowStatus(run.status))
244
274
  continue;
245
275
  if (wf.scope !== undefined && run.scope !== wf.scope)
@@ -4,7 +4,8 @@
4
4
  * - **可选分层(D3)**:仅当 center 已配置(sema-registry lane 的 baseUrl+token+worker)∧ `FLEET_ADVERTISE_ADDRESS`
5
5
  * 显式声明时才启动 —— 不配 center 的部署零行为变化。address 不自猜(k8s pod IP / docker alias / 127.0.0.1:port
6
6
  * 只有部署环境自己知道,故用显式 env)。
7
- * - **announce=注册=心跳同形状幂等 upsert**:boot 后 POST 一次 + 每 heartbeatMs 续;`ready = bootDone ∧ !draining`。
7
+ * - **announce=注册=心跳同形状幂等 upsert**:boot 后 POST 一次 + 每 heartbeatMs 续;`ready` 今 = `!draining`(实现只读
8
+ * `isDraining()`,announce 本身在 boot 之后才起;S-740 起与 /health 就绪同一只谓词 —— 车OC 6b 注释改真,实现归 S-740)。
8
9
  * - **draining 翻转即刻再 announce**(第一时间自摘流,比任何观测都快);退出前 DELETE(显式注销,幂等)。
9
10
  * - **usage 批报**:tracer 的 `brain.call` 已带 ALS principal —— FleetUsageAccumulator 按 principal×model
10
11
  * 累计,每窗口(默认 60s,半开 [windowStartMs, windowEndMs))POST /api/fleet/usage。幂等键
@@ -1,5 +1,6 @@
1
1
  import { adjudicateModelRoute, joinRequestUrl, resolveRouteCredential, routeKeyOf, routeRefusalText } from "@sema-agent/core";
2
- import { matchCatalogModel } from "../model-select.js";
2
+ import { matchCatalogModel, refusedCatalogEntry } from "../model-select.js";
3
+ import { modelUnavailableMessage } from "../model-entry-refusal.js";
3
4
  import { resolveOpenaiRouteEndpoint } from "../model-route-endpoint.js";
4
5
  import { buildDisplaySafeRefusal, redactSecrets } from "../trace/redact.js";
5
6
  export function buildHookFailureText(raw) {
@@ -32,21 +33,29 @@ export function createHookLlm(deps) {
32
33
  };
33
34
  const hookModelFor = (requested) => {
34
35
  const shape = (ref) => shapeFrom(ref, config.models[ref], config.models[ref]?.id ?? ref);
36
+ const unavailable = (field, ref) => {
37
+ if (config.models[ref] !== undefined)
38
+ return undefined;
39
+ const hit = refusedCatalogEntry(ref, config.modelRefusals);
40
+ return hit === undefined ? undefined : { ok: false, error: modelUnavailableMessage(field, ref, hit.name, hit.refusal) };
41
+ };
35
42
  if (requested === undefined) {
36
43
  const sum = config.roles.summarize;
37
44
  const roleModel = typeof sum === "string" ? sum : sum && typeof sum === "object" && "model" in sum && typeof sum.model === "string" ? sum.model : undefined;
38
45
  if (roleModel !== undefined)
39
- return config.models[roleModel] ? shape(roleModel) : shapeFrom(config.model.id, config.model, roleModel);
46
+ return unavailable("summarize role model", roleModel) ?? (config.models[roleModel] ? shape(roleModel) : shapeFrom(config.model.id, config.model, roleModel));
40
47
  return shapeFrom(config.model.id, config.model, config.model.id);
41
48
  }
42
49
  if (config.models[requested])
43
50
  return shape(requested);
44
- const byId = matchCatalogModel(requested, config.models);
51
+ if (Object.hasOwn(config.modelRefusals ?? {}, requested))
52
+ return unavailable("hook model", requested);
53
+ const byId = matchCatalogModel(requested, config.models, config.modelRefusals);
45
54
  if (byId !== undefined)
46
55
  return shape(byId);
47
56
  if (requested === config.model.id)
48
57
  return shapeFrom(requested, config.model, requested);
49
- return { ok: false, error: `hook model "${requested}" is not in this deployment's catalog` };
58
+ return unavailable("hook model", requested) ?? { ok: false, error: `hook model "${requested}" is not in this deployment's catalog` };
50
59
  };
51
60
  const judgeRoute = (pick, perModelKey, deploymentKey, declaredRoot) => {
52
61
  const credential = resolveRouteCredential({
@@ -9,7 +9,8 @@
9
9
  * `scrubServerSecrets` 的结果)。
10
10
  *
11
11
  * ## 规则(一句)
12
- * 语言服务器子进程的 env = {@link scrubServerSecrets}(process.env) —— 与模型可驱动的 host shell(`inheritEnv:"scrub"`)
12
+ * 语言服务器子进程的 env = {@link scrubServerSecrets}(process.env) ∪ 部署方点名放回的键(S-699,见下节;缺省空名单)——
13
+ * 剥除与模型可驱动的 host shell(`inheritEnv:"scrub"`)
13
14
  * 和 project-memory 的 git 子进程**同一只函数**:目录 `type:"secret"` 键按名剥 ∘ core 形状规则按形剥。只交 core 的默认
14
15
  * 会漏掉形状规则认不出的本仓凭据键(今天是 `OTEL_EXPORTER_OTLP_HEADERS`),那正是 S-684 当初的病形。
15
16
  * 剥掉了什么经既有观测面留痕(site = `server.lsp.spawn-env`,每 site×key 一次披露 + 逐次计数)—— core 那侧
@@ -19,11 +20,22 @@
19
20
  * env 在**构造时**取一次快照(boot / run-local 起腿时),而不是 core 默认那样每次 spawn 现取:`NodeLspManagerOptions.env`
20
21
  * 是一份静态记录。本仓的 `process.env` 是 boot 期输入(热配置走配置中心,不改 `process.env`),两者在本仓部署上同值。
21
22
  *
23
+ * ## 点名放行(S-699,`LSP_ENV_ALLOW`)
24
+ * 剥除会连语言服务器自己的真实依赖一起剥掉(`.npmrc` 的 `${NPM_TOKEN}` 插值、rust-analyzer `cargo metadata` 私有 registry 的
25
+ * `CARGO_REGISTRIES_<名>_TOKEN` —— 形状规则按名判,分不出「给语言服务器用的令牌」与「编排器的令牌」)。出路 = 部署方点名:
26
+ * env = {@link scrubServerSecrets}(process.env) 之上把 `config.lspEnvAllow` 点到的键经 {@link allowlistedEnv}(与 host shell
27
+ * `inheritEnv:[names]` 同一只谓词)从原 env 原样放回;没点到的照旧剥。名单由 `config.ts` 解析(形错拒启;
28
+ * `REQUIRE_PRINCIPAL=true` 下落空名单并 warn 一次),本函数只读 `config.lspEnvAllow`,不在这里再判一次姿态。
29
+ * 放回的键不是剥除 ⇒ 不进 `secret_env_scrubbed` 的键表与计数;剥掉的键经既有现场 `server.lsp.spawn-env` 一行 warn
30
+ * (带当前放行名单与出路句),这一行就是「剥了什么」的用户可见面 —— 语言服务器侧从不报。
31
+ *
22
32
  * ## 两个调用点
23
33
  * `boot/execution-env.ts`(服务的 host 车道)与 `run-local.ts`(纯本地 CLI)此前各写一遍 `new NodeLspManager({ log })`;
24
34
  * 现在都经本函数 —— 构造形只有一份,剥除规则也就只能有一份(源码对账钉在 `test/secret-env-scrub-disclosure.test.ts`)。
35
+ * 两处都交整份 `config`(类型只要 `lspEnvAllow` 一格),放行名单因此只有一个读点。
25
36
  */
26
37
  import { NodeLspManager } from "@sema-agent/core";
38
+ import type { ServiceConfig } from "./config-types.js";
27
39
  /** 构造宿主车道的本地语言服务器管理器(`create*`:返回活对象)。`log` = 管理器的观测钩(open/heal/degrade/evict 事件)。 */
28
- export declare function createHostLspManager(log: (event: string, fields: Record<string, unknown>) => void): NodeLspManager;
40
+ export declare function createHostLspManager(config: Pick<ServiceConfig, "lspEnvAllow">, log: (event: string, fields: Record<string, unknown>) => void): NodeLspManager;
29
41
  //# sourceMappingURL=host-lsp-manager.d.ts.map
@@ -1,12 +1,13 @@
1
1
  import { NodeLspManager } from "@sema-agent/core";
2
2
  import { recordSecretEnvScrub } from "./observability/secret-env-scrub.js";
3
- import { scrubServerSecrets } from "./server-secret-env.js";
4
- export function createHostLspManager(log) {
5
- const findings = [];
6
- const scrubbed = scrubServerSecrets(process.env, findings);
7
- recordSecretEnvScrub("server.lsp.spawn-env", findings);
3
+ import { allowlistedEnv, scrubServerSecrets } from "./server-secret-env.js";
4
+ export function createHostLspManager(config, log) {
5
+ const scrubFindings = [];
6
+ const composed = { ...scrubServerSecrets(process.env, scrubFindings), ...allowlistedEnv(config.lspEnvAllow, process.env) };
7
+ const findings = scrubFindings.filter((f) => composed[f.key] === undefined);
8
+ recordSecretEnvScrub("server.lsp.spawn-env", findings, config.lspEnvAllow);
8
9
  const env = {};
9
- for (const [k, v] of Object.entries(scrubbed))
10
+ for (const [k, v] of Object.entries(composed))
10
11
  if (v !== undefined)
11
12
  env[k] = v;
12
13
  return new NodeLspManager({ log, env });
@@ -1,5 +1,6 @@
1
1
  import { isThinkingLevel, expandTiers } from "@sema-agent/core";
2
- import { isModelAllowlisted, matchCatalogModel } from "../model-select.js";
2
+ import { isModelAllowlisted, matchCatalogModel, refusedCatalogEntry, selectableCatalog } from "../model-select.js";
3
+ import { modelUnavailableMessage } from "../model-entry-refusal.js";
3
4
  import { HttpError } from "../security.js";
4
5
  import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_PERMISSION_RULES, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS, SETTINGS_PERMISSION_MODE_WORDS, isSettingsPermissionMode, parseTaskSettings, skillListingBudgetFractionOk, skillListingMaxDescOk, taskSettingsKeyIssue } from "../task-settings.js";
5
6
  import { taskBodyKeyIssue } from "../request-key-closure.js";
@@ -39,7 +40,15 @@ const MAX_IMAGES_PER_REQUEST = 20;
39
40
  const MAX_IMAGE_BASE64_BYTES = 6 * 1024 * 1024;
40
41
  function mentionableNames(config, catalog) {
41
42
  const allowlist = config.atModelAllowlist ?? [];
42
- return Object.keys(catalog).filter((n) => n !== "default" && isModelAllowlisted(n, catalog, allowlist));
43
+ return Object.keys(catalog).filter((n) => n !== "default" && isModelAllowlisted(n, catalog, allowlist, config.modelRefusals));
44
+ }
45
+ function refuseUnavailableModel(res, config, field, ref) {
46
+ const hit = refusedCatalogEntry(ref, config.modelRefusals);
47
+ if (hit === undefined)
48
+ return false;
49
+ const message = modelUnavailableMessage(field, ref.slice(0, 120), hit.name, hit.refusal);
50
+ sendError(res, 400, "request.model_unavailable", message);
51
+ return true;
43
52
  }
44
53
  export function createAdmission(ctx) {
45
54
  const { deps, readJson, runnerFor } = ctx;
@@ -419,8 +428,10 @@ export function createAdmission(ctx) {
419
428
  return null;
420
429
  }
421
430
  const agentsBare = deps.config.models ?? {};
422
- const agentsCatalog = expandTiers(agentsBare, deps.config.tiers ?? {}) ?? agentsBare;
431
+ const agentsCatalog = selectableCatalog(agentsBare, deps.config.tiers, deps.config.modelRefusals);
423
432
  const um = unknownTaskAgentModelRef(body.agents, agentsCatalog);
433
+ if (um !== undefined && refuseUnavailableModel(res, deps.config, `agents[${um.index}].model`, um.ref))
434
+ return null;
424
435
  if (um !== undefined) {
425
436
  sendError(res, 400, "request.unknown_reference", `unknown agents[${um.index}].model "${um.ref.slice(0, 120)}" — not in the configured catalog (a catalog name or tier word)`, { available: mentionableNames(deps.config, agentsCatalog) });
426
437
  return null;
@@ -494,8 +505,10 @@ export function createAdmission(ctx) {
494
505
  return null;
495
506
  }
496
507
  const bare = deps.config.models ?? {};
497
- const catalog = expandTiers(bare, deps.config.tiers ?? {}) ?? bare;
498
- if (matchCatalogModel(body.model, catalog) === undefined) {
508
+ const catalog = selectableCatalog(bare, deps.config.tiers, deps.config.modelRefusals);
509
+ if (matchCatalogModel(body.model, catalog, deps.config.modelRefusals) === undefined) {
510
+ if (refuseUnavailableModel(res, deps.config, "model", body.model))
511
+ return null;
499
512
  sendError(res, 400, "request.unknown_reference", `unknown model "${body.model.slice(0, 120)}" — not in the configured catalog (name, tier word, or id)`, { available: mentionableNames(deps.config, catalog) });
500
513
  return null;
501
514
  }
@@ -506,8 +519,10 @@ export function createAdmission(ctx) {
506
519
  return null;
507
520
  }
508
521
  const bare = deps.config.models ?? {};
509
- const catalog = expandTiers(bare, deps.config.tiers ?? {}) ?? bare;
510
- if (matchCatalogModel(body.compactionModel, catalog) === undefined) {
522
+ const catalog = selectableCatalog(bare, deps.config.tiers, deps.config.modelRefusals);
523
+ if (matchCatalogModel(body.compactionModel, catalog, deps.config.modelRefusals) === undefined) {
524
+ if (refuseUnavailableModel(res, deps.config, "compactionModel", body.compactionModel))
525
+ return null;
511
526
  sendError(res, 400, "request.unknown_reference", `unknown compactionModel "${body.compactionModel.slice(0, 120)}" — not in the configured catalog (name, tier word, or id)`, { available: mentionableNames(deps.config, catalog) });
512
527
  return null;
513
528
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * core 7.31.1 #1094 `memory.target_outside_store` 的 **operator 面 HTTP 映射** —— 唯一一处字面量(判据在
3
+ * `src/memory-store-refusal.ts`)。挂在每一个触 store 树的 operator 记忆口的已分类收尾上:
4
+ * `routes/memory-compliance.ts`(出处 / 抹除)· `routes/memory-origin.ts`(外源列表 / 清标;**不含**只读控制面的清标账列表 ——
5
+ * core 的 wire 表逐字:只碰控制面的面不判,给它挂一条永远收不到的码路径正是附录 A 活触发门要防的形)·
6
+ * `routes/memory-bundle.ts`(导出 / 导入)。会话所有者的自助抹除口**不走这里**(`routes/sessions.ts`:同码、固定文案,见那里的注)。
7
+ * `POST /v1/admin/memory/consolidation/run` 也不走这里:那口把 core 的**任何**有码拒一律收成 409 `memory.consolidation_refused`
8
+ * (码进文案,附录 A 该行),本码已在那条臂上,不再给它第二个码。
9
+ *
10
+ * ## 为什么 409
11
+ * core 的读法:operator 要处置的拒(去掉 store 里那只通到外面的目录链),不是可重试的 IO 故障,也不是对调用方的裁决。与
12
+ * `memory.erasure_census_incomplete` 同一类(**当前 store 状态**下这次做不了;状态修好后重发即改判)⇒ 409,不 500(服务器没坏)、
13
+ * 不 4xx 请求族(请求没写错)。抹除面:链先于请求在场 ⇒ core 在提交点之前拒,零删除、零 journal(真 core 格钉在
14
+ * `test/memory-compliance-http.test.ts`);链在提交点之后才换上 ⇒ 已删的几条算数、journal 留盘 —— core 原句会带
15
+ * `after the commit point; the journal is kept`,本层原样转发、不嗅它分类(codex 一审 C1)。
16
+ *
17
+ * ## 文案原样
18
+ * core 的诊断句点名动作、路径与解析到哪 —— 那是运维找到那只链的唯一材料,与本族其它 core 码同一条「码与文案都不改写」纪律。
19
+ * 只对 operator 面这么做:这几口都过了 operator 门,宿主路径对这个受众不是泄露。
20
+ */
21
+ import type { ServerResponse } from "node:http";
22
+ /** `err` 是 `memory.target_outside_store` ⇒ 409 带码 + core 原句,返回 true(已应答);否则 false,一个字节都不写。 */
23
+ export declare function sentMemoryStoreRefusal(res: ServerResponse, err: unknown): boolean;
24
+ //# sourceMappingURL=memory-store-refusal-reply.d.ts.map
@@ -0,0 +1,10 @@
1
+ import { targetOutsideStoreOf } from "../memory-store-refusal.js";
2
+ import { sendError } from "./send.js";
3
+ export function sentMemoryStoreRefusal(res, err) {
4
+ const refusal = targetOutsideStoreOf(err);
5
+ if (refusal === undefined)
6
+ return false;
7
+ sendError(res, 409, "memory.target_outside_store", refusal.message);
8
+ return true;
9
+ }
10
+ //# sourceMappingURL=memory-store-refusal-reply.js.map
@@ -288,6 +288,7 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
288
288
  ...(s.deadline !== undefined ? { deadline: s.deadline } : {}),
289
289
  ...(s.hasBidiControls === true ? { hasBidiControls: true } : {}),
290
290
  ...(s.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
291
+ ...(s.mandated === true ? { mandated: true } : {}),
291
292
  ...(s.denialLimitFallback !== undefined ? { denialLimitFallback: s.denialLimitFallback } : {}),
292
293
  ...(s.origin !== undefined ? { origin: s.origin } : {}),
293
294
  ...(s.ruleStoreUnreadable !== undefined ? { ruleStoreUnreadable: s.ruleStoreUnreadable } : {}),
@@ -1,5 +1,5 @@
1
1
  import { expandTiers, reasoningEffortLevelsOf, routePairingStatus } from "@sema-agent/core";
2
- import { isModelAllowlisted } from "../../model-select.js";
2
+ import { isModelAllowlisted, selectableCatalog } from "../../model-select.js";
3
3
  import { resolveModelApiKey } from "../../key-resolver.js";
4
4
  import { isSealedKeyPoison } from "../../sealed-key.js";
5
5
  import { cwdHonored, deviceCwdHonored } from "../../task-cwd.js";
@@ -228,7 +228,7 @@ export async function handleCapabilities(req, res, match, ctx) {
228
228
  return;
229
229
  }
230
230
  case "models": {
231
- const atCatalog = expandTiers(deps.config.models, deps.config.tiers ?? {}) ?? deps.config.models;
231
+ const atCatalog = selectableCatalog(deps.config.models, deps.config.tiers, deps.config.modelRefusals);
232
232
  const atAllowlist = deps.config.atModelAllowlist ?? [];
233
233
  const models = Object.entries(deps.config.models)
234
234
  .filter(([name]) => name !== "default")
@@ -242,7 +242,7 @@ export async function handleCapabilities(req, res, match, ctx) {
242
242
  ...(m.maxTokens ? { maxOutputTokens: m.maxTokens } : {}),
243
243
  ...((levels) => (levels !== undefined ? { supportedEffortLevels: [...levels] } : {}))(reasoningEffortLevelsOf(m)),
244
244
  ...(m.compat !== undefined ? { compat: m.compat } : {}),
245
- ...(atAllowlist.length > 0 ? { atMentionable: isModelAllowlisted(name, atCatalog, atAllowlist) } : {}),
245
+ ...(atAllowlist.length > 0 ? { atMentionable: isModelAllowlisted(name, atCatalog, atAllowlist, deps.config.modelRefusals) } : {}),
246
246
  routePairing: routePairingOf(deps, name, m),
247
247
  }));
248
248
  const nonAlias = Object.entries(deps.config.models).filter(([n]) => n !== "default");
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { sendJson, sendError } from "../send.js";
3
+ import { sentMemoryStoreRefusal } from "../memory-store-refusal-reply.js";
3
4
  import { buildOriginFencedBundle, readOriginAware } from "../../memory-origin-fence.js";
4
5
  import { gatedPrincipal, explicitOperatorOk, sendPrincipalRefusal } from "../principal-gate.js";
5
6
  import { assertNever } from "../route-ctx.js";
@@ -35,6 +36,8 @@ function sentClassifiedFailure(res, ctx, face, err) {
35
36
  const code = codeOf(err);
36
37
  if (code !== undefined && sentCoreCode(res, code, err instanceof Error ? err.message : String(err)))
37
38
  return true;
39
+ if (sentMemoryStoreRefusal(res, err))
40
+ return true;
38
41
  ctx.deps.logger?.warn?.(`memory_bundle_${face}_failed`, { err: String(err) });
39
42
  return false;
40
43
  }
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { ControlPlaneCorruptError } from "@sema-agent/core";
3
3
  import { erasureInputSmugglesPrototypeKey } from "../../memory-operator-faces.js";
4
- import { MemoryLayerUnwritableError } from "../../memory-layer-preflight.js";
4
+ import { sentMemoryStoreRefusal } from "../memory-store-refusal-reply.js";
5
5
  import { sendJson, sendError } from "../send.js";
6
6
  import { gatedPrincipal, explicitOperatorOk, sendPrincipalRefusal } from "../principal-gate.js";
7
7
  import { assertNever } from "../route-ctx.js";
@@ -36,13 +36,11 @@ function codeOf(err) {
36
36
  return typeof code === "string" ? code : undefined;
37
37
  }
38
38
  function sentClassifiedFailure(res, ctx, face, err) {
39
- if (face === "erase" && err instanceof MemoryLayerUnwritableError) {
40
- sendError(res, 409, "memory.layer_unwritable", `memory layer directories are not writable on disk: ${err.dirList} — refused before the engine was called: nothing was written and no journal was left. Restore the owner write bit with \`chmod u+w ${err.chmodTargets}\` and retry. This is a known issue of the memory engine's mount: it leaves a layer that a session reads but does not write read-only on disk (directories 0555, entries 0444) and does not restore it, so a later project session can lock it again`);
41
- return true;
42
- }
43
39
  const coreCode = codeOf(err);
44
40
  if (coreCode !== undefined && sentCoreCode(res, coreCode, err instanceof Error ? err.message : String(err)))
45
41
  return true;
42
+ if (sentMemoryStoreRefusal(res, err))
43
+ return true;
46
44
  if (err instanceof ControlPlaneCorruptError) {
47
45
  sendError(res, 500, "internal.memory_control_plane_corrupt", err.message);
48
46
  return true;
@@ -1,8 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { ControlPlaneCorruptError } from "@sema-agent/core";
3
3
  import { sendJson, sendError } from "../send.js";
4
+ import { sentMemoryStoreRefusal } from "../memory-store-refusal-reply.js";
4
5
  import { buildOriginFence, readOriginAware } from "../../memory-origin-fence.js";
5
- import { MemoryLayerUnwritableError } from "../../memory-layer-preflight.js";
6
6
  import { gatedPrincipal, explicitOperatorOk, sendPrincipalRefusal } from "../principal-gate.js";
7
7
  import { assertNever } from "../route-ctx.js";
8
8
  export const MEMORY_ORIGIN_EXTERNAL_PATH = "/v1/memory/origin/external";
@@ -48,15 +48,13 @@ function codeOf(err) {
48
48
  return typeof code === "string" ? code : undefined;
49
49
  }
50
50
  function sentClassifiedFailure(res, ctx, face, err) {
51
- if (face === "clear" && err instanceof MemoryLayerUnwritableError) {
52
- sendError(res, 409, "memory.layer_unwritable", `memory layer directories are not writable on disk: ${err.dirList} — refused before the engine was called: nothing was written and no journal was left. Restore the owner write bit with \`chmod u+w ${err.chmodTargets}\` and retry. This is a known issue of the memory engine's mount: it leaves a layer that a session reads but does not write read-only on disk (directories 0555, entries 0444) and does not restore it, so a later project session can lock it again`);
53
- return true;
54
- }
55
51
  if (face === "clear") {
56
52
  const coreCode = codeOf(err);
57
53
  if (coreCode !== undefined && sentClearCode(res, coreCode, err instanceof Error ? err.message : String(err)))
58
54
  return true;
59
55
  }
56
+ if (face !== "clearances" && sentMemoryStoreRefusal(res, err))
57
+ return true;
60
58
  if (err instanceof ControlPlaneCorruptError) {
61
59
  sendError(res, 500, "internal.memory_control_plane_corrupt", err.message);
62
60
  return true;
@@ -4,12 +4,12 @@ import { isUuidV7, isUuidShape, legacyUnownedSessionOk } from "../../security.js
4
4
  import { windowMessages, truncateMessageBlobs } from "../../audit.js";
5
5
  import { redactSecrets } from "../../trace/redact.js";
6
6
  import { recordFailOpen } from "../../observability/fail-open.js";
7
+ import { targetOutsideStoreOf } from "../../memory-store-refusal.js";
7
8
  import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_PERMISSION_RULES, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS } from "../../task-settings.js";
8
9
  import { readSessionPermissionRules, describeSessionPermissionRulesProblem } from "../../session-policy-wire.js";
9
10
  import { sendJson, sendError, sendSessionPolicyError, sseHeaders } from "../send.js";
10
11
  import { gatedPrincipal, explicitOperatorOk, runOwnerReadable, sendPrincipalRefusal } from "../principal-gate.js";
11
12
  import { assertNever } from "../route-ctx.js";
12
- import { MemoryLayerUnwritableError } from "../../memory-layer-preflight.js";
13
13
  const MCP_STATUS_TTL_MS = 5_000;
14
14
  const MCP_MATERIALIZE_TIMEOUT_MS = 10_000;
15
15
  const A2A_STATUS_TTL_MS = 5_000;
@@ -500,16 +500,17 @@ export async function handleSessions(req, res, match, ctx) {
500
500
  sendJson(res, 200, await faces.erase({ requestId: raw.requestId, select: { sessionId }, ...(raw.allowUnevidenced !== undefined ? { allowUnevidenced: raw.allowUnevidenced } : {}) }));
501
501
  }
502
502
  catch (err) {
503
- if (err instanceof MemoryLayerUnwritableError) {
504
- deps.logger?.warn?.("session_memory_erase_layer_unwritable", { sessionId, dirs: err.dirs });
505
- sendError(res, 409, "memory.layer_unwritable", "memory erasure refused: a memory layer directory on this server is not writable on disk, so the erase was stopped before anything was written (nothing was deleted, no journal was left). Ask whoever operates this deployment to restore the owner write bit on the memory layer directories (a known issue of the memory engine's mount), then retry with the same requestId");
506
- return;
507
- }
508
503
  const code = err.code;
509
504
  if (code === "config.memory_erasure_request") {
510
505
  sendError(res, 400, "config.memory_erasure_request", err instanceof Error ? err.message : "invalid memory erasure request");
511
506
  return;
512
507
  }
508
+ const outside = targetOutsideStoreOf(err);
509
+ if (outside !== undefined) {
510
+ deps.logger?.warn?.("session_memory_erase_refused", { sessionId, err: outside.message });
511
+ sendError(res, 409, "memory.target_outside_store", "session memory erasure refused — a directory in this deployment's memory store leads outside it (a link), so this erasure did not complete (a refusal before its commit point changed nothing; one after it keeps the deletions already made and a journal that finishes the rest once the link is removed); ask the operator to remove the link, then resend with the same requestId");
512
+ return;
513
+ }
513
514
  deps.logger?.warn?.("session_memory_erase_failed", { sessionId, err: String(err) });
514
515
  sendError(res, 500, "internal.error", "session memory erasure failed");
515
516
  }