import { spawn } from "node:child_process"; 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"; type ExecOpts = Parameters[1]; export interface AdbEnvConfig { /** Device serial or `host:port` (TCP). `host:port` triggers `adb connect` on connect()/reconnect(). */ serial: string; /** Path to the adb binary. Default "adb" (must be on PATH in the runner image). */ adbPath?: string; /** * S-213④ / core [ref] —— 本执行环境的 **home 目录**(`ExecutionEnv.homeDir` 座,绝对路径,本环境自己的 * 命名空间里)。`~/…` 形权限规则(`Edit(~/.ssh/**)`)对**跑在本 env 里的调用**就以它为基。 * 缺席 ⇒ 本 env 声明「我没有 home」,core 对 `~/` 规则判 `unreadable`(fail-closed 的 ask), * 绝不回落引擎进程自己的 home(那在远端腿上是另一个用户的另一个目录 —— 静默守错目录正是 [ref] 的病)。 * 值由 `boot/execution-env.ts` 从**唯一属主** `execution-lane-caps.ts` 的 `executionLaneHomeDir` 递进来; * adapter 不自算(两处各算一份 = 422 门与引擎对同一条规则给相反答案)。 */ homeDir?: string; /** On-device workspace root; relative paths resolve against it. Default "/data/local/tmp". */ mountPath?: string; commandTimeoutMs?: number; dataTimeoutMs?: number; retry?: { maxAttempts?: number; backoffMs?: number; }; } type ConnectResult = { ok: true; value: WorkspaceHandle; } | { ok: false; error: RemoteExecutionError; }; /** * Test seam ([ref] §9.3 fault-injection): inject `spawn` and/or a `connectOnce` that replays an error * sequence, so the retry policy + first-attach behaviour is deterministically testable without a real device * (the E2B-adapter bug that only surfaced in dogfood is the lesson this guards against). */ export interface AdbEnvDeps { spawn?: typeof spawn; connectOnce?: (signal?: AbortSignal) => Promise; } /** [R78]#1: adb stderr markers that mean the DEVICE TRANSPORT died mid-op (not a normal command failure) — * typed retryable (reconnect + retry per idempotency), never a fake command result. Exported for tests. * * 🔴 **S-401 把这个判据的输入面改了一半(复审 F12 纠正:原来写的「此前 stderr 恒空 ⇒ 整条是死码」是 * **overclaim**,被本车自己的真机读数推翻)**。`exec-out` 合流的是**设备**的 stderr;**adb 客户端自己的** * 诊断在两种传输下都照样走宿主 stderr。真机实测(同一台设备,拔掉 serial): * `adb -s BOGUS exec-out 'true'` ⇒ host exit **255** + stderr `error: device 'BOGUS' not found`; * `adb -s BOGUS shell -T -- 'true'` ⇒ host exit **1** + stderr `adb: device 'BOGUS' not found`。 * ⇒ 本判据在 S-401 之前**对 adb 客户端级的传输故障是工作的**(这也正是原作者把词表写成这些 adb 自家 * 措辞的原因);S-401 改变的是**设备侧 stderr 从此也进这条通道**。 * * ⚠️ **随之而来的、如实登记的一格**:分流之后落到这里的 stderr **既有 adb 自己的诊断,也有设备命令自己的 * 输出**(adb 客户端把两者写在同一个 fd 上,真机实测无法在传输层分开)。于是设备上一条自己打印 * `Connection reset by peer` 的命令(curl/nc)会被判成 `transport_lost`:本次命令结果不返回、`connected` * 置假、下一次 exec fail-closed 直到 reconnect。**刻意不收窄词表**——词表收窄的方向是「漏判一次真掉线」, * 那是 fail-open;误判的方向是多断一次连接,是 fail-closed。[ref] 轴上后者必须赢(能力探测的失败方向要与 * 能力语义方向一致)。ssh / k8s 两腿不吃这一格,因为它们的传输层自己给「通道关闭却没有 exit status」这个 * **结构**信号;adb 的 shell v2 没有等价物,所以这里只能靠标记词,**这条臂就把话说在这里**。 */ export declare function isAdbTransportLost(stderr: string): boolean; export declare class RemoteAdbExecutionEnv implements RemoteExecutionEnv { /** * 🔴 **S-354 / core 7.20.0([ref])`canonicalPathAuthoritative: true`** —— 本适配器的 `canonicalPath` * 就是**命令真正跑的那块文件系统**的权威答案(`posix-shell-fs` 的 `canonicalPath` 原语,在设备 shell 里解到落点)。声明它 = 告诉引擎:整链答不出来是**解析器出了事** * (RPC 断、传输掉),不是「一个它看不见的命名空间」⇒ 读边界的执行期复核**拒**,而不是让词法判决站着。 * 缺席是今天每个适配器的读法(整链沉默 ⇒ 词法判决成立、命令照跑),所以这一声明是**收紧**方向。 * 严格读法 `=== true`(core `canonicalPathIsAuthoritative`);`isSuspendable` 一族同样严格,对本仓的 * 布尔字面量零影响(三键都写死字面 `true`/`false`,不存在「非布尔值被读成缺席」那一形 —— 那一形由 * core 的 `config.execution_env_capability_invalid` 普查响亮播报,本仓的受众表已登记)。 * 谁该声明由闭集门看着(`test/execution-lane-caps.test.ts` 的逐 lane 对账:新 lane 不表态 = 编译红)。 */ readonly capabilities: { isolation: boolean; suspendable: boolean; canonicalPathAuthoritative: boolean; }; cwd: string; private connected; /** 🔴 拆除是**终态**(2026-07-25 补齐:此前本适配器是同族里唯一没有这个概念的一条腿)。 * 没有它的后果实测:`destroy()` 只清 `connected`,于是任何残留引用**一次 `connect()` 就把 env 整个复活**, * `exec` 又能在真设备上跑;而 `reconnect(sessionToken)` 就在同一个接缝上摆着。读面更松 —— 它连 * `connected` 都不查,所以 destroy 之后 `readTextFile` 照样从真设备把文件读出来。 * 同姿态的 ssh 腿早有先例(`remote-env-ssh.ts`「destroy 后一律不复活,显式 connect/reconnect 同禁」), * 五条兄弟腿全部 fail-closed,只有 adb 例外 —— 补齐它。 */ private destroyed; /** 在飞的那一次懒连接(单飞记忆)——见 {@link ensureConnected}。settle 即清,**不缓存败诺**。 */ private connectPromise?; /** core `ExecutionEnv.homeDir`([ref]):本沙箱/目标机上执行用户的 home。见配置同名字段。 */ readonly homeDir?: string | undefined; private readonly cfg; private readonly spawnFn; private readonly injectedConnectOnce?; constructor(config: AdbEnvConfig, deps?: AdbEnvDeps); private resolve; workspaceHandle(): WorkspaceHandle; connect(config?: RemoteConnectConfig): Promise<{ ok: true; value: WorkspaceHandle; } | { ok: false; error: RemoteExecutionError; }>; /** * 懒连接:**首次调用任一面**(exec / execStream / 文件面)时自动 `connect()`(含重试策略),在飞的 * 那一次由全部调用者共用,失败**不缓存**(下一次调用重新试),`destroyed` 是终态不复活。 * * 🔴 为什么这条必须在 adapter 里:core 的 task/run 车道**没有任何人**替 adapter 调 `connect()` —— * 工厂铸出 env 就直接用它的 exec/文件面(本仓亲核:`src/` 里对执行环境的 `.connect()` 只有 bench 与 * 生命周期面自己)。所以一条「设了未连闸、又不自连」的腿,它的每一次工具调用都被自家的闸挡回; * 那不是 adb 特有语义,而是与 ssh([ref] 同一实红点)/ k8s / e2b / local-docker / device 不均匀。 * 未连时的错误文案因此从「call connect() first」(这条车道上没人能照做)换成**真试过之后**的拒因。 */ private ensureConnected; private connectOnce; suspendVM(_options?: VmLifecycleOptions): Promise<{ ok: true; value: SnapshotId; } | { ok: false; error: RemoteExecutionError; }>; resumeVM(_snapshotId: SnapshotId, _options?: VmLifecycleOptions): Promise<{ ok: true; value: WorkspaceHandle; } | { ok: false; error: RemoteExecutionError; }>; postResumeInit(): Promise<{ ok: true; value: void; } | { ok: false; error: RemoteExecutionError; }>; reconnect(_sessionToken: SessionToken): Promise<{ ok: true; value: WorkspaceHandle; } | { ok: false; error: RemoteExecutionError; }>; /** `adb disconnect` the TCP device (USB: just drop our flag). Does NOT reset the phone — idempotent, never throws. */ destroy(): Promise; cleanup(): Promise; exec(command: string, options?: ExecOpts): Promise>; execStream(command: string, options?: ExecStreamOptions): AsyncIterable; absolutePath(p: string): Promise>; joinPath(parts: string[]): Promise>; /** 文件面的门 —— 与 host/local-docker/k8s/e2b 的姿态对齐(它们的文件面在未连/已拆时都 fail-closed)。 * * 🔴 **S-401 复审 F2:这道门此前只查 `destroyed`,不查 `connected`**,于是 `readBinaryFile` / * `readTextFile` / `readTextLines` / `appendFile` 能在**从没连过**的 env 上直接打一次真设备往返。 * 在本批之前那只是个不一致;本批**新开的 `shell_v2` fail-closed 闸就长在 `connect()` 里**, * 所以「不查 connected」= 那道闸对整个读面**不生效** —— 而 `readTextFile` 把错误文本当文件内容返回 * 正是本 P1 点名的症状之一。闸的射程必须覆盖它要保护的每一个面,否则它只是一句话。 * ⇒ 规则收成一条:**这只 env 在 connect 成功之前、destroy 之后,文件面一个字都不答。** * (`exec`/`execStream` 本就是这条规则,见 `:257`/`:276`;文件面从此同判,不再是两套。) */ private fsGate; readBinaryFile(p: string, abortSignal?: AbortSignal): Promise>; readTextFile(p: string, abortSignal?: AbortSignal): Promise>; readTextLines(p: string, options?: { maxLines?: number; abortSignal?: AbortSignal; }): Promise>; writeFile(p: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise>; appendFile(p: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise>; private posixFsInst?; private get posixFs(); fileInfo(p: string, abortSignal?: AbortSignal): Promise>; listDir(p: string, abortSignal?: AbortSignal): Promise>; readLink(p: string, abortSignal?: AbortSignal): Promise>; /** S-362 / core 7.21.0 [ref] —— **实现**(表态:本腿实现 `canonicalChain`)。一次往返里的一个 `readlink` * 循环(上限 64、只锚不折、遇环即止),脚本与读法的单一属主在 `remote-shell.ts`,四条 shell 腿共用同一份。 * 缺席读法用不上(本腿在场);`ok:false` 由 core 读作「这一次没有跳」,永不是拒绝。 */ canonicalChain(p: string, abortSignal?: AbortSignal): Promise>; canonicalPath(p: string, abortSignal?: AbortSignal): Promise>; exists(p: string, abortSignal?: AbortSignal): Promise>; createDir(p: string, options?: { recursive?: boolean; abortSignal?: AbortSignal; }): Promise>; remove(p: string, options?: { recursive?: boolean; force?: boolean; abortSignal?: AbortSignal; }): Promise>; createTempDir(prefix?: string, abortSignal?: AbortSignal): Promise>; createTempFile(options?: { prefix?: string; suffix?: string; abortSignal?: AbortSignal; }): Promise>; /** Spawn the adb binary with the given args; collect stdout/stderr (+ raw bytes when binary), bound by timeout/abort. */ private adb; /** * 🔴 **S-401 —— 本适配器跑设备命令的唯一往返形**(`exec` / `execStream` / `readBinaryFile` 三个调用点 * 共用一只;此前 `exec-out` 写了三遍,于是「传输到底给不给退出码」这件事没有属主)。 * * **`adb shell`(shell protocol v2)而不是 `adb exec-out`。** 协议出处 = adb 自己的 `adb help`: * `-x: disable remote exit codes and stdout/stderr separation` —— 即**不带** `-x` 的 `adb shell` 就是 * 「远端退出码 + stdout/stderr 分流」那一形。`exec-out` 走的是另一条 `exec:` 服务:单条裸流,设备 * stderr **合流进 stdout**,宿主进程退出码**恒 0**。 * * 真机实测(车GV 2026-09-17,SM-S721B / Android 15 / toybox 0.8.11 / adb 1.0.41 `35.0.2`,设备 features * 含 `shell_v2`),逐条复现而不是从文档推断: * - `exec-out 'false'` ⇒ 宿主 exit **0**;`shell -T -- 'false'` ⇒ **1**;`shell -T -- 'exit 7'` ⇒ **7** * - `exec-out 'realpath -- <坏链>'` ⇒ exit 0 + 错误文本在 **stdout**;`shell -T --` ⇒ exit 1 + 在 **stderr** * - 1 MiB 随机字节 `cat` 回读:两形 md5 **相同**(`shell -T` 无 CRLF 转换、无包裹字节),故 binary 面照用 * - 同一条 `realpath` 的墙钟:`shell -T` 68 ms/次 vs `exec-out` 79 ms/次(n=10)—— 不是性能代价 * * 修前的后果链([ref] 安全轴静默 fail-open):共享基座 `posix-shell-fs.ts` 的**每一处**失败判据(7 处) * 与链原语 `remote-shell.ts` 的 `runSymlinkChain`(第 8 处)认的都是 `exitCode !== 0`,而这条传输恒答 0 * ⇒ adb 腿上 `realpath` / `readlink` / `stat` / `ls` / `cat` **永不失败**,`canonicalPath` 把那行**错误 * 文本**当成 canonical key 交给敏感路径门。⚠️ **复审 F15 纠正:原文写「五处」是数错了**,而且漏掉的 * 第 8 处恰恰是最贵的一处 —— `canonicalChain`(S-362 / core 7.21.0 [ref],上一版刚接)在 exit 恒 0 下会把 * **合流进 stdout 的错误文本当成链上的跳**解析出来,等于给 core 的路径判官递一条**伪造的** symlink 链。 * * 🔴 **`-T` 与 `--` 都是承重件,不是装饰**: * - `-T` = 不分配 pty。带 pty 的交互 shell 会把 stderr 并进 stdout 并做 CRLF 转换(council §9.2 的那句话 * 本身没错,错的是它被用来给 `exec-out` 背书)。给了命令的 `adb shell` 默认本就不开 pty,`-T` 是**显式** * 关闭,不依赖那个默认。 * - `--` = 终止 adb 自己的旗标解析。没有它,**首字符为 `-` 的设备命令会被 adb 当成自己的旗标吃掉** * (真机复现:`adb shell -T '-x'` 不报错,直接变成一个挂住的交互 shell)—— 而 `-x` 恰好就是关掉 * 远端退出码与分流的那根旗标,即「一条以 `-x` 开头的命令能静默把本修关回去」。真机验证 `--` 之后的 * 参数原样落到设备 shell(`adb shell -T -- '-x'` ⇒ 设备答 `/system/bin/sh: -c: requires an argument`)。 * * 命令永远是**最后一个**参数,整条包在一个 argv 元素里(设备侧 `sh -c` 收全串,与 `withCwdEnv` 的 * `cd … && …` 前缀语义一致)。 */ private deviceShellArgs; private withCwdEnv; } /** * `ExecutionEnvFactory` for the ADB backend — one fresh (unconnected) env per task against the same device * (lazy: `adb connect` on first use). Config (serial) is control-plane-supplied; the Runner owns the lifetime * and calls `destroy()` (= `adb disconnect`, never a device wipe) on task end. */ export declare function adbExecutionEnvFactory(config: AdbEnvConfig): ExecutionEnvFactory; export {}; //# sourceMappingURL=remote-env-adb.d.ts.map