/** * Shared shell/path helpers for the SSH + ADB `RemoteExecutionEnv` adapters. Kept in ONE place so the * security-sensitive quoting can't drift between adapters (council design/#4, #5). */ import type { ExecutionError, FileError, FileInfo, RemoteExecutionError, Result, SymlinkChain } from "@sema-agent/core"; import type { ChildProcess } from "node:child_process"; /** * D5 pipe-destroy grace (parity with core's vendored `EXEC_FORCE_SETTLE_GRACE_MS`, harness/env/nodejs.js): * after a `kill()` on an abort/timeout, the child's `close` may NEVER fire — a re-parented grandchild that * inherited the stdout/stderr pipe (or a D-state process) can hold those FDs open indefinitely. Our buffered * exec already SETTLES the promise synchronously on abort/timeout (no hang), but the dangling pipe FDs leak. * So after a kill we arm a short grace timer; if `close` hasn't fired by then we `destroy()` the pipes to * force the FDs released. Core does NOT export this const (it's a vendor-local value), so service defines its * own — a plain number, not a security/behavioural boundary. */ export declare const EXEC_FORCE_SETTLE_GRACE_MS = 3000; /** * D5 helper: arm a one-shot grace timer that `destroy()`s {@link ChildProcess.stdout}/`stderr` if the child's * `close` never lands after a kill (a re-parented grandchild holding the pipe → dangling FD). Returns the * timer so the caller can {@link clearTimeout} it the moment a real `close` (or any settle) arrives — the timer * is `unref`'d so it can never keep the process alive on its own. This ONLY frees pipe FDs; it does NOT settle * any promise (the buffered exec already settled synchronously on abort/timeout — that path is unchanged). */ export declare function armPipeDestroyGrace(child: ChildProcess): ReturnType; /** POSIX single-quote a string for safe interpolation into a remote shell command (the only metacharacter * inside '' is ' itself, closed/escaped/reopened). */ export declare function shellQuote(s: string): string; /** Derive FileInfo kind from POSIX st_mode bits (raw stat/readdir attrs carry no isDirectory() helper). */ export declare function kindFromMode(mode?: number): FileInfo["kind"]; export declare function drainNumEnvWarnings(): Array<{ env: string; raw: string; }>; export declare function numEnvOr(name: string, def: number, min: number): number; export declare const SYMLINK_CHAIN_MAX_HOPS = 64; /** * 一次往返走完叶 symlink 链的 POSIX sh 脚本(**纯数据**:返回一个命令串 ⇒ `build*`,CLAUDE.md 工厂律)。 * * 🔴 `readlink` **缺失**必须与「不是软链」分开:两者在 shell 里都是 exit≠0,而 `|| break` 会把「工具不在」 * 静默读成「零跳」—— 那是安全轴上的静默兜底(链视图整条消失,判官少一个视图)。所以脚本**先探**一次 * `command -v`,缺失时打一句 `readlink: not found` 到 stderr 并 exit 127 —— 那句话正好命中 * {@link import("./remote-env-file-error.js").classifyFsStderr} 的**命令缺失护栏**(它把命令缺失归 * `unknown` 而不是 `not_found`),于是调用方拿到的是 `ok:false`(core 读作「这一次没有跳」,永不是拒绝) * 而不是一个伪造的空链。 * * 🔴 **整条命令包在一个 `{ …; }` 里**,理由是**调用方会给它加前缀**:四条腿的 `withCwdEnv` 都拼 * `cd && <命令>`(ssh `:760` / k8s `:1418` / adb `:578` / local-docker 同形)。这里连着犯过两次错, * 两次都记下来,因为它们是**同一个坑的两面**: * · 写成 `command -v … || { …; }`:`||` 与前缀的 `&&` 结成一条链(`cd X && command -v … || { … }`), * **`cd` 失败时跑的是探测的失败臂** —— 报「readlink 不在」而真相是「那个目录没了」,运维去修一个没坏的东西; * · 改成独立语句 `if ! …; fi; c=…; while …`:前缀的 `&&` 只短路**那一条 `if`**,后面的循环照跑 —— * 于是「`cd` 失败 ∧ readlink 缺失」这一格里,`|| break` 把工具缺失吞成零跳,脚本 **exit 0 + 空 stdout**, * 调用方拿到一条**伪造的空链**。这正是探测本来要挡的那个静默兜底([ref] 轴),换了个入口回来了。 * ⇒ 正解是**让整条命令只有一个退出状态**:包进 `{ …; }`,前缀的 `&&` 要么统治全部、要么全部不跑。 * `cd` 失败 ⇒ 退出码来自 `cd` 自己、stderr 是它自己的话 ⇒ `ok:false`(core 读作「这一次没有跳」), * 诚实且指向真因;`cd` 成功 ⇒ 探测与循环照旧。`{ }` 不是子 shell,里面的 `exit 127` 照样结束整条命令。 * * 词法细节(逐条对着 core 的 Node 实现写,两边同形):绝对目标直接接上;相对目标用参数展开 * `${c%/*}` 取**当前这条链接**所在目录(不调 `dirname` —— toybox 的 `dirname` 不认 `--`,且这里零外部命令 * 更省一次 fork),根目录那一格(`${c%/*}` 为空)补 `/`。**全程不折叠 `..`**。 */ export declare function buildSymlinkChainCommand(absPath: string): string; /** * {@link buildSymlinkChainCommand} 的 stdout → `SymlinkChain.hops`。 * * 三件事**只在这里**做一次(四条 shell 腿 + e2b 共读): * · **环即止** —— 出现重复跳时在**重复的那一跳之前**截断(CC `vr` 的 `d.has(u)` 同律),且 seen 表用 * **被寻址的拼法播种**(S-408,core 裁 2026-09-17:契约句「不重复被寻址的拼法」的正解是**起点本身不是一跳**—— * 链视图列的是「从被寻址拼法出发经过的中间拼法」,链绕回起点即结束、起点不再列;成环的事实由终点判决 * `canonicalPath`(realpath ELOOP ⇒ unresolvable ⇒ 拒)承担,不由链视图承担)。于是 `a -> b`、`b -> a`,问 `a`, * 答 `[b]`,与 core `NodeExecutionEnv.canonicalChain`(`new Set([resolved])` 播种)逐字节同形。此前本族从空表 * 开始、答 `[b, a]`(7.82.0 及之前;多一个冗余视图 —— 判官对 `a` 已按拼法判过一次),裁定后对齐。 * 脚本侧不做: * POSIX sh 里维护一张 seen 表要么靠内嵌换行的 `case` 模式(易碎),要么另起一个进程;放在这里则 * **一份实现**被四条腿与 e2b 共用,且与 host 腿的 Node 循环逐字节对得上。代价如实登记:链上真有环时, * 远端在**那一次往返内**最多多跑到 64 次 `readlink`(有界,不是无界探测),而报出来的跳与 host 腿相同。 * · **上限 64** —— 脚本已经封顶,这里再切一次是对**传输层**的不信任(答多了不当真),不是第二份判据。 * · **逐行 trim** —— 与本族 `readLink` 的既有姿态**同一条**(基座头注:链接目标含换行是病态形,按 trim * 处理,不假装支持)。残留如实写出来:目标拼法真带首尾空白时,trim 后的跳可能与真目标差几个字节 —— * 方向上只会让一条跳**匹配不到**受保护行(少问,不是多放行),而终点判决(`canonicalPath`)完全不经本路。 */ export declare function symlinkChainHopsFromStdout(stdout: string, addressed: string): string[]; /** {@link runSymlinkChain} 的传输面 —— 各腿的 `exec` 收敛成的那一个形(与 `PosixShellFs` 基座的 `run` 同形)。 */ export type ShellChainRun = (cmd: string) => Promise>; /** * **shell 形 `canonicalChain` 的全部半场,一处**(对抗复审 F4 [low],验真后按类修)。 * * 此前脚本与读法收在这里,而「怎么把它们接到传输上、exit≠0 怎么归码」在**两处**各写一遍: * `posix-shell-fs.ts`(四条 shell 腿)与 `remote-env-e2b.ts`(原生 SDK 腿)。两份五行代码语义相同、 * 错误归类却各写各的,于是「e2b 与四条 shell 腿答得一样」只靠一句注释担保 —— 复审的换靶变异实测: * 把 e2b 那五行改成 `return ok({ hops: [] })`,**全部钉照绿**。收成一只函数之后,那条担保是结构事实: * 两边调的是同一个函数,分叉无处可生;基座上那一整套行为钉同时就是 e2b 的钉。 * * 归码(两腿共用同一条,不再各写各的): * · 传输自己失败 ⇒ {@link fileErrorFromExec}(`aborted` 穿透,其余诚实降级 `unknown`); * · exit≠0 ⇒ {@link classifyFsStderr} 且 fallback 取 **`unknown` 而不是 `not_found`** —— 这条命令上 * 「叶不是软链」与「叶不存在」**都是 exit 0 + 空 stdout**(脚本里的 `|| break`),所以任何 exit≠0 * 都**不是**「这个文件不在」,而是解析器自己出了事(工具缺失 / cwd 没了 / 传输坏 / shell 拒)。 * 把它折成 `not_found` 会让运维去找一条根本没丢的路径。 * ⚠️ 例外由 `classifyFsStderr` 自己认:`cd` 失败那一格的 stderr 里真带「No such file」,它按**那句话** * 归 `not_found` —— 那时真的有一条路径没了(工作目录),所以那个码是对的。 */ export declare function runSymlinkChain(absPath: string, run: ShellChainRun): Promise>; //# sourceMappingURL=remote-shell.d.ts.map