@sema-agent/server 7.103.0 → 7.103.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 (38) hide show
  1. package/MIGRATION.md +40 -10
  2. package/USAGE.md +12 -4
  3. package/dist/boot/budget-tracing.d.ts +1 -0
  4. package/dist/boot/budget-tracing.js +3 -2
  5. package/dist/boot/config-center.d.ts +5 -0
  6. package/dist/boot/config-center.js +15 -17
  7. package/dist/boot/stage-04-stores.d.ts +1 -0
  8. package/dist/boot/stage-04-stores.js +2 -2
  9. package/dist/boot/stage-05-execution-env.d.ts +1 -0
  10. package/dist/boot/stage-06-runners.d.ts +1 -0
  11. package/dist/boot/stage-07-capability-layer.d.ts +1 -0
  12. package/dist/boot/stage-08-reapers.d.ts +1 -0
  13. package/dist/boot/stage-08-reapers.js +2 -2
  14. package/dist/boot/stage-09-leader.d.ts +1 -0
  15. package/dist/boot/stage-10-http-server.d.ts +1 -0
  16. package/dist/brain.d.ts +32 -1
  17. package/dist/brain.js +24 -4
  18. package/dist/config-center/apply-effective.d.ts +24 -2
  19. package/dist/config-center/apply-effective.js +9 -1
  20. package/dist/config-center/facade.d.ts +1 -1
  21. package/dist/config-center/facade.js +1 -1
  22. package/dist/config-center/restart-signal.d.ts +40 -3
  23. package/dist/config-center/restart-signal.js +11 -12
  24. package/dist/model-entry-refusal.d.ts +2 -1
  25. package/dist/observability/secret-env-scrub.d.ts +1 -1
  26. package/dist/observability/secret-env-scrub.js +3 -2
  27. package/dist/plugins/remote-env-adb.d.ts +10 -3
  28. package/dist/plugins/remote-env-adb.js +24 -20
  29. package/dist/plugins/remote-env-k8s.d.ts +8 -1
  30. package/dist/plugins/remote-env-k8s.js +31 -24
  31. package/dist/plugins/remote-env-ssh.d.ts +8 -1
  32. package/dist/plugins/remote-env-ssh.js +24 -18
  33. package/dist/plugins/remote-shell.d.ts +109 -9
  34. package/dist/plugins/remote-shell.js +83 -0
  35. package/dist/server-secret-env.d.ts +21 -29
  36. package/dist/server-secret-env.js +3 -19
  37. package/package.json +3 -3
  38. package/skills/update-config.md +23 -5
@@ -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 { ExecutionError, FileError, FileInfo, RemoteExecutionError, Result, SymlinkChain } from "@sema-agent/core";
5
+ import { ExecutionError, type FileError, type FileInfo, type RemoteExecutionError, type Result, type 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):
@@ -25,6 +25,108 @@ export declare function armPipeDestroyGrace(child: ChildProcess): ReturnType<typ
25
25
  /** POSIX single-quote a string for safe interpolation into a remote shell command (the only metacharacter
26
26
  * inside '' is ' itself, closed/escaped/reopened). */
27
27
  export declare function shellQuote(s: string): string;
