import { type SshHostKeyPolicy } from "../ssh-host-key.js"; 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 SshEnvConfig { host: string; port?: number; username: string; /** PEM private key — from the adapter-held secret env (NEVER the workspace). [ref] §5 #2. */ privateKey: string; passphrase?: 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; /** Workspace root; relative paths resolve against it. Default "." (the login dir). */ mountPath?: string; /** Connect handshake bound (ms). */ readyTimeoutMs?: number; /** * Per-command wall-clock bound (ms) when the caller gives none — NOT the connection lifetime. * * ⚠️ **值与孪生腿不一致,原因未成文**(2026-08-08,[ref] 二轮扫描 finding,**候裁**):本腿默认 * `5 * 60_000`(见构造处),而同判据的另外两条腿 —— `remote-env-host.ts` / `remote-env-local-docker.ts` * 的 `DEFAULT_COMMAND_TIMEOUT_MS` —— 都是 `30 * 60_000`,JSDoc 措辞还逐字同源。三处无共享常量、无对表 * 钉,ssh 这一处也没有任何注释说明 5min 是刻意收紧(git 史是 history-free snapshot,「30min 是后来调 * 参、ssh 漏调」的假说无从验证)。后果:一条跑满 6 分钟的构建命令在 host/docker 腿上正常收尾、在 ssh * 腿上被判超时,而两腿都「按自己的默认值」正确工作。 * (`remote-env-k8s.ts` 的 30min **不属**同判据 —— 那是 pod 的 `activeDeadlineSeconds`,env 寿命, * 与 e2b sandbox lifetime 同族,别把它数进来。) * 取哪个值是行为面决定 ⇒ 留给属主裁,本批只把分歧成文,不动值。 */ commandTimeoutMs?: number; /** File-transfer (sftp) bound (ms). */ dataTimeoutMs?: number; /** connect retry (idempotent establishment only — withRetry, [ref] §2A). */ retry?: { maxAttempts?: number; backoffMs?: number; }; /** [ref] / [ref]:主机密钥校验策略(两旋钮,语义见 {@link SshHostKeyPolicy});缺席 = 两旋钮都没配。 */ hostKey?: SshHostKeyPolicy; } type ConnectResult = { ok: true; value: WorkspaceHandle; } | { ok: false; error: RemoteExecutionError; }; /** * Test seam ([ref] §9.3 fault-injection): inject a `connectOnce` that replays an error sequence, so the * retry policy (retryable codes / maxAttempts / "first fail → second success") is deterministically testable * without a live host. */ export interface SshEnvDeps { connectOnce?: (signal?: AbortSignal) => Promise; } export declare class RemoteSshExecutionEnv implements RemoteExecutionEnv { /** * 🔴 **S-354 / core 7.20.0([ref])`canonicalPathAuthoritative: true`** —— 本适配器的 `canonicalPath` * 就是**命令真正跑的那块文件系统**的权威答案(`posix-shell-fs` 的 `canonicalPath` 原语,在真机上解到落点)。声明它 = 告诉引擎:整链答不出来是**解析器出了事** * (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; }; /** Working directory; relative paths resolve against it (ExecutionEnv contract). Starts at mountPath. */ cwd: string; private conn?; private sftpPromise?; private destroyed; /** [ref] 代际闸:destroy/reconnect 各自 +1。逻辑连接操作(startConnect)起飞时抓拍一次并贯穿全部 * 重试轮(四轮复审:抓拍若在 connectOnce 内,被顶替代的下一轮重试会抓到**新**代际混进新代)—— * 每轮起跳先查代际/destroyed(陈旧=不再开 socket),ready 到达再查一次(在飞握手 late-ready=end * 不赋 conn)。关死「A 在飞→reconnect 起 B→A 竞后抢占/成孤儿活连接」的全部竞窗。 */ private connEpoch; /** [ref] 四轮复审 single-flight:connect()/reconnect()/懒连接三路共用一条代际键在飞记录——任何一路 * 在飞时其余路 join 同一 promise(同代际),绝不并起两条连接;settle 时按记录身份清(旧败诺清不掉 * 新记录)。五轮复审:①记录带 kind——并发 reconnect join 已在飞的 replace 记录(不再各自 bump 代际 * =重连风暴);②共享尝试**不携带任何 caller 信号**(首创者的 pre-abort 不得毒化 joiner;单 caller * 取消只退它自己的等待,底层握手余命 ≤ readyTimeout 有界,彻底取消归 destroy 代际闸)。 */ private inFlight?; /** core `ExecutionEnv.homeDir`([ref]):本沙箱/目标机上执行用户的 home。见配置同名字段。 */ readonly homeDir?: string | undefined; private readonly cfg; private readonly injectedConnectOnce?; constructor(config: SshEnvConfig, deps?: SshEnvDeps); private resolve; workspaceHandle(): WorkspaceHandle; connect(config?: RemoteConnectConfig): Promise<{ ok: true; value: WorkspaceHandle; } | { ok: false; error: RemoteExecutionError; }>; /** 五轮复审:per-caller 可中止等待(共享诺不动,只退本 caller)。 */ private raceConnAbort; /** [ref] 四轮复审:三路(connect/reconnect/懒连接)唯一的连接发起点——同代际在飞记录 join,settle * 身份清;代际抓拍一次贯穿全部重试轮,每轮起跳先查(陈旧/destroyed=不再开 socket,用不可重试的 * aborted 码即刻收敛 withRetry)。五轮复审:共享尝试零 caller 信号(取消面全在 per-caller race)。 */ private startConnect; /** [ref] exec/execStream/sftp 共用的懒连接入口。已连=零开销直过;未连=走完整 connect()(含重试策略)。 * caller 的 signal 只中止**它自己的等待**(共享在飞尝试继续伺服其他等待者;彻底取消归 destroy 的代际 * 闸管,handshake 余命 ≤ readyTimeout 有界)。 */ 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; }>; /** Re-establish a dropped connection (supported — unlike suspend, reconnect is idempotent establishment). */ reconnect(_sessionToken: SessionToken): Promise<{ ok: true; value: WorkspaceHandle; } | { ok: false; error: RemoteExecutionError; }>; /** Close the SSH connection. Does NOT destroy the machine (real system) — idempotent, never throws. */ destroy(): Promise; cleanup(): Promise; exec(command: string, options?: ExecOpts): Promise>; execStream(command: string, options?: ExecStreamOptions): AsyncIterable; private sftp; absolutePath(p: string): Promise>; joinPath(parts: string[]): Promise>; readTextFile(p: string, abortSignal?: AbortSignal): Promise>; readTextLines(p: string, options?: { maxLines?: number; abortSignal?: AbortSignal; }): Promise>; readBinaryFile(p: string, abortSignal?: AbortSignal): Promise>; writeFile(p: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise>; appendFile(p: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise>; fileInfo(p: string, abortSignal?: AbortSignal): Promise>; listDir(p: string, abortSignal?: AbortSignal): Promise>; private posixFsInst?; private get posixFs(); 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>; private withCwdEnv; private connErr; private fileError; } /** * `ExecutionEnvFactory` for the SSH backend — one fresh (unconnected) env per task against the same host * (lazy: connects on first use). Config (host/user/key) is control-plane-supplied; the Runner owns the * lifetime and calls `destroy()` (= disconnect) on task end. */ export declare function sshExecutionEnvFactory(config: SshEnvConfig): ExecutionEnvFactory; export {}; //# sourceMappingURL=remote-env-ssh.d.ts.map