@sema-agent/server 7.80.2 → 7.82.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 (51) hide show
  1. package/USAGE.md +40 -4
  2. package/dist/approval-ask-audit-store.d.ts +13 -7
  3. package/dist/boot/memory-consolidation.d.ts +38 -0
  4. package/dist/boot/memory-consolidation.js +14 -0
  5. package/dist/boot/reapers.js +1 -2
  6. package/dist/boot/runner-deps.d.ts +11 -0
  7. package/dist/boot/runner-deps.js +15 -0
  8. package/dist/boot/stage-06-runners.js +13 -8
  9. package/dist/config-catalog.js +1 -0
  10. package/dist/config-types.d.ts +26 -0
  11. package/dist/config.js +9 -1
  12. package/dist/http/routes/diagnostics.d.ts +3 -2
  13. package/dist/observability/fail-open.d.ts +8 -0
  14. package/dist/observability/fail-open.js +8 -0
  15. package/dist/observability/metrics.js +1 -0
  16. package/dist/plugins/approval-ask-store-file.d.ts +171 -0
  17. package/dist/plugins/approval-ask-store-file.js +258 -0
  18. package/dist/plugins/approval-ask-store-memory.d.ts +60 -2
  19. package/dist/plugins/approval-ask-store-memory.js +30 -12
  20. package/dist/plugins/checkpoint-store-sql.d.ts +22 -0
  21. package/dist/plugins/checkpoint-store-sql.js +1 -1
  22. package/dist/plugins/file-run-store.d.ts +6 -2
  23. package/dist/plugins/file-run-store.js +2 -1
  24. package/dist/plugins/local-checkpoint-store.d.ts +29 -1
  25. package/dist/plugins/local-checkpoint-store.js +24 -1
  26. package/dist/plugins/posix-shell-fs.d.ts +2 -1
  27. package/dist/plugins/posix-shell-fs.js +4 -1
  28. package/dist/plugins/remote-env-adb.d.ts +5 -1
  29. package/dist/plugins/remote-env-adb.js +3 -0
  30. package/dist/plugins/remote-env-e2b.d.ts +8 -1
  31. package/dist/plugins/remote-env-e2b.js +4 -1
  32. package/dist/plugins/remote-env-host.d.ts +15 -1
  33. package/dist/plugins/remote-env-host.js +37 -1
  34. package/dist/plugins/remote-env-k8s.d.ts +5 -1
  35. package/dist/plugins/remote-env-k8s.js +3 -0
  36. package/dist/plugins/remote-env-local-docker.d.ts +5 -1
  37. package/dist/plugins/remote-env-local-docker.js +3 -0
  38. package/dist/plugins/remote-env-ssh.d.ts +5 -1
  39. package/dist/plugins/remote-env-ssh.js +3 -0
  40. package/dist/plugins/remote-shell.d.ts +78 -1
  41. package/dist/plugins/remote-shell.js +35 -0
  42. package/dist/plugins/store-backend.d.ts +9 -8
  43. package/dist/plugins/store-backend.js +7 -4
  44. package/dist/plugins/store-contracts.d.ts +20 -0
  45. package/dist/plugins/store-contracts.js +2 -0
  46. package/dist/tool-approval.d.ts +11 -6
  47. package/dist/tool-approval.js +0 -2
  48. package/dist/trace/core-keyset-guard.d.ts +6 -1
  49. package/dist/trace/project.d.ts +18 -0
  50. package/dist/trace/project.js +11 -0
  51. package/package.json +2 -2
@@ -348,6 +348,9 @@ export class RemoteAdbExecutionEnv {
348
348
  async readLink(p, abortSignal) {
349
349
  return this.posixFs.readLink(p, abortSignal);
350
350
  }
351
+ async canonicalChain(p, abortSignal) {
352
+ return this.posixFs.canonicalChain(p, abortSignal);
353
+ }
351
354
  async canonicalPath(p, abortSignal) {
352
355
  return this.posixFs.canonicalPath(p, abortSignal);
353
356
  }
@@ -20,7 +20,7 @@
20
20
  * iterator MAY throw `RemoteExecutionError` mid-stream before an `exit` chunk (seam contract, OutputChunk).
21
21
  */
22
22
  import { Sandbox, type SandboxOpts, type SandboxConnectOpts } from "e2b";
23
- import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type ExecutionEnvFactory, type VmLifecycleOptions, type BackgroundShellCapability, type BackgroundShellId, type BackgroundShellError, type BackgroundSpawnOptions, type BackgroundPoll } from "@sema-agent/core";
23
+ import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type SymlinkChain, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type ExecutionEnvFactory, type VmLifecycleOptions, type BackgroundShellCapability, type BackgroundShellId, type BackgroundShellError, type BackgroundSpawnOptions, type BackgroundPoll } from "@sema-agent/core";
24
24
  /** Options accepted by `ExecutionEnv.exec` (core does not re-export the type; derive it from the seam). */
25
25
  type ExecOpts = Parameters<ExecutionEnv["exec"]>[1];
26
26
  /** Minimal optional observability hooks (kept structural so the adapter stays standalone-testable). */
@@ -250,6 +250,13 @@ export declare class RemoteContainerExecutionEnv implements RemoteExecutionEnv,
250
250
  * 缺它 ⇒ 合法的悬空链接让整个工具调用失败。必须返回**原始链接目标**(`readlink` 不带 `-f`)。
251
251
  * 本腿的文件面也走 `exec`(与 canonicalPath 同通道),所以实现方式与 shell 腿一致。 */