28
+ /** S-410:cwd 守卫在**远端**的退出码 —— 包装层自己失败、命令没跑(POSIX 包装工具 `env(1)` / `nohup(1)` / `timeout(1)` 的惯用码;126 / 127 是 shell
29
+ * 自己的「不可执行 / 找不到」)。buffered `exec` 经 {@link createCwdGuardedCall} 把它收成 `spawn_error`(车PL);命令自己也可能退 125,判别见那只函数。 */
30
+ export declare const CWD_UNAVAILABLE_EXIT = 125;
31
+ /** S-410:cwd 守卫触发时写进 stderr 的那一行的开头(后接 ` [<nonce>]: <目录> (cd failed; command not run)`),也是 `spawn_error` 消息的主句。 */
32
+ export declare const CWD_UNAVAILABLE_LINE = "sema: working directory unavailable";
33
+ /** 车PL 验修 ②:settled 面上 cd 成功之后、命令之前打到 stderr 的那一行的开头(后接 ` [<同一 nonce>]`)。腿在交出之前把它剥掉。 */
34
+ export declare const CWD_STARTED_LINE = "sema: command started";
35
+ /** 一次调用的 cwd 守卫:被守卫的目录 + 本次调用的两只标记(同一个 nonce)。 */
36
+ export interface CwdGuard {
37
+ /** 被守卫的目录(腿已绝对化)。 */
38
+ readonly dir: string;
39
+ /** `sema: working directory unavailable [<nonce>]` —— 守卫行以它开头。nonce = 每次调用现铸的 8 字节随机数(16 位十六进制)。
40
+ * 🔴 nonce **对命令不保密**:命令能从 `BASH_EXECUTION_STRING` / `/proc/self/cmdline` 读到整串(验修 codex ②)⇒ 守卫行可以被伪造,
41
+ * 判据因此不单靠它(见 {@link CwdGuard.started})。 */
42
+ readonly marker: string;
43
+ /** settled 面:cd 成功之后、命令之前 `echo` 到 stderr 的那一整行 `sema: command started [<同一 nonce>]`;raw 面缺席(见 {@link CwdGuardFace})。
44
+ * 它在命令开跑**之前**就进了流,命令收不回它 ⇒「见到这一行 = 命令跑过了」,伪造的守卫行不再被认。 */
45
+ readonly started: string | undefined;
46
+ }
47
+ /** {@link buildCwdEnvCommand} 的产物(纯数据):交给远端 `sh -c` 的整串 + 本次调用的 cwd 守卫(没给 `dir` ⇒ 缺席)。 */
48
+ export interface CwdEnvCommand {
49
+ readonly command: string;
50
+ readonly cwdGuard: CwdGuard | undefined;
51
+ }
52
+ /**
53
+ * 串是给哪一面用的(闭集,两词):
54
+ * · `settled` —— buffered `exec`:结果要经 {@link createCwdGuardedCall} 判「命令跑没跑」,所以守卫之后多打一行 started 标记,腿再把它剥掉;
55
+ * · `raw` —— `execStream` 与后台 launch:不判(货币是 `RemoteExecutionError` / 落盘的 job 输出),守卫行 + 125 原样交出,**不打** started 行 ——
56
+ * 打了就得在流式的三处与后台的落盘文件里各剥一次,而这两面没有要它的判据。
57
+ */
58
+ export type CwdGuardFace = "settled" | "raw";
59
+ /**
60
+ * 🔴 S-410(7.103.1)—— 三条远端 shell 腿(ssh / k8s / adb)交给远端 `sh -c` 的那一串**唯一**的组装:进工作目录 →(settled 面)打 started 行 →
61
+ * 导出任务 env → 跑命令。各腿只决定 `dir`(自己的缺省 cwd 规则 + 路径绝对化),串的形状只在这里。
62
+ *
63
+ * 形:`cd '<dir>' || { echo '<marker>: <dir> (cd failed; command not run)' >&2; exit 125; }; echo '<started>' >&2; export K='v'; … <command>`
64
+ * (raw 面没有 `echo '<started>' >&2;` 那一段)
65
+ * · cd 失败(目录不存在 / 无权限 / 不是目录)⇒ 命令**不跑**,整条退 {@link CWD_UNAVAILABLE_EXIT};stderr 先是 shell 自己的 cd 报错,再是守卫行。
66
+ * 修前形 `cd '<dir>' && export K='v'; <command>`:`&&` 只绑到第一只 `export`,`;` 之后的命令照样在**登录目录**里跑,还少了那只 env ——
67
+ * 执行面 fail-open([ref])。core 的 Bash 工具每次都带 `env:{PWD}`,所以修前**每一条**模型命令都是这一形;没有 env 时修前形是
68
+ * `cd … && <command>`,只短路命令的第一段 —— `a; b`、`a || b`、多行命令的尾部照样在登录目录里跑。本形对两者一律不跑(钉:
69
+ * `test/remote-cwd-env-compose.test.ts`「本机 sh 真跑」格的复合命令半)。
70
+ * · 为什么是前缀守卫,不是 `cd … && { export …; <command>; }`:命令串原样留在**最后**,与修前同位。包进 `{ …; }` 会让命令里的尾随注释
71
+ * (`ls # x` 把 `; }` 注释掉)与 heredoc(终止行后面多出 `; }`)改义或语法错。cd 成功时,命令、env、退出码与修前逐字同义;stderr 在 settled 面
72
+ * 多出的 started 行由腿剥掉({@link createCwdGuardedCall}),交出去的 stderr 逐字是命令自己的。
73
+ * · 两行都用 `echo`,不用 `printf`:`echo` 在每一只 POSIX shell 里都是内建(dash / bash / busybox ash / mksh / toybox sh),早期 Android 的
74
+ * toolbox 可能没有 `printf` 可执行件(未实机核)—— 真没有的话,用 `printf` 那种设备上守卫行出不来、腿认不出守卫,命令照样不跑但退回「命令自己退 125」的形。
75
+ * `echo` 在 dash 里会解释参数里的反斜杠转义,只动得了守卫行的目录那一段:判别只读行首的 marker(无反斜杠),消息里的目录取 {@link CwdGuard.dir};
76
+ * started 行里没有目录、没有反斜杠。
77
+ * · export 规则逐字照旧。
78
+ * - 值经 {@link shellQuote}(POSIX 单引号,里面只有 `'` 自己要转义)⇒ 命令读到的是原值,不展开、不分词。
79
+ * - 键原样拼:键与 `command` 出自同一个调用方(引擎交来的 `ExecOpts.env`),而 `command` 本身就是任意 shell 文本、以同一身份跑 ⇒
80
+ * 不引用键不多给任何能力。引用了反而改变「非法键名 ⇒ export 报错」的现状。
81
+ * · ⚠️ 边界:需要远端是 POSIX sh(`{ …; }` 组命令)。k8s 走 `sh -c`、adb 走设备 `sh`;**ssh 腿走远端账户登录 shell 的 `-c`** —— 登录 shell 是
82
+ * csh / tcsh(fish 看版本)的账户不认 `{ …; }`,整条语法错、命令不跑(修前无 env 的 `cd 'X' && cmd` 在 csh 里能跑)。部署前提写在
83
+ * `docs/DEPLOY-PREREQS.md`「ssh / k8s / adb 车道:远端工作目录不可用」节。
84
+ * `dir` 缺席 ⇒ 不进目录、没有守卫、没有 started 行,只有 export + 命令(与修前同)。
85
+ */
86
+ export declare function buildCwdEnvCommand(command: string, dir: string | undefined, env: Readonly<Record<string, string>> | undefined, face: CwdGuardFace): CwdEnvCommand;
87
+ /** 经 {@link buildCwdEnvCommand} 组串的三条远端 shell 腿(`spawn_error` 消息的腿名,与 host 腿的 `host spawn failed: …` 同一句族)。 */
88
+ export type RemoteShellLeg = "ssh" | "k8s" | "adb";
89
+ /** 远端 stderr 流过的那一半(各腿的传输层按原始字节喂):剥掉本次调用的 started 行(只那一行),其余逐字交出。 */
90
+ export interface CwdStartedStderrFilter {
91
+ /** 一块原始字节 → 该交给下游(缓冲 / 回调 / tee)的字节。 */
92
+ filterStderr(chunk: Buffer): Buffer;
93
+ /** 流结束:交出还压着的字节(started 行之前、还没收到换行的那一截)。 */
94
+ endStderr(): Buffer;
95
+ }
96
+ /** 远端三腿 buffered `exec` 的一次调用(活对象:stderr 流过它,结果经它收口)。 */
97
+ export interface CwdGuardedCall extends CwdStartedStderrFilter {
98
+ /** 交给远端 `sh -c` 的整串(settled 面)。 */
99
+ readonly command: string;
100
+ /** 收口:退 125 ∧ stderr 有以本次 marker 开头的一行 ∧ **没见到** started 行 ⇒ `spawn_error`;其余原样。 */
101
+ settle<T extends {
102
+ readonly stderr: string;
103
+ readonly exitCode: number;
104
+ }>(r: Result<T, ExecutionError>): Result<T, ExecutionError>;
105
+ }
106
+ /**
107
+ * 🔴 车PL(7.103.1 验修轮 A [high] + 验修 codex ②)—— 三条远端 shell 腿 buffered `exec` 的**唯一**协议:组串(settled 面)+ 剥 started 行 + 收口。
108
+ * 守卫触发 ⇒ 与 host 腿同形的 `ExecutionError("spawn_error", "<腿> spawn failed: sema: working directory unavailable: <目录> — the command did not run
109
+ * (<cd 自己的话>)")`。
110
+ *
111
+ * 为什么是 spawn_error:core 的 ExecutionEnv 契约把「命令没跑(死 cwd 在列)」给了它;core 的 Bash 工具只在 `spawn_error` / `shell_unavailable` 上
112
+ * 走死 cwd 诊断(`diagnoseDeadCwd`:目录真没了 ⇒ 复位到任务根、答「reset to <root>; re-run」)。S-410 初版把同一形报成 `{ ok:true, exitCode:125 }`,
113
+ * core 读作命令自己失败 ⇒ cwd 不复位,前缀又挡在模型自救的 `cd` / `mkdir` 前面 ⇒ 本轮后续 Bash 全退 125。host 腿(`remote-env-host.ts` 的
114
+ * `child.on("error")`)本就报 `spawn_error`,四条腿一条规则。
115
+ *
116
+ * 判据(一条规则,缺一不可):`r.ok` ∧ 退出码 = {@link CWD_UNAVAILABLE_EXIT} ∧ stderr 有一行以本次 {@link CwdGuard.marker} 开头 ∧ **流里没出现过**
117
+ * 本次 {@link CwdGuard.started} 那一整行。started 行在 cd 成功之后、命令之前写进流,命令收不回它 ⇒ 命令读到 nonce 伪造守卫行再退 125,照样按
118
+ * 「命令跑过了」交出(验修 codex ②:修前这一形被读成「没跑」⇒ 输出丢、调用方可能重试一个非幂等操作)。消息里的目录取 {@link CwdGuard.dir}
119
+ * (不从 stderr 抠),括号里是守卫行之前 shell 自己的 cd 报错(无则 `cd failed`)。
120
+ *
121
+ * started 行的剥法:按原始字节逐行扫,**第一处**与 started 行逐字节相等的整行剥掉(连同换行),其后的字节原样直通;之前的行(ssh 登录 shell 可能
122
+ * 先说几句)原样交出、只压着还没收到换行的那一截。腿在三处都用它:缓冲的 stderr、流式回调、k8s detach 的 tee ⇒ 交出去的 stderr 逐字是命令自己的。
123
+ * 没有守卫(没给 `dir`,如文件面探针)⇒ 直通、收口不判。
124
+ *
125
+ * 辖域 = buffered `exec`(core Bash 工具、git 探针走它;文件面探针也走同一只 exec 体但**不带 cwd**,见各腿)。流式 `execStream` 与后台 launch 用
126
+ * `raw` 面:照旧交出 125 + 守卫行 —— 两面的货币是 `RemoteExecutionError`(没有「命令没跑」这个词),消费方(core exec-gate、leader `pullDiff`)
127
+ * 把任何非 0 退出当失败并带出 stderr,而 core 的后台 Bash 在 launch 前自己先做死 cwd 检查(`fs-bash.js` `run_in_background` 分支)。
128
+ */
129
+ export declare function createCwdGuardedCall(leg: RemoteShellLeg, command: string, dir: string | undefined, env: Readonly<Record<string, string>> | undefined): CwdGuardedCall;
28
130
  /** Derive FileInfo kind from POSIX st_mode bits (raw stat/readdir attrs carry no isDirectory() helper). */
