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"; /** Options accepted by `ExecutionEnv.exec` (core does not re-export the type; derive it from the seam). */ type ExecOpts = Parameters[1]; /** Construction config for {@link RemoteHostExecutionEnv}. */ export interface HostEnvConfig { /** * Base directory under which this adapter creates its per-task working directory. Default {@link os.tmpdir}. * The actual workspace is `/sema-host-` and is `rm -rf`'d on {@link destroy}. */ workspaceBase?: string; /** * Stable id mixed into the per-task workspace dir name (e.g. the sessionId). A short random suffix is always * appended so two envs sharing an id never collide. Default = a random id. */ id?: string; /** * Out-of-band env injected into EVERY command (secrets etc.). The control plane resolves secret env-NAMEs * from the worker's own `process.env` and passes the resolved values here — they never enter the model * prompt or the tool command string. Merged UNDER per-command `options.env` (per-call wins). The host's * own `process.env` is the default base (a local coding agent inherits the user's environment). */ env?: Record; /** * 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; /** Per-command wall-clock bound (ms) when the caller gives none. Default {@link DEFAULT_COMMAND_TIMEOUT_MS}. */ commandTimeoutMs?: number; /** Shell-child env policy (D1; mirrors core's `NodeExecutionEnv`). Default `"scrub"`: inherit the worker's * `process.env` but DROP secret-pattern keys (core's `scrubSecretEnv` — `*_KEY`/`*_TOKEN`/`*_SECRET`/`*_PASSWORD`…) * so a model-driven shell can't `printenv DEEPSEEK_API_KEY` and exfiltrate orchestrator secrets — keeps PATH/HOME/ * locale/build (low breakage). `"all"` = inherit everything (explicit opt-out, trusted local dev). A `string[]` = * strict allowlist. `env`/per-command `options.env` ALWAYS pass through verbatim. */ inheritEnv?: "scrub" | "all" | string[]; /** @deprecated use {@link inheritEnv}. `true`→`"all"`, `false`→`[]` (hermetic). Ignored if `inheritEnv` is set. */ inheritProcessEnv?: boolean; /** * EXACT persistent working directory (TOC, CC-like): when set, the adapter uses THIS dir verbatim — NO * `sema-host--` per-task subdir — and **NEVER `rm -rf`s it on {@link destroy}**. It is the * user's OWN directory (e.g. the cwd `run-local` was invoked in), not an adapter-created ephemeral sandbox, * so the agent's file edits PERSIST and are user-visible (like a local coding agent working in your repo). * Takes precedence over `workspaceBase`/`id` (those are ignored). Default unset = ephemeral random + cleanup. */ workspaceDir?: string; /** R7 self-wake: the injected `SchedulerCapability` backend (the TOC self-wake scheduler write side). * When set, `hasScheduler(env)` is true and core mounts CronCreate/CronDelete/CronList(旧名 CronCancel;Sleep 已 [ref] 撤除;挂载在 prepare-task 非 createHandsToolkit——L8 订正); when absent the env * reports `supported:false` → INERT (no scheduler tools). Injected at boot only on the single-user host lane. */ scheduler?: SchedulerCapability; /** [ref] background shell: `false` → INERT (multi-tenant host lane gate). Default (undefined) = on for the * single-user TOC host lane. `HOST_BG_ENABLED=false` is an additional global kill switch(🪦 旧名 * `HOST_BG_DISABLED` 自 server 3.0.0 起是 fail-loud 墓碑:设了即拒启并指路新名,不再被读取)。 */ backgroundShell?: boolean; } export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, SchedulerCapability, BackgroundShellCapability { /** [ref] §9 / DUAL-MODE-DESIGN §5: a `host` env is the operator's real machine — no isolation, no snapshot. */ /** * 🔴 **S-354 / core 7.20.0([ref])`canonicalPathAuthoritative: true`** —— 本适配器的 `canonicalPath` * 就是**命令真正跑的那块文件系统**的权威答案(worker 本机的 `fs.realpath`)。声明它 = 告诉引擎:整链答不出来是**解析器出了事** * (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; }; /** R7 自唤醒: structural delegation to the injected scheduler backend. `hasScheduler(env)` checks BOTH the method * presence (always, since we implement the interface) AND `schedulerCapabilities.supported===true` — which is only * true when a backend was injected, so a host env without one stays INERT (the four scheduler tools don't mount). */ get schedulerCapabilities(): SchedulerCapability["schedulerCapabilities"]; schedule(intent: ScheduledIntent, ctx: SchedulerContext): Promise>; cancel(id: ScheduledTaskId, ctx: SchedulerContext): Promise>; list(ctx: SchedulerContext): Promise>; /** Working directory; relative paths resolve against it (ExecutionEnv contract). Starts at the workspace root. */ cwd: string; /** core `ExecutionEnv.homeDir`([ref]):本沙箱/目标机上执行用户的 home。见配置同名字段。 */ readonly homeDir?: string | undefined; private readonly workspaceDir; private readonly cfg; /** Workspace created on disk (lazily, on first use). */ private prepared; /** Terminal once {@link destroy} ran — guards fs/exec from running against a removed workspace. */ private destroyed; /** [ref] 五轮复审:fg/bg spool 根=tmpdir 私有目录(core bgSpoolDir 同形)。workspace 是命令可写区, * `git clean -fdx` 类清理会 unlink 活跃 spool(子进程经继承 fd 对 unlinked inode 继续写,而父进程 * 按路径 statSync/read 静默断=输出丢失+轮转帽失效);顺带不再弄脏 user-owned workspace(TOC/CC * persistent 形)。懒建;destroy 整树清。 */ private spoolRootDir?; /** 🔴 在飞的 fg spool 目录登记表(2026-07-25)。`destroy()` 原来只看 `bgManager.hasShells()` 就 `rm -rf` * spool 根 —— 而**前台** exec 的 spool 也在同一个根下,子进程通过继承的 fd 继续往已 unlink 的 inode 写, * 父进程按**路径** stat/read 于是什么都看不到 ⇒ 命令照跑、副作用照落盘,但 stdout 被静默截断,还返回 * `exitCode 0`。对模型而言那与「成功且无输出」**不可区分**。(与 core [ref] 同形:写已释放的 inode。) * 判据与 `hasShells()` 并列:**只要还有人在读写这个根下的文件,就不删它**。 */ private readonly liveFgSpools; /** 🔴 S-539 在飞的 fs **写**登记表(`liveFgSpools` 同律:「还有人在动这棵树,就别删它」)。 * 为什么按 promise 存而不是计数:只有 promise 才能被 `destroy()` **await**;计数只能轮询。 */ private readonly inflightFs; /** 一次性:慢 drain 只抱怨一次(一条按次数刷屏的 teardown 读数没人会看)。 */ private inflightFsDrainWarned; /** * 🔴 S-539 —— 把一只**会在 workspace / spool 根里建路径或改路径**的 fs 动作登记为「在飞」,直到它 settle。 * * ── 病(旧 `destroy()` 的自称与实情)────────────────────────────────────────────────────────── * `destroy()` 只 await 了 `ensurePrepared()` 那只 memo(`preparing`),于是它顶注里的 * 「eliminating the prepared-flag race entirely」只对 **prepare 那一段**成立。写面在 * `await this.fsReady(p)` 放行**之后**还会自己碰一次磁盘(`writeFile`/`appendFile` 的 * `mkdir(dirname)`、`createDir` 的 `mkdir`、`createTempDir`/`createTempFile` 在 spool 根下建), * 那一次调用与 destroy 的 `fs.rm` 是**两条独立 fs 调用在 libuv 线程池里赛跑**:rm 先 ⇒ `mkdir -p` 把已拆除 * 的工作区重建成**孤儿**(而 rm 成功后 `untrackEphemeralHostWorkspace()` 已注销 ⇒ 兜底腿也捞不回); * mkdir 先 ⇒ 随后 rm 删掉,调用方拿到 `EINVAL`/`ENOENT` 而不是 "already destroyed"。 * * ── 治(一处定义,十几个面共用)──────────────────────────────────────────────────────────────── * 调用点必须在方法体的**第一个同步段**里调它(第一个 `await` 之前),登记才与 `destroy()` 置 `destroyed` * 的同步段互斥。于是二者只有两种相对次序,**两种都安全**: * · 先登记 ⇒ destroy 的快照看得到它,`rm` 等它 settle 之后才发; * · 后登记 ⇒ 这一面自己的 `fsReady` 看得到 `destroyed`,一个字节都不碰磁盘。 * 登记放在 `await this.fsReady(...)` **之后**就没有这个性质:那一刻已经跨过一个 microtask,destroy 可能刚好 * 在这中间把快照取完了。所以形状是 `return this.withInflightFs(async () => { …原方法体… })`, * 而不是在方法体里补一句 `if (this.destroyed)` —— 后者仍然不原子(查完到动手之间还有让出点)。 * * ── 读面刻意不进来 ───────────────────────────────────────────────────────────────────────────── * ① 读不建路径:拆除之后一次在飞的读拿到 ENOENT 是**诚实**的,复活目录这条病与它无关; * ② 把读拉进来会让 teardown 去等一次可能**永不 settle** 的读(workspace 里的 FIFO / 特殊文件), * 把 destroy 挂死 —— 拿一条新的可用性病换一条不存在的正确性收益。 * 判据因此是一句话:**「可能建路径或改路径的那些面」进在飞集合**,不是「过了 fsReady 的都进」。 * * ── 不止文件面(S-541)───────────────────────────────────────────────────────────────────────── * 判据是「会不会建/改路径」,**不是**「在不在 `env` 的文件面上」。后台 shell 的 `launch`(`makeBgDriver`) * 在 spool 根下 `mkdir` 一棵子树,而它整段期间 `bgManager.hasShells()` 还是 false(`shells.set` 在 * `await launch(ctx)` 之后)⇒ 同一条竞态的另一张脸,同一只集合治。见那处头注。 */ private withInflightFs; /** * 等 `destroy()` 取到的那批在飞写全部 settle。**永不抛**;答「在时限内清空了吗」。 * * 🔴 两条都不能要,所以有第三条(合并复审 [high] 打出来的): * ① 无界等 —— workspace 里的 FIFO / 特殊文件会让一次 `writeFile` **永不 settle**,而拆除在 run 收尾路径上 * ⇒ 整条收尾停摆。用一条新的可用性病换一条正确性收益,不划算。 * ② 到点就照删 —— 把 S-539 的竞态**原样**搬回来(rm 与在飞 mkdir 再次在线程池里赛跑),只是把窗口从 * 「总是」改成「慢的时候」;一条确定的病换一条间歇的病,更糟。 * ③ **到点就不再占着调用方,但 rm 依旧排在那批写之后**(`destroy()` 里的 `void …then(removeOwnedTrees)`)。 * 次序保证一个字节不松,teardown 变成「有界返回」。teardown 契约(core 1.258.2)禁的是**抛**,不是**慢**; * 这一条连慢都不欠。 * * 快照是 `destroy()` 在同步段取的,所以 destroy **之后**才登记的那些面(唯一一族:`appendFile` 的 * `lingeringBgWrite` 残余镜像写)不在这一批里 —— 它们按设计就是跨 destroy 存活的,等它们等于等后台 shell。 */ private drainInflightFs; private ensureSpoolRoot; /** True when `workspaceDir` was given (user-owned persistent dir) → destroy() must NOT delete it. */ private readonly persistent; /** Memoized lazy `mkdir -p` so concurrent first-use prepares the workspace exactly once. */ private preparing?; private readonly handle; constructor(config?: HostEnvConfig); workspaceHandle(): WorkspaceHandle; connect(config?: RemoteConnectConfig): Promise<{ ok: true; value: WorkspaceHandle; } | { ok: false; error: RemoteExecutionError; }>; 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; }>; /** The "connection" is the local process; there is nothing to re-establish. Idempotent success (unless destroyed). */ reconnect(_sessionToken: SessionToken): Promise<{ ok: true; value: WorkspaceHandle; } | { ok: false; error: RemoteExecutionError; }>; /** `rm -rf` the per-task workspace (NOT the machine). Idempotent, best-effort, never throws (cleanup contract). * * core 1.258.2 destroy 契约注(remote-env.d.ts):非隔离 env(host lane,capabilities.isolation * 缺席)的 destroy = 工作区/对象级清理,**不得收割仍在跑的后台进程**——收割全权归 `disposeBackgroundShells` * (它带 timeout/session keep-alive except 名单)。此前这里的无-except 全灭把 * core 留活过的驻留 watcher 又杀了一遍(run 尾 dispose→destroy 顺序,destroy 是第二刀)。隔离 env(容器/VM) * 整体拆机自然带走进程,与本契约不冲突(k8s/e2b 各自 destroy 不走这条路径)。 */ destroy(): Promise; /** `destroy()` 真正动手删的那一段(本 env **自有**的两棵树:spool 根 + ephemeral workspace)。 * 抽成一只是因为它有两个调用时机 —— 在飞写按时清空时由 `destroy()` 直接 await,超时时挂在那批写后面异步跑 * (见 {@link drainInflightFs} 头注③)。永不抛(teardown 契约)。 */ private removeOwnedTrees; cleanup(): Promise; get backgroundCapabilities(): BackgroundShellCapability["backgroundCapabilities"]; private _bgManager?; private get bgManager(); private makeBgDriver; spawnBackground(command: string, options?: BackgroundSpawnOptions): Promise>; /** * [ref] detach (core 1.207, clay 排 1.81): ADOPT a still-running FOREGROUND exec child as a background * shell — the mid-flight ctrl+b path. The exec child writes to pipes (not files, unlike a `launch`-spawned * bg shell), so adoption splices those live pipes into fresh bg spool files: pre-fill them with the output * captured so far (the detach TEE, fed from the exec's FIRST chunk → TaskOutput's first read has the full * history), then append every subsequent chunk. From then on the shell is indistinguishable from a spawned one * (poll by cursor, kill by pgid, the BG-timeout wall applies). Returns the opaque shellId, or `undefined` when * the live-shell limit is hit (detach refused → the exec keeps running in the foreground; NEVER a dropped process). * * `memOut`/`memErr` are the exec's tee-from-start buffers (Fable-5 review #3, mirrors the E2B tee): they were fed * every chunk since process start THROUGH THE SAME per-stream StringDecoder that keeps feeding post-detach, so a * multibyte codepoint straddling the detach instant decodes correctly (the old seed-from-fg-tail handoff decoded * the two halves in separate passes → U+FFFD at the seam). */ private adoptRunningExec; pollBackground(shellId: BackgroundShellId): Promise>; killBackground(shellId: BackgroundShellId): Promise>; disposeBackgroundShells(opts?: { except?: readonly BackgroundShellId[]; }): Promise; exec(command: string, options?: ExecOpts): Promise>; /** 管道旧形(win32 / spool 打开失败的回退;行为=1.225 及以前的 exec 原样)。 */ private execViaPipes; /** [ref] fg spool pair:tmpdir 私有根下专属子目录(五轮复审迁址,workspace=命令可写区不可放)+ "ax" * 独占建(core openSpoolPair 同形)。失败=undefined(调用方回退管道形)。 */ private openFgSpool; /** [ref] spool 形 fg exec(core node-execution-env 1.318-1.321 机制移植;坑图=core CHANGELOG 同段)。 */ private execViaSpool; /** [ref] spool 形 detach adoption:spool 路径+fg 游标直接进 HostBgState 的 FILE 面(launch 同形,poll * 共码);同一文件单一字节流=天然无 decoder 接缝(mem-tee 版为此专门修过的病在此结构性不存在)。 */ private adoptRunningExecSpool; execStream(command: string, options?: ExecStreamOptions): AsyncIterable; 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>; /** S-539:整只方法体(**含** `fsReady` 那道门)走 `withInflightFs` —— 登记必须与调用方同一同步段, * 否则 destroy 的快照可能刚好在 `await this.fsReady(...)` 的让出点上取完。见那处头注。 */ writeFile(p: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise>; /** S-539(写面一律 `withInflightFs`,见那处头注)。本面是唯一能在 `destroyed` **之后**过门的写面 * (`lingeringBgWrite`):那一族登记在 destroy 的快照**之后**,所以 destroy 不会去等它们 —— 正确, * 它们按设计跨 destroy 存活(等它们等于等后台 shell)。 */ appendFile(p: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise>; fileInfo(p: string, abortSignal?: AbortSignal): Promise>; listDir(p: string, abortSignal?: AbortSignal): Promise>; canonicalPath(p: string, abortSignal?: AbortSignal): Promise>; readLink(p: string, abortSignal?: AbortSignal): Promise>; /** * S-362 / core 7.21.0 [ref] —— **实现**(表态:本腿实现 `canonicalChain`)。host 腿是 node:fs 腿,所以按 * 提货单 [ref] @server 五适配器段的第一行,与 core 的 `NodeExecutionEnv.canonicalChain` **同形**:对整条 * 拼法 `readlink`,再对每一跳 `readlink`,**上限 64**、**只锚不折**、**遇环即止**;win32 宿主上跳到共享 * (UNC)时**记下该跳并停止探测**(不碰网络,CC `vr` 对网络拼法只给拼法)。 * * 🔴 **不复用 core 的实现而是同形重写**:`NodeExecutionEnv` 是 core 的内部类,包根不导出它的这只方法; * 而本腿的每一次读都必须先过 {@link fsReady}(2026-07-25 全族统一:拆除之后只读面不得与写面自相矛盾), * 那道门在 core 的实现里不存在。同形的判据钉在 `test/remote-env-canonical-chain.test.ts`。 * * 链尽的三种 errno(`EINVAL` 不是软链 / `ENOENT` 不存在 / `ENOTDIR`)⇒ `ok` + 到此为止的跳;其余 errno * (`EACCES` …)⇒ `ok:false`(core 读作「这一次没有跳」,**永不是拒绝**)。非链接的叶答 `[]`。 */ canonicalChain(p: string, abortSignal?: AbortSignal): Promise>; exists(p: string, abortSignal?: AbortSignal): Promise>; /** S-539(写面一律 `withInflightFs`,见那处头注)。 */ createDir(p: string, options?: { recursive?: boolean; abortSignal?: AbortSignal; }): Promise>; /** S-539(写面一律 `withInflightFs`,见那处头注)。本面**不会**复活目录(`fs.rm` 只删),进在飞集合的理由是 * 同一条:destroy 的 rm 把树从它脚下抽走 ⇒ 一次本该 ok 的删返回 `not_found`。判据是「可能建或改路径」, * 不是「可能复活目录」—— 少一条例外就少一处以后要重新想清楚的地方。 */ remove(p: string, options?: { recursive?: boolean; force?: boolean; abortSignal?: AbortSignal; }): Promise>; /** 🔴 临时文件/目录**不放进 workspace**(2026-07-25 修)。 * 原来两者都建在 `this.workspaceDir` 下,而 **TOC/CC 形的 workspaceDir 就是用户自己的目录**,且 `destroy()` * 明文永不删它 ⇒ core 给**每个**后台 bash 都调一次 `createTempFile({prefix:"bash-task-", suffix:".output"})` * ⇒ 用户仓库根目录按后台任务数累积 `bash-task-.output`(内容=命令输出)+ 每次 `createTempDir` 一个 * `tmp-XXXXXX/`,**永久**留在他们的 git 树里。两条独立判据说明这是偏差不是设计:①core 自己的 TOC 参照实现 * `NodeExecutionEnv.createTempFile` 放的是 `os.tmpdir()`;②**本适配器早就为了同一个理由把 spool 根搬去了 * tmpdir**([ref] 五轮复审明写"不再弄脏 user-owned workspace"),只是 createTempFile/Dir 没跟上。 * 改用本 env 自己的 tmp 根(`ensureSpoolRoot()`,已在 `os.tmpdir()` 下): * · 用户目录不再被写入; * · 镜像写仍然可用 —— `isLingeringMirrorTarget` 同批放行这个根(见那里的旁注); * · ephemeral 形反而更好:`destroy()` 在**有后台 shell 在册**时不删这个根,而 workspace 是会被 rm 的。 * ⚠️ 这是 **host-only**:k8s/local-docker 的临时文件必须落在**容器内**可见的 cwd,ssh/e2b 本来就用远端系统 tmp。 */ /** S-539(写面一律 `withInflightFs`,见那处头注)。这一面建在 **spool 根**下,而 destroy 也会整树删那个根 —— * `fs.mkdtemp` 不建父目录,所以它不会复活什么,坏处是一次本该成功的调用返回 `not_found`。 */ createTempDir(prefix?: string, abortSignal?: AbortSignal): Promise>; /** S-539(写面一律 `withInflightFs`,见那处头注;spool 根同 `createTempDir`)。 */ createTempFile(options?: { prefix?: string; suffix?: string; abortSignal?: AbortSignal; }): Promise>; /** Resolve a (possibly relative) path against {@link cwd} like the other adapters. Host paths are platform-native. */ private resolve; /** * S-260 ②:**建之前**先判「我这个用户能不能在那儿建」。 * * 不判的话,唯一的读数是 `failed to create host workspace …: EACCES: permission denied, mkdir '<随机子目录>'` * —— 它说的是那个**随机**子目录建不出来,而真正的事实是**父目录**(`HOST_WORKSPACE_BASE`,缺省 * `os.tmpdir()`)对本进程的运行用户不可写。运维拿着那条 errno 会去查那个随机路径(它当然不存在), * 查不到根因;更糟的是每一个任务都复发一次同一条。⇒ 前判把它换成一句能直接照做的拒绝:哪个目录、 * 哪个用户、改哪个旋钮。 * * 只对 **ephemeral** 形做(persistent 形是用户自己的目录,`mkdir -p` 一个已存在的只读 checkout 本来 * 就该成功 —— 在那儿加可写判据会把"只读仓库上跑只读任务"这个正当用法拒掉)。 * root 与非 POSIX 平台跳过:root 的可写判据恒真(root-squash 的网络盘上反而会假拒), * 而 `process.getuid` 不存在时这条 POSIX 权限判据无从谈起。 */ private assertWorkspaceParentWritable; /** Lazily create the per-task workspace exactly once (memoized; concurrent first-use shares it). */ private ensurePrepared; private shellReady; /** * @param opts.lingeringBgWrite 只由 {@link appendFile} 传 true —— 允许「destroy 之后、仍在册的后台 shell * 的输出镜像写」。 * * 🔴 为什么需要这个口(2026-07-25,cli dogfood 报的现象追出来的真因):`destroy()` 无条件置 * `destroyed = true`,于是文件面**全拒**;而 `pollBackground` 直通 bgManager、**没有** destroyed 门。 * 这与本适配器自己遵守的 core 契约(1.258.2「destroy 不得收割后台进程」,上方 destroy() 里也为 * 「dispose 前仍可轮询」的残余刻意保留了 spool 根)直接打架:我们**刻意**让后台 shell 跨 destroy 存活并 * 继续被轮询,却拒掉它们要写的那个文件 ⇒ core 的 `appendFile` 一失败即置 `spool.mirrorFailed`,用户在一个 * 其实成功的后台任务上看到「[!] The output file is INCOMPLETE」。已用真子进程执行验证过这个不对称。 * * 放行判据刻意收紧到三条同时成立,少一条都拒: * ① `persistent` —— 只有 TOC/CC 形(构造时给了 `workspaceDir` = 用户自己的目录)。那一形 destroy() 明文 * 「NEVER rm -rf it」,什么都没删,文件确实还在,core 还告诉用户「Read it any time」⇒ 报 INCOMPLETE 是谎。 * **ephemeral 形不放行**:它的 destroy() 真的 rm -rf 了 workspace,而 `createTempFile` 恰把 outputFile * 建在 workspace 里 ⇒ 镜像目标已被删,拒写是**诚实**的;放行反而会让 appendFile 的 mkdir -p 把一个已拆除 * 的工作目录**复活**成孤儿目录。 * ② 还有后台 shell **在册** —— 判据与 destroy() 里"跳过删 spool 根"用的是同一个 `hasShells()`,即 * 「还有人在读写这些文件」这一个事实,不是两套规则。没有 shell 在册时 `destroyed` 照旧是硬护栏。 * ③ 只对 append 一个动作开 —— 不是把整个文件面在 destroy 之后重新开门。 * 用 `this._bgManager?.` 而不是惰性 getter:拆除路径上不该顺手 **新建** 一个 manager。 */ /** destroy 后「残余镜像写」的**路径**判据 —— 与方法判据(`lingeringBgWrite`)并列,两者都满足才放行。 * * 🔴 为什么需要它(深挖第一轮打在我自己头上的一条,2026-07-25):我原先的放行只看方法标志,于是 destroy 之后 * `appendFile` 能**新建**文件、`mkdir -p` 建整棵树、甚至写 workspace **之外**的绝对路径 —— 而我在 fsReady * 头注里写的是「别的文件动作在 destroy 之后没有正当理由」。探针实测把这个不一致钉了出来:说是"窄",在路径 * 维度上一点都不窄。 * * 收紧到恰好贴合唯一的正当调用方(core 的后台输出镜像): * ① 目标必须在 **workspace 之内**(`createTempFile` 就把 outputFile 建在这儿)—— 挡住"写到工作区外面"; * ② 目标**必须已经存在** —— 镜像写的文件是 destroy **之前**建好的,所以"新建"从来不是这条路的需求; * 这一条同时挡掉 `mkdir -p` 建新目录树的副作用。 * 同步 `existsSync` 而不是 await:这是拆除路径上的判据,不该再引入一次异步让出。 */ private isLingeringMirrorTarget; private fsReady; /** Base env (process.env when inheriting) merged with the adapter's secret env, then per-command overrides win. */ /** D1 secure default = `"scrub"`: inherit `process.env` minus secret-pattern keys (core's `scrubSecretEnv`, single * source of truth). `"all"` = full inherit (opt-out); a `string[]` = strict allowlist. Legacy `inheritProcessEnv` * maps `true`→`"all"` / `false`→`[]`. */ private inheritMode; private mergeEnv; private fileError; } /** * `ExecutionEnvFactory` for the TOC `host` backend (DUAL-MODE-DESIGN §5). Wiring this onto a deployment makes * its agent run DIRECTLY on the worker's machine (no container) — the local-coding-agent default. One fresh * env per task; the Runner owns the lifetime and calls `destroy()` (= `rm -rf` the workspace) on task end. * * Mirrors `e2bExecutionEnvFactory`: returns the env **unconnected** (lazy) and folds the per-task * `ctx.sessionId` into the workspace dir id so two concurrent tasks never share a working directory. */ export declare function hostExecutionEnvFactory(config?: HostEnvConfig): ExecutionEnvFactory; export { localDockerExecutionEnvFactory, type LocalDockerEnvConfig, type LocalDockerEnvDeps, RemoteLocalDockerExecutionEnv } from "./remote-env-local-docker.js"; import { type LocalDockerEnvConfig, type LocalDockerEnvDeps } from "./remote-env-local-docker.js"; /** * Config for the `remote-docker` provider: run each task in a container on a REMOTE docker daemon * (DUAL-MODE-DESIGN §5 — "a user-specified REMOTE host's docker"; registry-core `RemoteExecSpec`). * * It is {@link LocalDockerEnvConfig} with the endpoint made REQUIRED — that is the whole difference, by design: * the container adapter is already daemon-agnostic (workspace `mkdir`'d INSIDE the container, never a host * bind-mount; bytes over `docker cp` / `docker exec cat`, which stream through the daemon API), and the endpoint * rides as `-H ` on every invocation. So every local-docker knob (memory/cpus/pidsLimit/dropAllCaps/ * network/timeouts/env/dockerPath) applies unchanged to the remote lane. */ export interface RemoteDockerEnvConfig extends Omit { /** Remote docker daemon endpoint (e.g. `tcp://host:2376` or `ssh://user@host`). REQUIRED for remote-docker. */ dockerHost: string; } /** * `ExecutionEnvFactory` for the TOC `remote-docker` backend (DUAL-MODE-DESIGN §5): a per-task container on * SOMEONE ELSE'S docker daemon (offload — the operator's box stays free; isolation:true, suspendable:false). * Same lifetime contract as local-docker: unconnected/lazy, one container per task, `destroy()` = `docker rm -f`. * * 🔴 **Endpoint credentials are the docker CLI's own** — this adapter deliberately carries none. The docker CLI * reads `DOCKER_CERT_PATH` + `DOCKER_TLS_VERIFY` from the worker's environment (inherited by every spawn) for a * `tcp://` + TLS daemon, and uses `ssh-agent` / `~/.ssh/config` for an `ssh://` one. That keeps key material out * of this process's config objects entirely. Consequence to know: `tcp://…:2376` WITHOUT those env vars fails * loud at connect (the daemon refuses plaintext) — it does not silently downgrade; a plaintext `tcp://…:2375` * daemon is unauthenticated and remains the operator's decision, exactly as in the registry-core schema. * (The registry-core `remote-docker` arm additionally models `tlsCertEnv`/`tlsKeyEnv`/`tlsCaEnv`/`sshKeyEnv` = * env-NAMEs the control plane resolves to PEM BYTES. Materializing those bytes to short-lived 0600 files and * passing `--tlsverify --tlscert/--tlskey/--tlscacert` is the remaining config-surface follow-up, together with * the `provider:"remote-docker"` arm on `AppConfig.remoteExec` — see the note on that union in config-types.ts.) * * Throws at factory-BUILD time on a missing/blank endpoint: a "remote" lane with no endpoint would otherwise * quietly become the LOCAL daemon — an isolation-topology surprise, so it fails loud instead. */ export declare function remoteDockerExecutionEnvFactory(config: RemoteDockerEnvConfig, deps?: LocalDockerEnvDeps): ExecutionEnvFactory; //# sourceMappingURL=remote-env-host.d.ts.map