252
252
  readLink(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
253
+ /** S-362 / core 7.21.0 [ref] —— **实现**(表态:本腿实现 `canonicalChain`)。本腿的文件面走原生 SDK 的
254
+ * `exec`([ref] B6 的既有裁决:host 与 e2b 是原生 API 腿,**不并**进 `posix-shell-fs` 基座),但
255
+ * 「链怎么走、怎么读」与四条 shell 腿**是同一份**:脚本与读法的单一属主在 `remote-shell.ts`,这里只多一行
256
+ * 传输。一次往返、上限 64、只锚不折、遇环即止;`ok:false` 由 core 读作「这一次没有跳」,永不是拒绝。
257
+ * 🔴 exit≠0 归 `unknown` 而不是 `not_found`(与本腿 `readLink` 那一格刻意分家,同 `posix-shell-fs` 的理由):
258
+ * 「叶不是软链」与「叶不存在」在这条命令上都是 exit 0 + 空 stdout,所以 exit≠0 一定不是「文件不在」。 */
259
+ canonicalChain(p: string, abortSignal?: AbortSignal): Promise<Result<SymlinkChain, FileError>>;
253
260
  canonicalPath(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
254
261
  exists(p: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
255
262
  createDir(p: string, options?: {
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import { Sandbox, CommandExitError, FileType } from "e2b";
3
3
  import { FileError, ExecutionError, RemoteExecutionError, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
4
4
  import { fileErrorFromExec } from "./remote-env-file-error.js";
5
- import { numEnvOr, shellQuote } from "./remote-shell.js";
5
+ import { numEnvOr, runSymlinkChain, shellQuote } from "./remote-shell.js";
6
6
  import { BackgroundShellManager } from "./background-shell-support.js";
7
7
  const PROVIDER = "e2b";
8
8
  const DEFAULT_MOUNT_PATH = "/home/user";
@@ -692,6 +692,9 @@ export class RemoteContainerExecutionEnv {
692
692
  return { ok: false, error: new FileError("not_found", r.value.stderr.trim() || `readlink failed for ${abs}`, abs) };
693
693
  return ok(r.value.stdout.trim());
694
694
  }
695
+ async canonicalChain(p, abortSignal) {
696
+ return runSymlinkChain(this.resolve(p), (cmd) => this.exec(cmd, { abortSignal }));
697
+ }
695
698
  async canonicalPath(p, abortSignal) {
696
699
  const abs = this.resolve(p);
697
700
  const r = await this.exec(`realpath -- ${shellQuote(abs)}`, { abortSignal });
@@ -1,4 +1,4 @@
1
- import { FileError, ExecutionError, RemoteExecutionError, SchedulerError, type SchedulerCapability, type ScheduledIntent, type SchedulerContext, type ScheduledTaskId, type ScheduledTaskSummary, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory, BackgroundShellError, type BackgroundShellCapability, type BackgroundShellId, type BackgroundPoll, type BackgroundSpawnOptions } from "@sema-agent/core";
1
+ import { FileError, ExecutionError, RemoteExecutionError, SchedulerError, type SchedulerCapability, type ScheduledIntent, type SchedulerContext, type ScheduledTaskId, type ScheduledTaskSummary, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type SymlinkChain, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory, BackgroundShellError, type BackgroundShellCapability, type BackgroundShellId, type BackgroundPoll, type BackgroundSpawnOptions } from "@sema-agent/core";
2
2
  /** Options accepted by `ExecutionEnv.exec` (core does not re-export the type; derive it from the seam). */
3
3
  type ExecOpts = Parameters<ExecutionEnv["exec"]>[1];
4
4
  /** Construction config for {@link RemoteHostExecutionEnv}. */
@@ -216,6 +216,20 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
216
216
  listDir(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
217
217
  canonicalPath(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
218
218
  readLink(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
219
+ /**
220
+ * S-362 / core 7.21.0 [ref] —— **实现**(表态:本腿实现 `canonicalChain`)。host 腿是 node:fs 腿,所以按
221
+ * 提货单 [ref] @server 五适配器段的第一行,与 core 的 `NodeExecutionEnv.canonicalChain` **同形**:对整条
222
+ * 拼法 `readlink`,再对每一跳 `readlink`,**上限 64**、**只锚不折**、**遇环即止**;win32 宿主上跳到共享
223
+ * (UNC)时**记下该跳并停止探测**(不碰网络,CC `vr` 对网络拼法只给拼法)。
224
+ *
225
+ * 🔴 **不复用 core 的实现而是同形重写**:`NodeExecutionEnv` 是 core 的内部类,包根不导出它的这只方法;
226
+ * 而本腿的每一次读都必须先过 {@link fsReady}(2026-07-25 全族统一:拆除之后只读面不得与写面自相矛盾),
227
+ * 那道门在 core 的实现里不存在。同形的判据钉在 `test/remote-env-canonical-chain.test.ts`。
228
+ *
229
+ * 链尽的三种 errno(`EINVAL` 不是软链 / `ENOENT` 不存在 / `ENOTDIR`)⇒ `ok` + 到此为止的跳;其余 errno
230
+ * (`EACCES` …)⇒ `ok:false`(core 读作「这一次没有跳」,**永不是拒绝**)。非链接的叶答 `[]`。
231
+ */
232
+ canonicalChain(p: string, abortSignal?: AbortSignal): Promise<Result<SymlinkChain, FileError>>;
219
233
  exists(p: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
220
234
  createDir(p: string, options?: {
221
235
  recursive?: boolean;
@@ -5,7 +5,7 @@ import fs from "node:fs/promises";
5
5
  import { randomBytes } from "node:crypto";
6
6
  import { openSync, closeSync, readSync, statSync, truncateSync, unlinkSync, mkdirSync, rmdirSync, mkdtempSync, existsSync, realpathSync, accessSync, constants as FS_CONST } from "node:fs";
7
7
  import { StringDecoder } from "node:string_decoder";
8
- import { armPipeDestroyGrace, numEnvOr } from "./remote-shell.js";
8
+ import { armPipeDestroyGrace, numEnvOr, SYMLINK_CHAIN_MAX_HOPS } from "./remote-shell.js";
9
9
  import { hostBackgroundShellEnabled, hostExecSpoolEnabled } from "../config.js";
10
10
  import { resolveHostShell, hostShell, spawnGroupOptions, killTreeHard, killTreeSoft, collapseWin32EnvKeys } from "./host-platform.js";
11
11
  import { BackgroundShellManager, seedMemStream, feedMemStream, drainMemStream } from "./background-shell-support.js";
@@ -22,6 +22,7 @@ const HOST_BG_FILE_CAP = numEnvOr("HOST_BG_FILE_CAP", 64 * 1024 * 1024, HOST_BG_
22
22
  const HOST_BG_KILL_GRACE_MS = numEnvOr("HOST_BG_KILL_GRACE_MS", 1000, 0);
23
23
  const HOST_BG_MEM_CAP = numEnvOr("HOST_BG_MEM_CAP", 8 * 1024 * 1024, 64 * 1024);
24
24
  const ok = (value) => ({ ok: true, value });
25
+ const UNC_SPELLING = /^[\\/]{2}[^\\/]+[\\/]+[^\\/]/;
25
26
  const unsupported = (op) => ({
26
27
  ok: false,
27
28
  error: new RemoteExecutionError("unsupported", `${op} is not supported on a host target (the operator's own machine — no container boundary, not snapshotable; capabilities.suspendable=false)`),
@@ -1006,6 +1007,41 @@ export class RemoteHostExecutionEnv {
1006
1007
  return { ok: false, error: this.fileError(e, abs, abortSignal) };
1007
1008
  }
1008
1009
  }
1010
+ async canonicalChain(p, abortSignal) {
1011
+ const ready = await this.fsReady(p);
1012
+ if (!ready.ok)
1013
+ return ready;
1014
+ const hops = [];
1015
+ const seen = new Set();
1016
+ let cur = this.resolve(p);
1017
+ for (let i = 0; i < SYMLINK_CHAIN_MAX_HOPS; i += 1) {
1018
+ let target;
1019
+ try {
1020
+ target = await fs.readlink(cur);
1021
+ }
1022
+ catch (e) {
1023
+ const fe = this.fileError(e, cur, abortSignal);
1024
+ if (fe.code === "invalid" || fe.code === "not_found" || fe.code === "not_directory")
1025
+ break;
1026
+ return { ok: false, error: fe };
1027
+ }
1028
+ let next;
1029
+ if (path.isAbsolute(target))
1030
+ next = target;
1031
+ else {
1032
+ const dir = path.dirname(cur);
1033
+ next = dir.endsWith(path.sep) ? `${dir}${target}` : `${dir}${path.sep}${target}`;
1034
+ }
1035
+ if (seen.has(next))
1036
+ break;
1037
+ seen.add(next);
1038
+ hops.push(next);
1039
+ if (process.platform === "win32" && UNC_SPELLING.test(next))
1040
+ break;
1041
+ cur = next;
1042
+ }
1043
+ return ok({ hops });
1044
+ }
1009
1045
  async exists(p, abortSignal) {
1010
1046
  const ready = await this.fsReady(p);
1011
1047
  if (!ready.ok)
@@ -1,4 +1,4 @@
1
- import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory, type BackgroundShellCapability, type BackgroundShellId, type BackgroundShellError, type BackgroundSpawnOptions, type BackgroundPoll } from "@sema-agent/core";
1
+ import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type SymlinkChain, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory, type BackgroundShellCapability, type BackgroundShellId, type BackgroundShellError, type BackgroundSpawnOptions, type BackgroundPoll } from "@sema-agent/core";
2
2
  import type { PodSpecPatch } from "./k8s-exec-protocol.js";
3
3
  type ExecOpts = Parameters<ExecutionEnv["exec"]>[1];
4
4
  export { buildBgRunnerScript, buildBgLauncherScript, buildBgPollScript, buildBgKillScript, buildBgDisposeScript, buildDetachCapableExec, parseBgPollOutput, type ParsedBgPoll, } from "./k8s-bg-scripts.js";
@@ -310,6 +310,10 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
310
310
  fileInfo(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>>;
311
311
  listDir(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
312
312
  readLink(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
313
+ /** S-362 / core 7.21.0 [ref] —— **实现**(表态:本腿实现 `canonicalChain`)。一次往返里的一个 `readlink`
314
+ * 循环(上限 64、只锚不折、遇环即止),脚本与读法的单一属主在 `remote-shell.ts`,四条 shell 腿共用同一份。
315
+ * 缺席读法用不上(本腿在场);`ok:false` 由 core 读作「这一次没有跳」,永不是拒绝。 */
316
+ canonicalChain(p: string, abortSignal?: AbortSignal): Promise<Result<SymlinkChain, FileError>>;
313
317
  canonicalPath(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
314
318
  exists(p: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
315
319
  createDir(p: string, options?: {
@@ -919,6 +919,9 @@ export class RemoteK8sExecutionEnv {
919
919
  async readLink(p, abortSignal) {
920
920
  return this.posixFs.readLink(p, abortSignal);
921
921
  }
922
+ async canonicalChain(p, abortSignal) {
923
+ return this.posixFs.canonicalChain(p, abortSignal);
924
+ }
922
925
  async canonicalPath(p, abortSignal) {
923
926
  return this.posixFs.canonicalPath(p, abortSignal);
924
927
  }
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory } from "@sema-agent/core";
2
+ import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type SymlinkChain, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory } from "@sema-agent/core";
3
3
  /** Options accepted by `ExecutionEnv.exec` (core does not re-export the type; derive it from the seam). */
4
4
  type ExecOpts = Parameters<ExecutionEnv["exec"]>[1];
5
5
  /** Construction config for {@link RemoteLocalDockerExecutionEnv}. */
@@ -166,6 +166,10 @@ export declare class RemoteLocalDockerExecutionEnv implements RemoteExecutionEnv
166
166
  fileInfo(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>>;
167
167
  listDir(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
168
168
  readLink(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
169
+ /** S-362 / core 7.21.0 [ref] —— **实现**(表态:本腿实现 `canonicalChain`)。一次往返里的一个 `readlink`
170
+ * 循环(上限 64、只锚不折、遇环即止),脚本与读法的单一属主在 `remote-shell.ts`,四条 shell 腿共用同一份。
171
+ * 缺席读法用不上(本腿在场);`ok:false` 由 core 读作「这一次没有跳」,永不是拒绝。 */
172
+ canonicalChain(p: string, abortSignal?: AbortSignal): Promise<Result<SymlinkChain, FileError>>;
169
173
  canonicalPath(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
170
174
  exists(p: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
171
175
  createDir(p: string, options?: {
@@ -406,6 +406,9 @@ export class RemoteLocalDockerExecutionEnv {
406
406
  async readLink(p, abortSignal) {
407
407
  return this.posixFs.readLink(p, abortSignal);
408
408
  }
409
+ async canonicalChain(p, abortSignal) {
410
+ return this.posixFs.canonicalChain(p, abortSignal);
411
+ }
409
412
  async canonicalPath(p, abortSignal) {
410
413
  return this.posixFs.canonicalPath(p, abortSignal);
411
414
  }
@@ -1,5 +1,5 @@
1
1
  import { type SshHostKeyPolicy } from "../ssh-host-key.js";
2
- import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory } from "@sema-agent/core";
2
+ import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type SymlinkChain, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory } from "@sema-agent/core";
3
3
  type ExecOpts = Parameters<ExecutionEnv["exec"]>[1];
4
4
  export interface SshEnvConfig {
5
5
  host: string;
@@ -171,6 +171,10 @@ export declare class RemoteSshExecutionEnv implements RemoteExecutionEnv {
171
171
  private posixFsInst?;
172
172
  private get posixFs();
173
173
  readLink(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
174
+ /** S-362 / core 7.21.0 [ref] —— **实现**(表态:本腿实现 `canonicalChain`)。一次往返里的一个 `readlink`
175
+ * 循环(上限 64、只锚不折、遇环即止),脚本与读法的单一属主在 `remote-shell.ts`,四条 shell 腿共用同一份。
176
+ * 缺席读法用不上(本腿在场);`ok:false` 由 core 读作「这一次没有跳」,永不是拒绝。 */
177
+ canonicalChain(p: string, abortSignal?: AbortSignal): Promise<Result<SymlinkChain, FileError>>;
174
178
  canonicalPath(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
175
179
  exists(p: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
176
180
  createDir(p: string, options?: {
@@ -548,6 +548,9 @@ export class RemoteSshExecutionEnv {
548
548
  async readLink(p, abortSignal) {
549
549
  return this.posixFs.readLink(p, abortSignal);
550
550
  }
551
+ async canonicalChain(p, abortSignal) {
552
+ return this.posixFs.canonicalChain(p, abortSignal);
553
+ }
551
554
  async canonicalPath(p, abortSignal) {
552
555
  return this.posixFs.canonicalPath(p, abortSignal);
553
556
  }
@@ -2,7 +2,7 @@
2
2
  * Shared shell/path helpers for the SSH + ADB `RemoteExecutionEnv` adapters. Kept in ONE place so the
3
3
  * security-sensitive quoting can't drift between adapters (council design/#4, #5).
4
4
  */
5
- import type { FileInfo } from "@sema-agent/core";
5
+ import type { ExecutionError, FileError, FileInfo, RemoteExecutionError, Result, SymlinkChain } from "@sema-agent/core";
6
6
  import type { ChildProcess } from "node:child_process";
7
7
  /**
8
8
  * D5 pipe-destroy grace (parity with core's vendored `EXEC_FORCE_SETTLE_GRACE_MS`, harness/env/nodejs.js):
@@ -32,4 +32,81 @@ export declare function drainNumEnvWarnings(): Array<{
32
32
  raw: string;
33
33
  }>;
34
34
  export declare function numEnvOr(name: string, def: number, min: number): number;
35
+ export declare const SYMLINK_CHAIN_MAX_HOPS = 64;
36
+ /**
37
+ * 一次往返走完叶 symlink 链的 POSIX sh 脚本(**纯数据**:返回一个命令串 ⇒ `build*`,CLAUDE.md 工厂律)。
38
+ *
39
+ * 🔴 `readlink` **缺失**必须与「不是软链」分开:两者在 shell 里都是 exit≠0,而 `|| break` 会把「工具不在」
40
+ * 静默读成「零跳」—— 那是安全轴上的静默兜底(链视图整条消失,判官少一个视图)。所以脚本**先探**一次
41
+ * `command -v`,缺失时打一句 `readlink: not found` 到 stderr 并 exit 127 —— 那句话正好命中
42
+ * {@link import("./remote-env-file-error.js").classifyFsStderr} 的**命令缺失护栏**(它把命令缺失归
43
+ * `unknown` 而不是 `not_found`),于是调用方拿到的是 `ok:false`(core 读作「这一次没有跳」,永不是拒绝)
44
+ * 而不是一个伪造的空链。
45
+ *
46
+ * 🔴 **整条命令包在一个 `{ …; }` 里**,理由是**调用方会给它加前缀**:四条腿的 `withCwdEnv` 都拼
47
+ * `cd <dir> && <命令>`(ssh `:760` / k8s `:1418` / adb `:578` / local-docker 同形)。这里连着犯过两次错,
48
+ * 两次都记下来,因为它们是**同一个坑的两面**:
49
+ * · 写成 `command -v … || { …; }`:`||` 与前缀的 `&&` 结成一条链(`cd X && command -v … || { … }`),
50
+ * **`cd` 失败时跑的是探测的失败臂** —— 报「readlink 不在」而真相是「那个目录没了」,运维去修一个没坏的东西;
51
+ * · 改成独立语句 `if ! …; fi; c=…; while …`:前缀的 `&&` 只短路**那一条 `if`**,后面的循环照跑 ——
52
+ * 于是「`cd` 失败 ∧ readlink 缺失」这一格里,`|| break` 把工具缺失吞成零跳,脚本 **exit 0 + 空 stdout**,
53
+ * 调用方拿到一条**伪造的空链**。这正是探测本来要挡的那个静默兜底([ref] 轴),换了个入口回来了。
54
+ * ⇒ 正解是**让整条命令只有一个退出状态**:包进 `{ …; }`,前缀的 `&&` 要么统治全部、要么全部不跑。
55
+ * `cd` 失败 ⇒ 退出码来自 `cd` 自己、stderr 是它自己的话 ⇒ `ok:false`(core 读作「这一次没有跳」),
56
+ * 诚实且指向真因;`cd` 成功 ⇒ 探测与循环照旧。`{ }` 不是子 shell,里面的 `exit 127` 照样结束整条命令。
57
+ *
58
+ * 词法细节(逐条对着 core 的 Node 实现写,两边同形):绝对目标直接接上;相对目标用参数展开
59
+ * `${c%/*}` 取**当前这条链接**所在目录(不调 `dirname` —— toybox 的 `dirname` 不认 `--`,且这里零外部命令
60
+ * 更省一次 fork),根目录那一格(`${c%/*}` 为空)补 `/`。**全程不折叠 `..`**。
61
+ */
62
+ export declare function buildSymlinkChainCommand(absPath: string): string;
63
+ /**
64
+ * {@link buildSymlinkChainCommand} 的 stdout → `SymlinkChain.hops`。
65
+ *
66
+ * 三件事**只在这里**做一次(四条 shell 腿 + e2b 共读):
67
+ * · **环即止** —— 出现重复跳时在**重复的那一跳之前**截断(CC `vr` 的 `d.has(u)` 同律)。
68
+ * 🔴 **一条如实登记的、与 core 参考实现的分歧(合并复审车GU 亲核两树,验真后**披露不修**)**:本族(六腿同形)
69
+ * 的 seen 表**从空开始**,而 core 的 `NodeExecutionEnv.canonicalChain` 用**被寻址的拼法**给它播种
70
+ * (`new Set([resolved])`)。于是**环绕回起点**的那一类链上两边差一跳:`a -> b`、`b -> a`,问 `a`,本族答
71
+ * `[b, a]`,core 答 `[b]`。**只在真有环时可观察**(无环链上 seen 永远碰不到起点,两边逐字节相同),而有环的
72
+ * 链上那次写本来就会以 `ELOOP` 失败。不修的两条理由:①方向是**多报一跳**(判官多一个视图 ⇒ 多问,从不
73
+ * 造成误放行),而改成与 core 同形是**摘掉**一个视图 —— 在 [ref] 轴上那是错的方向,要动得先有裁定;
74
+ * ②那一跳是 `b` **真的写下的**目标,契约句「不重复被寻址的拼法」两种读法都讲得通(core 读作「起点不入表」,
75
+ * 本族读作「每条链接写下的目标都入表」),属**待 core 裁**的一格,不是本仓单方面能定的。
76
+ * ⚠️ 故各腿头注里「与 core 同形 / 逐字节对得上」那句话的射程是**无环链**;有环的那一格以本段为准。
77
+ * 脚本侧不做:
78
+ * POSIX sh 里维护一张 seen 表要么靠内嵌换行的 `case` 模式(易碎),要么另起一个进程;放在这里则
79
+ * **一份实现**被四条腿与 e2b 共用,且与 host 腿的 Node 循环逐字节对得上。代价如实登记:链上真有环时,
80
+ * 远端在**那一次往返内**最多多跑到 64 次 `readlink`(有界,不是无界探测),而报出来的跳与 host 腿相同。
81
+ * · **上限 64** —— 脚本已经封顶,这里再切一次是对**传输层**的不信任(答多了不当真),不是第二份判据。
82
+ * · **逐行 trim** —— 与本族 `readLink` 的既有姿态**同一条**(基座头注:链接目标含换行是病态形,按 trim
83
+ * 处理,不假装支持)。残留如实写出来:目标拼法真带首尾空白时,trim 后的跳可能与真目标差几个字节 ——
84
+ * 方向上只会让一条跳**匹配不到**受保护行(少问,不是多放行),而终点判决(`canonicalPath`)完全不经本路。
85
+ */
86
+ export declare function symlinkChainHopsFromStdout(stdout: string): string[];
87
+ /** {@link runSymlinkChain} 的传输面 —— 各腿的 `exec` 收敛成的那一个形(与 `PosixShellFs` 基座的 `run` 同形)。 */
88
+ export type ShellChainRun = (cmd: string) => Promise<Result<{
89
+ stdout: string;
90
+ stderr: string;
91
+ exitCode: number;
92
+ }, ExecutionError | RemoteExecutionError>>;
93
+ /**
94
+ * **shell 形 `canonicalChain` 的全部半场,一处**(对抗复审 F4 [low],验真后按类修)。
95
+ *
96
+ * 此前脚本与读法收在这里,而「怎么把它们接到传输上、exit≠0 怎么归码」在**两处**各写一遍:
97
+ * `posix-shell-fs.ts`(四条 shell 腿)与 `remote-env-e2b.ts`(原生 SDK 腿)。两份五行代码语义相同、
98
+ * 错误归类却各写各的,于是「e2b 与四条 shell 腿答得一样」只靠一句注释担保 —— 复审的换靶变异实测:
99
+ * 把 e2b 那五行改成 `return ok({ hops: [] })`,**全部钉照绿**。收成一只函数之后,那条担保是结构事实:
100
+ * 两边调的是同一个函数,分叉无处可生;基座上那一整套行为钉同时就是 e2b 的钉。
101
+ *
102
+ * 归码(两腿共用同一条,不再各写各的):
103
+ * · 传输自己失败 ⇒ {@link fileErrorFromExec}(`aborted` 穿透,其余诚实降级 `unknown`);
104
+ * · exit≠0 ⇒ {@link classifyFsStderr} 且 fallback 取 **`unknown` 而不是 `not_found`** —— 这条命令上
105
+ * 「叶不是软链」与「叶不存在」**都是 exit 0 + 空 stdout**(脚本里的 `|| break`),所以任何 exit≠0
106
+ * 都**不是**「这个文件不在」,而是解析器自己出了事(工具缺失 / cwd 没了 / 传输坏 / shell 拒)。
107
+ * 把它折成 `not_found` 会让运维去找一条根本没丢的路径。
108
+ * ⚠️ 例外由 `classifyFsStderr` 自己认:`cd` 失败那一格的 stderr 里真带「No such file」,它按**那句话**
109
+ * 归 `not_found` —— 那时真的有一条路径没了(工作目录),所以那个码是对的。
110
+ */
111
+ export declare function runSymlinkChain(absPath: string, run: ShellChainRun): Promise<Result<SymlinkChain, FileError>>;
35
112
  //# sourceMappingURL=remote-shell.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { classifyFsStderr, fileErrorFromExec } from "./remote-env-file-error.js";
1
2
  export const EXEC_FORCE_SETTLE_GRACE_MS = 3000;
2
3
  export function armPipeDestroyGrace(child) {
3
4
  const t = setTimeout(() => {
@@ -36,4 +37,38 @@ export function numEnvOr(name, def, min) {
36
37
  }
37
38
  return Math.max(min, (Number.isFinite(n) ? n : 0) || def);
38
39
  }
40
+ export const SYMLINK_CHAIN_MAX_HOPS = 64;
41
+ export function buildSymlinkChainCommand(absPath) {
42
+ return (`{ if ! command -v readlink >/dev/null 2>&1; then printf 'readlink: not found\\n' >&2; exit 127; fi; ` +
43
+ `c=${shellQuote(absPath)}; n=0; ` +
44
+ `while [ "$n" -lt ${SYMLINK_CHAIN_MAX_HOPS} ]; do ` +
45
+ `t=$(readlink -- "$c") || break; ` +
46
+ `case "$t" in "") break ;; /*) c="$t" ;; ` +
47
+ `*) d="${"${c%/*}"}"; case "$d" in "") c="/$t" ;; */) c="$d$t" ;; *) c="$d/$t" ;; esac ;; esac; ` +
48
+ `printf '%s\\n' "$c"; n=$((n+1)); done; }`);
49
+ }
50
+ export function symlinkChainHopsFromStdout(stdout) {
51
+ const hops = [];
52
+ const seen = new Set();
53
+ for (const raw of stdout.split("\n")) {
54
+ const hop = raw.trim();
55
+ if (hop === "")
56
+ continue;
57
+ if (seen.has(hop))
58
+ break;
59
+ seen.add(hop);
60
+ hops.push(hop);
61
+ if (hops.length >= SYMLINK_CHAIN_MAX_HOPS)
62
+ break;
63
+ }
64
+ return hops;
65
+ }
66
+ export async function runSymlinkChain(absPath, run) {
67
+ const r = await run(buildSymlinkChainCommand(absPath));
68
+ if (!r.ok)
69
+ return { ok: false, error: fileErrorFromExec(r.error, absPath) };
70
+ if (r.value.exitCode !== 0)
71
+ return { ok: false, error: classifyFsStderr(r.value.stderr, absPath, "unknown") };
72
+ return { ok: true, value: { hops: symlinkChainHopsFromStdout(r.value.stdout) } };
73
+ }
39
74
  //# sourceMappingURL=remote-shell.js.map
@@ -154,15 +154,16 @@ export interface StoreBackend {
154
154
  * **不能**退化成进程内易失形:一次重启若把记录抹掉,JWT TTL 窗内的重放就复活了,而那正是本店要关的洞。 */
155
155
  approvalNonce(): ApprovalNonceStore;
156
156
  /** [ref]([ref] §3.0-§3.2)流内审批协议持久层:每个流内工具调用一行的 ask 状态机 + 批行。
157
- * REQUIRED on all backends — tidb/pg = SQL 双方言 twin;local = {@link InMemoryApprovalAskStore}。
157
+ * REQUIRED **且持久** on all backends — tidb/pg = SQL 双方言 twin;local = {@link FileApprovalAskStore}。
158
158
  *
159
- * 🔴 local 形的代价必须在这里说清楚(而不是留给读者自己发现):InMemory 的 STREAM_PENDING/DECIDED/
160
- * PARKING 全在两个进程内 Map 里 ⇒ **重启同时丢重放基准、丢已接受的决议、丢审计**——「一次真实的人类
161
- * 批准凭空消失」和「对账便利丢失」不是一个量级。因此协议在 local 车道**默认不上场**:能力面
162
- * (`streamApproval`)的判据带一条「ask 账必须是持久的」合取项,local 不报 true(诚实缺席,同
163
- * `park 设施缺席 ⇒ 协议不上场` 的姿势)。要在 local 真上协议,先落 File 形(现成模子 =
164
- * {@link FileApprovalExemptionStore}:core `AppendLog`/`readJsonlRecords`,内存索引 + 追加日志即可
165
- * 满足单实例 boot-lock 下的 CAS),那时这里换成 File twin、能力面自然翻真。
159
+ * 🔴 **「持久」是本口的契约,不是某条腿的巧合**(S-384):`resolveStreamApprovalGate` 自此**不再**
160
+ * 单独判一次 ask 账的持久性 —— 那条合取项(旧 `volatile_ask_ledger`)存在的唯一理由是 local 车道
161
+ * 当年交的是 `InMemoryApprovalAskStore`(两个进程内 Map ⇒ 重启同时丢重放基准、丢已接受的决议;
162
+ * 「一次真实的人类批准凭空消失」与「对账便利丢失」不是一个量级)。File 形落地后三条腿全是持久店,
163
+ * 那条合取项没有任何可以让它为假的输入 ⇒ 按「修完让规则更少」删掉,持久性的义务搬到**这里**。
164
+ * ⇒ **新加一条 backend 腿时必须给持久店**:进程内易失形会让能力面 `streamApproval` 对消费方撒谎
165
+ * (它承诺的是「稍后还能批、崩了行还在」)。机器钉 = `wiring-governance-operator.test.ts` 的
166
+ * 「三腿 approvalAsk 全持久」格。
166
167
  *
167
168
  * 无 backend 的 env-only worker 压根没有 `StoreBackend` ⇒ 协调器拿到 undefined askStore ⇒ 车2 的
168
169
  * D1 逐字现行为(store 缺席 = 现行 `tool_approval` 活卡腿一字不变)。 */
@@ -6,7 +6,8 @@ import { fileSessionCaptureRecordStore } from "@sema-agent/core";
6
6
  import { TiDBOutcomeLedger, PgOutcomeLedger } from "./outcome-ledger-sql.js";
7
7
  import { FileOutcomeSink } from "./file-outcome-sink.js";
8
8
  import { TiDBWorkflowRunStore, TiDBWorkflowCompletionInbox, TiDBWorkflowNotifyJournalStore, TiDBWorkflowAgentSessionIndex, PgWorkflowRunStore, PgWorkflowCompletionInbox, PgWorkflowNotifyJournalStore, PgWorkflowAgentSessionIndex } from "./workflow-run-store-sql.js";
9
- import { FileRunStore, RunStoreStrictHydrateError } from "./file-run-store.js";
9
+ import { FileRunStore } from "./file-run-store.js";
10
+ import { StoreBootRefusalError } from "./store-contracts.js";
10
11
  import { NO_SQL_MIGRATIONS, runSqlOneTimeMigrations } from "./one-time-migrations.js";
11
12
  import { LocalSessionStore } from "./local-session-store.js";
12
13
  import { LocalCheckpointStore } from "./local-checkpoint-store.js";
@@ -14,7 +15,7 @@ import { FileResumeAnchorStore } from "./file-resume-anchor-store.js";
14
15
  import { TiDBApprovalExemptionStore, PgApprovalExemptionStore, FileApprovalExemptionStore } from "./approval-exemption-store.js";
15
16
  import { TiDBApprovalNonceStore, PgApprovalNonceStore, FileApprovalNonceStore } from "./approval-nonce-store.js";
16
17
  import { TiDBApprovalAskStore, PgApprovalAskStore } from "./approval-ask-store-sql.js";
17
- import { InMemoryApprovalAskStore } from "./approval-ask-store-memory.js";
18
+ import { FileApprovalAskStore } from "./approval-ask-store-file.js";
18
19
  import { TiDBMemoryOptOutGrantStore, PgMemoryOptOutGrantStore } from "./memory-optout-grant-store-sql.js";
19
20
  import { TiDBSessionCaptureRecordStore, PgSessionCaptureRecordStore } from "./session-capture-record-store-sql.js";
20
21
  import { TiDBSendFileLedger, PgSendFileLedger, FileSendFileLedger } from "./send-file-ledger.js";
@@ -204,7 +205,7 @@ class LocalBackend {
204
205
  resumeAnchorStore;
205
206
  approvalExemptionStore;
206
207
  approvalNonceStore;
207
- approvalAskStore = new InMemoryApprovalAskStore();
208
+ approvalAskStore;
208
209
  sendFileLedgerStore;
209
210
  workflowJournalStore;
210
211
  outcomeSinkInst;
@@ -243,6 +244,7 @@ class LocalBackend {
243
244
  this.resumeAnchorStore = new FileResumeAnchorStore(this.fileBackend.root);
244
245
  this.approvalExemptionStore = new FileApprovalExemptionStore(this.fileBackend.root);
245
246
  this.approvalNonceStore = new FileApprovalNonceStore(this.fileBackend.root);
247
+ this.approvalAskStore = new FileApprovalAskStore(this.fileBackend.root);
246
248
  this.sendFileLedgerStore = new FileSendFileLedger(this.fileBackend.root);
247
249
  this.workflowJournalStore = new FileWorkflowJournalStore(this.fileBackend.root);
248
250
  this.outcomeSinkInst = new FileOutcomeSink(this.fileBackend.root);
@@ -261,6 +263,7 @@ class LocalBackend {
261
263
  this.resumeAnchorStore.dispose();
262
264
  this.approvalExemptionStore.dispose();
263
265
  this.approvalNonceStore.dispose();
266
+ this.approvalAskStore.dispose();
264
267
  this.sendFileLedgerStore.dispose();
265
268
  this.workflowJournalStore.dispose();
266
269
  this.checkpointStoreInst?.close();
@@ -336,7 +339,7 @@ export async function openStoreBackendWithFallback(config, logger) {
336
339
  catch (err) {
337
340
  if (err instanceof AdoptionError)
338
341
  throw err;
339
- if (err instanceof RunStoreStrictHydrateError)
342
+ if (err instanceof StoreBootRefusalError)
340
343
  throw err;
341
344
  if (!mayFallback)
342
345
  throw err;
@@ -15,6 +15,26 @@
15
15
  * code should import from this file.
16
16
  */
17
17
  import type { TaskResult, TaskStatus } from "@sema-agent/core";
18
+ /**
19
+ * **File 店族的 boot 期拒启基类**(S-384;把 `RunStoreStrictHydrateError` 的那条论证升成一条族规)。
20
+ *
21
+ * 🔴 **为什么必须是一个可判别的类型,而且必须是一个共同基类**:`openStoreBackendWithFallback` 对
22
+ * **默认推导**的 local(`DB_BACKEND` 未设 —— 也就是绝大多数单机部署)有一条既有降级臂:mkdir / 只读盘
23
+ * 那类「这台机器存不了盘」的失败降级到内存继续起。而 `LocalBackend` 构造器里的每一只 File 店都可能抛出
24
+ * 一条**完全不同**的句子——「这份数据有主 / operator 要了 fail-closed / 这份账本我重放不了」——它们从那个
25
+ * catch 看长得一模一样。不给身份 ⇒ 被同一条降级臂吞掉:一次**明确的 fail-closed 表态被翻译成 fail-open**,
26
+ * 整只 `StoreBackend` 被丢掉(持久 run 账本、`/v1/approvals`、park 全没),而唯一的痕迹是一行
27
+ * 「DB 不可达,已退回内存」把运维支去查连通性。
28
+ *
29
+ * 此前这条纪律是**逐个 `if instanceof` 手工登记**的(`AdoptionError` / `RunStoreStrictHydrateError`),
30
+ * 于是「下一只会拒启的 File 店」默认是**漏的** —— S-384 的 ask 账本拒启就这么漏了一次(替补对抗复审
31
+ * F1 [high] 实测:`DB_BACKEND` 未设时坏账本 ⇒ `{backend: undefined, degraded: true}`,不是拒启)。
32
+ * 收法:**一个基类,一条重抛**。新 File 店的拒启只要 `extends StoreBootRefusalError` 就自动落在正确的
33
+ * 一侧;忘了继承是**可见**的(它是一个必须显式选择的父类),而忘了往 if 链里加一行是不可见的。
34
+ * (core 的 `AdoptionError` 不在本仓,改不了它的继承链 ⇒ 降级臂保留它那一条 `instanceof`,共两条。)
35
+ */
36
+ export declare class StoreBootRefusalError extends Error {
37
+ }
18
38
  /**
19
39
  * 取消便签里的**大脑相位**快照 —— core `BrainStatus` 帧经 `brainStatusEventData` 投影后的五键子集。
20
40
  *
@@ -1,4 +1,6 @@
1
1
  import { parseJsonOr, toIso as iso, normalizeIsoOrNull as isoOrNull } from "./sql-row-helpers.js";
2
+ export class StoreBootRefusalError extends Error {
3
+ }
2
4
  export const RUN_STATUS_CLASS = {
3
5
  running: "live",
4
6
  completed: "succeeded",
@@ -574,24 +574,29 @@ export type UnattendedApprovalPolicy = "park" | "deny";
574
574
  * 于是「能力面说 true」⟺「协调器真拿到了 askStore」⟺「回决端点真有账可 CAS」是**结构成立**的,
575
575
  * 不再靠三处注释互相提醒。
576
576
  *
577
- * 五个合取项,缺一即不上场(每一项的缺席都有它自己的 `reason`,供启动期 info 与诊断分辨):
577
+ * 四个合取项,缺一即不上场(每一项的缺席都有它自己的 `reason`,供启动期 info 与诊断分辨):
578
578
  * 1. `toolApprovalEnabled` —— 连活卡腿都没有,谈不上流内协议;
579
579
  * 2. `streamApprovalEnabled` —— 协议总开关(`STREAM_APPROVAL_ENABLED`,树上已**默认 ON**;显式 `false`
580
580
  * 是唯一干净还原键。默认极性与版本坐标的属主口径见 `config.ts` 的 `streamApprovalConfig()` 头注);
581
581
  * 3. `backend` 在场 —— env-only worker 没有 `StoreBackend` 本体;
582
- * 4. **账必须是持久的**(`kind !== "local"`)—— InMemory 形重启即丢**已接受的决议**,那与「对账便利
583
- * 丢失」不是一个量级(§2.2(b));File 形 ask 店落地后这一项自然翻真;
584
- * 5. **park 设施在场**(§8.4)—— 缺席时「窗到期 ⇒ unavailable」在 core 侧没有降级目的地,结局是
582
+ * 4. **park 设施在场**(§8.4)—— 缺席时「窗到期 ⇒ unavailable」在 core 侧没有降级目的地,结局是
585
583
  * fail-closed deny,**比现状(5min 活卡、人能批)更差**。所以自检不满足 ⇒ **协议不上场**、现行
586
584
  * `tool_approval` 活卡腿逐字保留,而不是「把 ask 推向一个不存在的目的地」(§14 属主照准,core [ref]
587
585
  * 回帖确认即原意)。
586
+ *
587
+ * 🔴 **S-384:第 5 项(旧第 4 项 `volatile_ask_ledger` = 「账必须是持久的」,判据 `kind !== "local"`)
588
+ * 已整条删除,词也从闭集里删了(零别名)**。它当年成立的唯一输入是 local 车道交的 `InMemoryApprovalAskStore`;
589
+ * `FileApprovalAskStore` 落地后 `StoreBackend.approvalAsk()` 的三条腿全是持久店 ⇒ 这条合取项**没有任何
590
+ * 能让它为假的输入**,留着就是一条永不触发的规则(「修完让规则集更小」)。持久性的义务搬到
591
+ * `StoreBackend.approvalAsk()` 的接口契约上(那条注 + `wiring-governance-operator.test.ts` 的三腿机器钉)——
592
+ * 它是**装配面**的事实,而本谓词能看见的只有 `kind`,拿 `kind` 当持久性的代理正是这次要拆掉的那层间接。
588
593
  */
589
594
  export type StreamApprovalGate = {
590
595
  active: true;
591
596
  askStore: ApprovalAskStore;
592
597
  } | {
593
598
  active: false;
594
- reason: "no_tool_approval" | "protocol_disabled" | "no_backend" | "volatile_ask_ledger" | "no_park_facility";
599
+ reason: "no_tool_approval" | "protocol_disabled" | "no_backend" | "no_park_facility";
595
600
  };
596
601
  /** {@link resolveStreamApprovalGate} 的入参。`backend` 用**结构形**(不 import `StoreBackend`):本模块是
597
602
  * 协调器的家,不该为一个布尔判据把整棵 store 依赖树拖进类型面。 */
@@ -656,7 +661,7 @@ export interface ApprovalLegAssembly {
656
661
  * 🔴 **[ref] 起它与 `active` 解耦**(旧文「`active` 为假时恒 false —— 协议都没上场,谈不上关窗」已
657
662
  * 作废):`STREAM_ASK_WINDOW_MS` 是**部署级**旋钮,`0` 的语义(运维显式关窗 ⇒ 恒走
658
663
  * {@link ToolApprovalCoordinator.unattendedAskOutcome},连卡都不发)在两条车道上必须是同一个意思。
659
- * 旧形下 local 车道(`volatile_ask_ledger`)/ 无 park 设施 / 显式关协议的部署配了 `0`,拿到的是一张
664
+ * 旧形下 local 车道(S-384 之前的「账不持久」臂)/ 无 park 设施 / 显式关协议的部署配了 `0`,拿到的是一张
660
665
  * 等满 5min 的活卡 —— 与它自己的域注正好相反,且没有任何一行说出来。
661
666
  * ⚠️ 「窗**缺席**」(`windowMs: undefined` = 这份装配根本没有 streamApproval 段,只出现在 stub-harness)
662
667
  * **不是**关窗:那时逐字保持活卡腿。 */
@@ -167,8 +167,6 @@ export function resolveStreamApprovalGate(input) {
167
167
  return { active: false, reason: "protocol_disabled" };
168
168
  if (!input.backend)
169
169
  return { active: false, reason: "no_backend" };
170
- if (input.backend.kind === "local")
171
- return { active: false, reason: "volatile_ask_ledger" };
172
170
  if (!input.parkFacility)
173
171
  return { active: false, reason: "no_park_facility" };
174
172
  return { active: true, askStore: input.backend.approvalAsk() };
@@ -60,7 +60,7 @@ type MailboxAppendProjected = "from" | "content" | "sentAt" | "hopChain" | "peer
60
60
  type _GuardMailboxAppend = AssertAllKeysHandled<Exclude<keyof MailboxAppendMessage, MailboxAppendProjected>>;
61
61
  type _GuardMailboxAppendReverse = AssertAllKeysHandled<Exclude<MailboxAppendProjected, keyof MailboxAppendMessage>>;
62
62
  type WiringSectionProjected = "schemaVersion" | "leg" | "ask" | "question" | "interaction" | "elicit" | "parkLane" | "session" | "fleet" | "permissionRules" | "modelGate" | "autoMode" | "mcp" | "tools" | "hooks" | "lsp" | "writeProtection";
63
- type WiringSectionExcluded = "governance" | "configFingerprint";
63
+ type WiringSectionExcluded = "governance" | "configFingerprint" | "autoConsolidation";
64
64
  type _GuardWiringSections = AssertAllKeysHandled<Exclude<keyof WiringManifest, WiringSectionProjected | WiringSectionExcluded>>;
65
65
  type _GuardWiringSectionsReverse = AssertAllKeysHandled<Exclude<WiringSectionProjected | WiringSectionExcluded, keyof WiringManifest>>;
66
66
  type WiringSection<K extends keyof WiringManifest> = NonNullable<WiringManifest[K]>;
@@ -103,6 +103,9 @@ type _GuardWiringRulesReverse = AssertAllKeysHandled<Exclude<WiringRulesHandled,
103
103
  type WiringWriteProtectionHandled = "targetView";
104
104
  type _GuardWiringWriteProtection = AssertAllKeysHandled<Exclude<keyof WiringSection<"writeProtection">, WiringWriteProtectionHandled>>;
105
105
  type _GuardWiringWriteProtectionReverse = AssertAllKeysHandled<Exclude<WiringWriteProtectionHandled, keyof WiringSection<"writeProtection">>>;
106
+ type WiringAutoConsolidationHandled = "onRecommendation";
107
+ type _GuardWiringAutoConsolidation = AssertAllKeysHandled<Exclude<keyof WiringSection<"autoConsolidation">, WiringAutoConsolidationHandled>>;
108
+ type _GuardWiringAutoConsolidationReverse = AssertAllKeysHandled<Exclude<WiringAutoConsolidationHandled, keyof WiringSection<"autoConsolidation">>>;
106
109
  type WiringModelGateHandled = "class" | "removed" | "restore";
107
110
  type _GuardWiringModelGate = AssertAllKeysHandled<Exclude<keyof WiringSection<"modelGate">, WiringModelGateHandled>>;
108
111
  type _GuardWiringModelGateReverse = AssertAllKeysHandled<Exclude<WiringModelGateHandled, keyof WiringSection<"modelGate">>>;
@@ -185,6 +188,8 @@ export type CoreKeysetGuards = [
185
188
  _GuardWiringSections,
186
189
  _GuardWiringWriteProtection,
187
190
  _GuardWiringWriteProtectionReverse,
191
+ _GuardWiringAutoConsolidation,
192
+ _GuardWiringAutoConsolidationReverse,
188
193
  _GuardWiringSectionsReverse,
189
194
  _GuardWiringLeg,
190
195
  _GuardWiringLegReverse,
@@ -604,6 +604,14 @@ export declare function workspaceChangedEventData(ev: {
604
604
  * ⚠️ **缺席 = 第三态**:core 的段是 **present-iff 编译出了判官**,`writeProtectedPaths: []` 的部署
605
605
  * 整段缺席。缺席**绝不**折成 `"spelling-only"`(那是「判官在、但只认拼法」,完全不同的一件事),
606
606
  * 也不铸 `writeProtection: {}`。词出闭集同样**不**折进缺席,走 `dropProjectedFace` 留痕(见那里)。
607
+ * · `autoConsolidation`(`{ onRecommendation: true }`;core 7.21.0 [ref],S-362 提货件)—— **裁:operator
608
+ * 受众,租户面剥**,所以它**不在本构造器里**,在 {@link wiringManifestOperatorEventData}。四条判据逐字在
609
+ * `core-keyset-guard.ts` ⑥b 的 `WiringSectionExcluded` 那一行(与 `governance` / `configFingerprint`
610
+ * 同表同处,免得同一条裁定有两个写者)。一句话:它是**出口事实**(武装 = 每个建议任务之后整库记忆自动
611
+ * 外流到整理模型),与 governance 四布尔同族,而不是 `writeProtection` 那种「这条腿手里有什么」的自述;
612
+ * 并且它对调用方**本就不可观测**(整理跑在终端 harvest 之后、fire-and-forget、宿主侧),剥掉它不收走
613
+ * 任何能力 —— 这正是 `writeProtection` 判据①**不成立**的那一格。
614
+ * ⚠️ **缺席 = 未武装**(core present-iff),不折 `false`、不读「未知」。
607
615
  * 未来 core 加一段而它没有 audience 标签时,**先在这里补一条裁定再决定挑不挑键** —— 段级完备性钉
608
616
  * (test/wiring-manifest-projection.test.ts)会在那一刻先把人拦下来 —— ⚠️ **但那条钉是夹具驱动的**:它只看
609
617
  * `deriveWiringManifest(EFFECTIVE_FACTS)` 真铸出来的段,facts 不带的可选段(147③ 的 `autoMode` 就是)它一格
@@ -662,6 +670,16 @@ export declare function wiringManifestEventData(ev: {
662
670
  *
663
671
  * governance **逐键挑**(禁 `{...governance}`):core 往这个段里加第五键时必须由人显式处置,而不是顺着
664
672
  * spread 裸上线 —— 这个段的每一键都是部署治理面,新键的默认待遇必须是「不发」。
673
+ *
674
+ * `autoConsolidation`(core 7.21.0 [ref],S-362)与 governance 同规:租户面剥、本面发(裁定四条判据在
675
+ * `core-keyset-guard.ts` ⑥b `WiringSectionExcluded`)。**present-iff 武装**:
676
+ * · 整段缺席 ⇒ **未武装**,不铸键、不计数(正面事实,不是「未知」,更不是 `{onRecommendation:false}`);
677
+ * · 在场且 `onRecommendation === true` ⇒ 逐字铸出(core 的在场形只有这一个,构造期门已拒掉「武装但跑不起来」);
678
+ * · 在场但读不出那个形 ⇒ `dropProjectedFace` 留痕并整段缺席(与 `writeProtection` / `lsp` / `hooks` 同一条
679
+ * 族规:「在场但读不出 ⇒ 计一次并丢弃」;**不**自造第三态,词表/形的属主在 core)。
680
+ * 🔴 代价如实登记,与 `writeProtection` 那一格逐字同源:wire 上「读不出」与「真未武装」是**同一个形**,
681
+ * 分得开它们的只有 `server.trace.projection-input-malformed` 这只计数 —— 而这一格比那一格更该说清楚,
682
+ * 因为它读错的方向是**把一台正在外流记忆的部署渲染成没在外流**。故本段的读不出**必须**留痕,不许静默。
665
683
  */
666
684
  export declare function wiringManifestOperatorEventData(ev: {
667
685
  manifest?: unknown;