29
131
  export declare function kindFromMode(mode?: number): FileInfo["kind"];
30
132
  export declare function drainNumEnvWarnings(): Array<{
@@ -43,17 +145,16 @@ export declare const SYMLINK_CHAIN_MAX_HOPS = 64;
43
145
  * `unknown` 而不是 `not_found`),于是调用方拿到的是 `ok:false`(core 读作「这一次没有跳」,永不是拒绝)
44
146
  * 而不是一个伪造的空链。
45
147
  *
46
- * 🔴 **整条命令包在一个 `{ …; }` 里**,理由是**调用方会给它加前缀**:四条腿的 `withCwdEnv` 都拼
47
- * `cd <dir> && <命令>`(ssh `:760` / k8s `:1418` / adb `:578` / local-docker 同形)。这里连着犯过两次错,
48
- * 两次都记下来,因为它们是**同一个坑的两面**:
148
+ * 🔴 **整条命令包在一个 `{ …; }` 里**,让它**只有一个退出状态**,不依赖调用方加的前缀是什么形。今天三条远端 shell 腿的文件面探针
149
+ * (本命令经 `PosixShellFs` 发出)**不带 cwd**(车PL 验修 ③:只用绝对路径,不进目录 ⇒ 前面没有 {@link buildCwdEnvCommand} 的守卫前缀);
150
+ * local-docker 用 `docker exec -w <dir>`,e2b 走 SDK 的 `cwd`,都不拼前缀。S-410 之前三条远端腿给它拼过 `cd <dir> && <命令>`,在那个形下
151
+ * 这里连着犯过两次错,记下来是因为它们是**同一个坑的两面**,换任何一种链式前缀都会再来:
49
152
  * · 写成 `command -v … || { …; }`:`||` 与前缀的 `&&` 结成一条链(`cd X && command -v … || { … }`),
50
153
  * **`cd` 失败时跑的是探测的失败臂** —— 报「readlink 不在」而真相是「那个目录没了」,运维去修一个没坏的东西;
51
154
  * · 改成独立语句 `if ! …; fi; c=…; while …`:前缀的 `&&` 只短路**那一条 `if`**,后面的循环照跑 ——
52
155
  * 于是「`cd` 失败 ∧ readlink 缺失」这一格里,`|| break` 把工具缺失吞成零跳,脚本 **exit 0 + 空 stdout**,
53
156
  * 调用方拿到一条**伪造的空链**。这正是探测本来要挡的那个静默兜底([ref] 轴),换了个入口回来了。
54
- * ⇒ 正解是**让整条命令只有一个退出状态**:包进 `{ …; }`,前缀的 `&&` 要么统治全部、要么全部不跑。
55
- * `cd` 失败 ⇒ 退出码来自 `cd` 自己、stderr 是它自己的话 ⇒ `ok:false`(core 读作「这一次没有跳」),
56
- * 诚实且指向真因;`cd` 成功 ⇒ 探测与循环照旧。`{ }` 不是子 shell,里面的 `exit 127` 照样结束整条命令。
157
+ * `{ }` 不是子 shell,里面的 `exit 127` 照样结束整条命令。
57
158
  *
58
159
  * 词法细节(逐条对着 core 的 Node 实现写,两边同形):绝对目标直接接上;相对目标用参数展开
59
160
  * `${c%/*}` 取**当前这条链接**所在目录(不调 `dirname` —— toybox 的 `dirname` 不认 `--`,且这里零外部命令
@@ -101,8 +202,7 @@ export type ShellChainRun = (cmd: string) => Promise<Result<{
101
202
  * 「叶不是软链」与「叶不存在」**都是 exit 0 + 空 stdout**(脚本里的 `|| break`),所以任何 exit≠0
102
203
  * 都**不是**「这个文件不在」,而是解析器自己出了事(工具缺失 / cwd 没了 / 传输坏 / shell 拒)。
103
204
  * 把它折成 `not_found` 会让运维去找一条根本没丢的路径。
104
- * ⚠️ 例外由 `classifyFsStderr` 自己认:`cd` 失败那一格的 stderr 里真带「No such file」,它按**那句话**
105
- * 归 `not_found` —— 那时真的有一条路径没了(工作目录),所以那个码是对的。
205
+ * 三条远端腿的文件面探针不带 cwd(车PL 验修 ③)⇒ 这条命令不会因为某个工作目录不可用而不跑。
106
206
  */
107
207
  export declare function runSymlinkChain(absPath: string, run: ShellChainRun): Promise<Result<SymlinkChain, FileError>>;
108
208
  //# sourceMappingURL=remote-shell.d.ts.map
@@ -1,4 +1,6 @@
1
+ import { ExecutionError } from "@sema-agent/core";
1
2
  import { classifyFsStderr, fileErrorFromExec } from "./remote-env-file-error.js";
3
+ import { randomBytes } from "node:crypto";
2
4
  export const EXEC_FORCE_SETTLE_GRACE_MS = 3000;
3
5
  export function armPipeDestroyGrace(child) {
4
6
  const t = setTimeout(() => {
@@ -15,6 +17,87 @@ export function armPipeDestroyGrace(child) {
15
17
  export function shellQuote(s) {
16
18
  return `'${s.replace(/'/g, `'\\''`)}'`;
17
19
  }
20
+ export const CWD_UNAVAILABLE_EXIT = 125;
21
+ export const CWD_UNAVAILABLE_LINE = "sema: working directory unavailable";
22
+ export const CWD_STARTED_LINE = "sema: command started";
23
+ export function buildCwdEnvCommand(command, dir, env, face) {
24
+ const parts = [];
25
+ let cwdGuard;
26
+ if (dir !== undefined && dir !== "") {
27
+ const nonce = randomBytes(8).toString("hex");
28
+ cwdGuard = { dir, marker: `${CWD_UNAVAILABLE_LINE} [${nonce}]`, started: startedLineFor(face, nonce) };
29
+ const line = `${cwdGuard.marker}: ${dir} (cd failed; command not run)`;
30
+ parts.push(`cd ${shellQuote(dir)} || { echo ${shellQuote(line)} >&2; exit ${CWD_UNAVAILABLE_EXIT}; };`);
31
+ if (cwdGuard.started !== undefined)
32
+ parts.push(`echo ${shellQuote(cwdGuard.started)} >&2;`);
33
+ }
34
+ if (env)
35
+ for (const [k, v] of Object.entries(env))
36
+ parts.push(`export ${k}=${shellQuote(v)};`);
37
+ parts.push(command);
38
+ return { command: parts.join(" "), cwdGuard };
39
+ }
40
+ function startedLineFor(face, nonce) {
41
+ switch (face) {
42
+ case "settled":
43
+ return `${CWD_STARTED_LINE} [${nonce}]`;
44
+ case "raw":
45
+ return undefined;
46
+ }
47
+ }
48
+ const STARTED_SCAN_CAP_BYTES = 64 * 1024;
49
+ const EMPTY = Buffer.alloc(0);
50
+ export function createCwdGuardedCall(leg, command, dir, env) {
51
+ const sent = buildCwdEnvCommand(command, dir, env, "settled");
52
+ const guard = sent.cwdGuard;
53
+ const startedBytes = guard?.started === undefined ? undefined : Buffer.from(guard.started, "utf8");
54
+ let pending = EMPTY;
55
+ let scanning = startedBytes !== undefined;
56
+ let sawStarted = false;
57
+ return {
58
+ command: sent.command,
59
+ filterStderr(chunk) {
60
+ if (!scanning || startedBytes === undefined)
61
+ return chunk;
62
+ let rest = pending.length === 0 ? chunk : Buffer.concat([pending, chunk]);
63
+ const out = [];
64
+ for (let nl = rest.indexOf(0x0a); nl >= 0; nl = rest.indexOf(0x0a)) {
65
+ if (rest.subarray(0, nl).equals(startedBytes)) {
66
+ sawStarted = true;
67
+ scanning = false;
68
+ out.push(rest.subarray(nl + 1));
69
+ rest = EMPTY;
70
+ break;
71
+ }
72
+ out.push(rest.subarray(0, nl + 1));
73
+ rest = rest.subarray(nl + 1);
74
+ }
75
+ if (scanning && rest.length > STARTED_SCAN_CAP_BYTES) {
76
+ scanning = false;
77
+ out.push(rest);
78
+ rest = EMPTY;
79
+ }
80
+ pending = rest;
81
+ return out.length === 1 ? out[0] : Buffer.concat(out);
82
+ },
83
+ endStderr() {
84
+ const p = pending;
85
+ pending = EMPTY;
86
+ scanning = false;
87
+ return p;
88
+ },
89
+ settle(r) {
90
+ if (!r.ok || guard === undefined || sawStarted || r.value.exitCode !== CWD_UNAVAILABLE_EXIT)
91
+ return r;
92
+ const lines = r.value.stderr.split("\n");
93
+ const at = lines.findIndex((l) => l.startsWith(guard.marker));
94
+ if (at < 0)
95
+ return r;
96
+ const said = lines.slice(0, at).join(" ").trim().slice(0, 300) || "cd failed";
97
+ return { ok: false, error: new ExecutionError("spawn_error", `${leg} spawn failed: ${CWD_UNAVAILABLE_LINE}: ${guard.dir} — the command did not run (${said})`) };
98
+ },
99
+ };
100
+ }
18
101
  export function kindFromMode(mode) {
19
102
  const t = (mode ?? 0) & 0o170000;
20
103
  if (t === 0o040000)
@@ -59,16 +59,8 @@ export interface NameListKnobEnvFinding {
59
59
  readonly confidence: "high";
60
60
  readonly source: "server-name-list-knob";
61
61
  }
62
- /** 随残组扣下的 git 配置组成员(codex r1 C1;见 {@link hostSubprocessEnv} 的组不变量)。它本身不是凭据 —— 是因为同组的某个成员被剥掉、
63
- * 整组已不成形而一起扣下的;`kind` 单列一桶,读观测面的人分得清「剥的」与「随组扣下的」。 */
64
- export interface GitConfigGroupEnvFinding {
65
- readonly key: string;
66
- readonly kind: "git-config-group";
67
- readonly confidence: "high";
68
- readonly source: "server-git-config-group";
69
- }
70
- /** 本仓子进程腿的扣下记录:core 的形状臂 ∪ 本仓的目录臂 ∪ 名单型旋钮臂 ∪ 随残组扣下的 git 配置组成员。 */
71
- export type ServerSecretEnvFinding = SecretEnvFinding | CatalogSecretEnvFinding | NameListKnobEnvFinding | GitConfigGroupEnvFinding;
62
+ /** 本仓子进程腿的扣下记录:core 的形状臂 ∪ 本仓的目录臂 ∪ 名单型旋钮臂。 */
63
+ export type ServerSecretEnvFinding = SecretEnvFinding | CatalogSecretEnvFinding | NameListKnobEnvFinding;
72
64
  /** 配置目录里 `type:"secret"` 的 env 名(大写 —— Windows 的 env 名不分大小写,按大写比才不会漏)。 */
73
65
  export declare const CATALOG_SECRET_ENV_NAMES: ReadonlySet<string>;
74
66
  /**
@@ -76,11 +68,18 @@ export declare const CATALOG_SECRET_ENV_NAMES: ReadonlySet<string>;
76
68
  * 形状臂剥掉的记 core 的 finding;目录臂只记**形状臂漏下、被目录点名剥掉**的键 —— 于是既有的形状臂计数不变,
77
69
  * 目录臂的计数恰好就是 core 形状规则的缺口读数;名单型旋钮臂只记前两臂都漏下的(今天 = 不在服务目录里的 `BAKE_ENV_ALLOW`)。
78
70
  * 各腿不直接调它 —— 经 {@link hostSubprocessEnv}(剥 + 放回 + 留痕一只口)。
71
+ *
72
+ * **只为宿主子进程组合**:子进程与本进程同平台 ⇒ core `scrubSecretEnv` 的平台尾参(core 7.33.6 #1159:git 的 env 配置记录按子进程的平台读 ——
73
+ * POSIX 只认大写名,win32 大小写折叠)取缺省 `process.platform` 即对。本包跑在别的平台上的子进程(ssh / k8s / adb / e2b / docker 车道)的 env
74
+ * 不经本函数:那些车道不继承本进程 env,只交调用方显式给的 env 与部署方点名的键(`E2B_SANDBOX_ENV` / `DOCKER_SANDBOX_ENV`,经 {@link allowlistedEnv})。
79
75
  */
80
76
  export declare function scrubServerSecrets(env: NodeJS.ProcessEnv, findings: ServerSecretEnvFinding[]): NodeJS.ProcessEnv;
81
77
  /**
82
- * 🔴 **宿主子进程 env 的唯一组合**(S-684 残余按类收口,7.103.0):{@link scrubServerSecrets}(process.env) ∪ `allow` 点到的键
83
- * (经 {@link allowlistedEnv} 从原 env 原样放回),**最终没进子进程**的剥除键经 `site` 留痕(点名放回的键不是剥除,不计数、不披露)。
78
+ * 🔴 **宿主子进程 env 的唯一组合**(S-684 残余按类收口,7.103.0):{@link scrubServerSecrets}(env) ∪ `allow` 点到的键
79
+ * (经 {@link allowlistedEnv} 从同一份源 env 原样放回),**没有按原样交给子进程**的键(剥掉 / 换成 core 占位半条)经 `site` 留痕(点名放回的键取回源值,
80
+ * 不是剥除,不计数、不披露)。源 env 只在入参 `sourceEnv` 绑定一次(缺省 `process.env`):剥除源、放回源、谓词回读三处读的是同一份映射。`sourceEnv` 是测试缝 ——
81
+ * `src/` 里的调用一律缺省(只带一个实参;钉:`test/secret-env-scrub-host-legs.test.ts` 类级门「opus 验修 L-1」格),平台尾参取缺省的推理只在源 = 本进程 env 时成立。
82
+ * (入参不叫 `env`:D1 对账门按标识符名在全文件收 `= process.env` 缺省参数的别名,叫 `env` 会把 {@link allowlistedEnv} 的同名形参一并认成别名、多记一处动态读。)
84
83
  *
85
84
  * 六条宿主腿都只经它(直接或经 {@link gitSubprocessEnv}):host shell 缺省 scrub 腿、project-memory / center-plugins / leader 三条 git 腿、
86
85
  * 本地语言服务器、bake-runner 的每一只子进程组。`allow` 只在**有部署级出路**的现场非空(今天 = `LSP_ENV_ALLOW` / `BAKE_ENV_ALLOW`,
@@ -88,27 +87,20 @@ export declare function scrubServerSecrets(env: NodeJS.ProcessEnv, findings: Ser
88
87
  * 返回 `NodeJS.ProcessEnv` 形(值可能是 `undefined` 的键本来就不会进子进程);调用方显式要交的键(`LC_ALL`、`cfg.env` …)叠在它之上。
89
88
  * 调用时机 = 每次要交 env 时现算一次(`process.env` 在本仓是 boot 期输入,各次同值;计数因此是「每组合一次」)。
90
89
  *
91
- * 🔴 **组不变量(codex r1 C1 [high])**:逐键剥除不许把一组**按组生效**的 env 剥成残组。今天的实例是 git 的 env 配置组
92
- * (`GIT_CONFIG_COUNT` + `GIT_CONFIG_KEY_<i>` / `GIT_CONFIG_VALUE_<i>`):core 剥掉某个 `GIT_CONFIG_KEY_<i>`、COUNT 与 VALUE 照留 ⇒ 子进程里的
93
- * git 起手就 `missing config key`、退 128(任何读配置的子命令,不只要用那条配置的)。core ≤7.33.0 形状规则的 `KEY(_<数字>)?` 后缀剥**每一个**
94
- * `GIT_CONFIG_KEY_<i>`;core 7.33.1 #1129 起该族具名豁免,残组今天从**两侧**来:KEY 侧的值筛(值里是带 userinfo 的 URL —— 令牌
95
- * `url.<base>.insteadOf`)剥 KEY;core 7.33.2 起 VALUE 侧的值族表(`GIT_CONFIG_VALUE_<i>` 是 `Authorization:` / `Proxy-Authorization:` /
96
- * `Cookie:` 头、带 userinfo 的 URL,或 proxy 地址键下的 `user[:secret]@host` ⇒ `family-rule`)剥 VALUE。两侧的 URL userinfo 判据自 core 7.33.2
97
- * 起就是**同一只**(7.33.2 = 正则 `URL_USERINFO_RE`:`//` 到 `@` 之间无 `/` `?` `#`);core 7.33.3 #1140 只改这一只判据本身,剥得更多(`://` 后到
98
- * 第一个 `@` 的段:无 `/` `?` `#`,或第一个 `/` `?` `#` 之前有 `:`(段首 `[…]` 里的 `:` 不计)—— 密码里未转义的 `/` 也算;带端口的路径 `@` 过剥,
99
- * core KL-1129b);core 7.33.4 #1146 再改它、不再解析 authority:值里有 `:/`、其后任意位置有 `@` 或 `%40` 即剥,proxy 地址键下值里有 `@` / `%40` 即剥
100
- * —— 路径里的 `@`(`@scope` 包路径)与 scp 绝对路径带 `@`(`host:/repo@v2.git`)一并多剥(fail-closed,KL-1129b)。本不变量对哪一侧剥残的组都照样
101
- * 成立(车OR / 车PA / 车PB / 车PC 提货翻钉,零改码)。
102
- * 规则:交出去的组要么**源里有的成员一个不少**、要么**整组都不给**(值同扣 —— VALUE 里可能正是鉴权头);随组扣下的成员记
103
- * `kind: "git-config-group"`。本层只**多扣**、从不放回 core 判掉的键(core 的判据不在这里改;`GIT_CONFIG_KEY_<i>` 是不是凭据是 core
104
- * 形状规则的事,见 [ref] 登记);部署方点名放回全部 `GIT_CONFIG_KEY_<i>` 与 `GIT_CONFIG_VALUE_<i>`(有放行面的现场;core 7.33.2 起两边都可能被剥,
105
- * 只放回 KEY 仍是残组)⇒ 组完整,原样交出。源里本就残缺的组不是本层
106
- * 剥残的,不碰(git 对它照样报错 —— 那是部署方自己的配置)。
90
+ * 🔴 **git 的 env 配置组(`GIT_CONFIG_COUNT` + `GIT_CONFIG_KEY_<i>` / `GIT_CONFIG_VALUE_<i>`)只有 core 一只写手**(7.103.1:车PG 退役本层,车PK 提 core 7.33.6):
91
+ * 一条配置(KEY + VALUE)一次判定;COUNT 以内被扣的一条**原位**换占位(`GIT_CONFIG_KEY_<i>=sema.withheld` / `GIT_CONFIG_VALUE_<i>=""`,git 读成一条没人用的
92
+ * 配置)、不重排、COUNT 原样(#1160);COUNT 之外 / 没有 COUNT 的被扣条目整条不交;COUNT 按 git 的 `strtoul` 读(#1162);记录按子进程的平台读(#1159,见
93
+ * {@link scrubServerSecrets})。本层 7.103.0 的「整组扣」与 7.103.1 中间态的「按 pair 扣留 + 重编号」(D-7)都已退役,本层不重判(同一语义面上不留第二只写手)。
94
+ * 本仓按名两臂(目录 secret 名 / 名单型旋钮)不含这一族的名字(钉:`test/secret-env-git-config-pair.test.ts`「一只写手」格)。
95
+ * 点名放回按名叠在 scrub 结果之上 ⇒ 放回一条被扣配置要把 KEY 与 VALUE 两只一并点名(只点一只 = 与另一只的占位半条配成一对)。
96
+ * 🔴 **披露谓词 = 「这个名字没有按原样交给子进程」**(输入值 vs 输出值;core 7.33.6 pickup 注:「not handed on as written」)。被扣的记录成员在输出里
97
+ * **仍在**(装着占位半条)⇒「组合结果里有没有这个名字」恒为有;点名放回的名字取回源值 ⇒ 与输入同值 ⇒ 不是剥除;占位半条恰与输入同值的成员(KEY 里
98
+ * 带凭据、VALUE 本就是空串)子进程拿到的正是原值 ⇒ 也不披露。一只谓词盖住「剥掉」「换占位」「点名放回」三形,不按形分臂。
107
99
  */
108
100
  export declare function hostSubprocessEnv(o: {
109
101
  site: SecretEnvScrubSite;
110
102
  allow: readonly string[];
111
- }): NodeJS.ProcessEnv;
103
+ }, sourceEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
112
104
  /** git 子进程腿的现场(闭集里名字带 `.git-` 的那几只 —— 编译期筛;它们都没有点名放行面)。 */
113
105
  export type GitSubprocessSite = Extract<SecretEnvScrubSite, `${string}.git-${string}`>;
114
106
  /**
@@ -22,28 +22,12 @@ export function scrubServerSecrets(env, findings) {
22
22
  }
23
23
  return out;
24
24
  }
25
- export function hostSubprocessEnv(o) {
25
+ export function hostSubprocessEnv(o, sourceEnv = process.env) {
26
26
  const scrubFindings = [];
27
- const composed = { ...scrubServerSecrets(process.env, scrubFindings), ...allowlistedEnv(o.allow, process.env) };
28
- const stripped = scrubFindings.filter((f) => composed[f.key] === undefined);
29
- const grouped = withholdBrokenGitConfigGroup(process.env, composed).map((key) => ({ key, kind: "git-config-group", confidence: "high", source: "server-git-config-group" }));
30
- recordSecretEnvScrub(o.site, [...stripped, ...grouped], o.allow);
27
+ const composed = { ...scrubServerSecrets(sourceEnv, scrubFindings), ...allowlistedEnv(o.allow, sourceEnv) };
28
+ recordSecretEnvScrub(o.site, scrubFindings.filter((f) => composed[f.key] !== sourceEnv[f.key]), o.allow);
31
29
  return composed;
32
30
  }
33
- const GIT_CONFIG_GROUP_MEMBER_RE = /^GIT_CONFIG_(?:COUNT|(?:KEY|VALUE)_\d+)$/;
34
- function withholdBrokenGitConfigGroup(source, composed) {
35
- const isMember = (k) => GIT_CONFIG_GROUP_MEMBER_RE.test(k.toUpperCase());
36
- const sourceMembers = new Set(Object.keys(source).filter(isMember).map((k) => k.toUpperCase()));
37
- if (!sourceMembers.has("GIT_CONFIG_COUNT"))
38
- return [];
39
- const composedMembers = Object.keys(composed).filter(isMember);
40
- const present = new Set(composedMembers.map((k) => k.toUpperCase()));
41
- if ([...sourceMembers].every((m) => present.has(m)))
42
- return [];
43
- for (const k of composedMembers)
44
- delete composed[k];
45
- return composedMembers;
46
- }
47
31
  export function gitSubprocessEnv(o) {
48
32
  return {
49
33
  ...hostSubprocessEnv({ site: o.site, allow: [] }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.103.0",
3
+ "version": "7.103.1",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -61,8 +61,8 @@
61
61
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
62
62
  },
63
63
  "dependencies": {
64
- "@sema-agent/core": "7.33.4",
65
- "@sema-agent/settings-schema": "6.2.0",
64
+ "@sema-agent/core": "7.33.6",
65
+ "@sema-agent/settings-schema": "6.3.0",
66
66
  "e2b": "^2.28.0",
67
67
  "libsodium-wrappers": "^0.8.4",
68
68
  "mysql2": "^3.22.4",
@@ -65,7 +65,7 @@ The core resolved units (`SemaSettings`):
65
65
  "env": { "DEBUG": "true" },
66
66
  "model": "model-name",
67
67
  "outputStyle": "style-name",
68
- "webSearch": { "provider": "brave" | "tavily" | "searxng", "apiKey": "...", "endpoint": "...", "maxResults": 5 },
68
+ "webSearch": { "provider": "searxng", "endpoint": "https://search.example.com", "maxResults": 5 },
69
69
  "ultracode": true
70
70
  }
71
71
  ```
@@ -74,20 +74,38 @@ Notes:
74
74
 
75
75
  - **Permission rule syntax** is upstream-compatible: exact match `"Bash(npm run test)"`, prefix wildcard `"Bash(git:*)"`, tool-only `"Read"`.
76
76
  - `webSearch` and `ultracode` are sema-only keys (no upstream equivalent).
77
+ - `webSearch.provider` is `"brave"`, `"tavily"` or `"searxng"`. The block also accepts an `apiKey`, but that field holds the key itself (not the name of an env var), so this skill never writes it: for a keyed provider (brave / tavily), leave `webSearch` out of the settings files and have the human export `WEB_SEARCH_PROVIDER` and `WEB_SEARCH_API_KEY` in the environment the engine starts from. A `webSearch` block in settings replaces that environment backend for the request; it does not borrow its key.
77
78
  - Settings files accept the wider upstream settings.json superset; unknown keys are tolerated, but the units above are what the sema resolver folds across layers.
78
79
 
79
80
  ## Model configuration (sema-specific)
80
81
 
81
82
  Models are NOT configured via a bare `"model": "sonnet"` alias. Two channels, both under the user layer:
82
83
 
83
- 1. **Model catalog** — `<config-home>/config.d/models.json`: a multi-slot catalog of entries `{ name, id, api, baseUrl, apiKeyEnv, contextWindow, maxTokens }` where `api` is `"anthropic-messages"` or `"openai-completions"`. A catalog with enabled models REPLACES the env catalog, and the FIRST enabled entry is the default model. Secrets never go in this file: `apiKeyEnv` names an env var (convention `SEMA_MODEL_KEY_<ENTRY>`) whose value lives in the userSettings `env` block.
84
- 2. **Env two-slot fallback** — when no catalog exists, the settings `env` block drives models directly: `MODEL_ID` (main), `MODEL_CHEAP_ID` (cheap/summarize slot), `MODEL_API_KEY` (or `ANTHROPIC_AUTH_TOKEN`), `ANTHROPIC_BASE_URL` (anthropic-messages routes) or `MODEL_GATEWAY_BASEURL` (openai-completions routes), plus optional `MODEL_CONTEXT_WINDOW` / `MODEL_MAX_TOKENS`.
84
+ 1. **Model catalog** — `<config-home>/config.d/models.json`: a multi-slot catalog of entries `{ name, id, api, baseUrl, apiKeyEnv, contextWindow, maxTokens }` where `api` is `"anthropic-messages"` or `"openai-completions"`. A catalog with enabled models REPLACES the env catalog, and the FIRST enabled entry is the default model. Secrets never go in this file: `apiKeyEnv` names an env var (convention `SEMA_MODEL_KEY_<ENTRY>`); the variable's **value** is set by the human out of band (the shell or service environment the engine starts from), never by this skill.
85
+ 2. **Env two-slot fallback** — when no catalog exists, the settings `env` block drives models directly: `MODEL_ID` (main), `MODEL_CHEAP_ID` (cheap/summarize slot), `ANTHROPIC_BASE_URL` (anthropic-messages routes) or `MODEL_GATEWAY_BASEURL` (openai-completions routes), plus optional `MODEL_CONTEXT_WINDOW` / `MODEL_MAX_TOKENS`. The credential for this route (`MODEL_API_KEY` or `ANTHROPIC_AUTH_TOKEN`) is **not** one of the slots this skill edits — see the credentials rule below.
85
86
 
86
- Prefer editing the catalog when it exists; only touch the env slots for catalog-less installs.
87
+ Prefer editing the catalog when it exists (it never carries secrets); only touch the env slots for catalog-less installs.
88
+
89
+ ## Credentials — hard rule
90
+
91
+ This skill never handles credential values (API keys, tokens, anything shaped like `*_KEY` / `*_TOKEN` / `apiKey`). Whatever a tool shows you from a settings file enters the model context and the session transcript, and a settings file may already hold credentials the user put there (an `env` block, a `webSearch.apiKey`). A line-oriented read does not keep them out: `grep` for one key prints its whole line, and a one-line or minified JSON file is a single line. So:
92
+
93
+ - Never write a credential value into any settings file, and never read one back or restate it. If the user asks you to set an API key or token, decline to write it and tell them to export it in the environment the engine starts from (shell profile / service unit), out of band.
94
+ - For model changes prefer `config.d/models.json`: it names credentials by env var only and is safe to read and edit.
95
+ - Never open a settings file that may hold credentials (`settings.json` / `settings.local.json` at any layer) with any tool that reads file contents — Read, Grep, Edit, Write, or Bash `cat` / `head` / `tail` / `less` / `jq` / `grep` / `sed` / `awk`. Change it with one short script that parses the JSON, touches only the key you were asked to change, writes the file back, and prints nothing but the names of the keys it changed. The script must never print file contents or exception messages: on any failure it prints a fixed error code and exits non-zero (a runtime's own error for a malformed file can quote the offending line, credentials included). For example, to set the main model slot:
96
+
97
+ ```sh
98
+ node -e 'const fs=require("fs");const f=process.argv[1];let j;try{j=JSON.parse(fs.readFileSync(f,"utf8"))}catch{console.error("settings-json-unparseable");process.exit(2)}if(!j||typeof j!=="object"||Array.isArray(j)){console.error("settings-json-not-an-object");process.exit(2)}j.env??={};if(!j.env||typeof j.env!=="object"||Array.isArray(j.env)){console.error("settings-env-not-an-object");process.exit(2)}j.env.MODEL_ID="new-model";try{fs.writeFileSync(f,JSON.stringify(j,null,2)+"\n")}catch{console.error("settings-json-write-failed");process.exit(3)}console.log("changed: env.MODEL_ID")' "<file>"
99
+ ```
100
+
101
+ To check a key before changing it, print that one key the same way — and only when it is a scalar leaf (a string, number or boolean) that is not a credential slot. Never print an object or an array: it can carry credentials nested inside.
102
+ - If a change cannot be made that way — the file does not parse as JSON, deciding the change would need a credential's value, or no script runtime is available — stop. Tell the user to move the credentials out of the file first (into the environment the engine starts from), then make the change.
103
+ - Do not quote, summarize or re-emit any `env` value in your reply or notes.
104
+ - A settings file that already holds credentials is a risk worth naming once to the user: recommend moving them to the process environment.
87
105
 
88
106
  ## How to apply a change
89
107
 
90
108
  1. Pick the narrowest file that satisfies the scope and trust rules above.
91
- 2. Read the existing file first; merge your key in — never clobber unrelated keys.
109
+ 2. Merge your key in — never clobber unrelated keys. A settings file is changed only through the JSON projection script from the credentials rule (never through a tool that reads its contents); `config.d/models.json` carries no secrets and can be edited normally.
92
110
  3. Settings files are watched: edits hot-reload without a restart. If a change appears ignored, check for a resolver rejection (trust/tighten violation) before assuming a bug.
93
111
  4. For "do X automatically every time Y" requests, configure a hook — do not promise behavior from memory.