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, type BackgroundShellCapability, type BackgroundShellId, type BackgroundShellError, type BackgroundSpawnOptions, type BackgroundPoll } from "@sema-agent/core"; import type { PodSpecPatch } from "./k8s-exec-protocol.js"; type ExecOpts = Parameters[1]; export { buildBgRunnerScript, buildBgLauncherScript, buildBgPollScript, buildBgKillScript, buildBgDisposeScript, buildDetachCapableExec, parseBgPollOutput, type ParsedBgPoll, } from "./k8s-bg-scripts.js"; export interface K8sEnvConfig { /** API server URL. Default: in-cluster (`https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT`). */ apiUrl?: string; /** Bearer token. Default: in-cluster service-account token file. */ token?: string; /** API server CA (PEM). Default: in-cluster ca.crt. */ caCert?: string; /** Skip TLS verification (lab only — never production). */ insecureTls?: boolean; namespace?: string; /** Sandbox pod image (a plain docker image present in the cluster). REQUIRED. */ image: string; /** RuntimeClass for the VM boundary. Default "kata-qemu" (set "" to run plain runc — tests/dev only). */ runtimeClass?: 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 inside the pod; created on connect. Default "/workspace". */ mountPath?: string; /** Pod lifetime (activeDeadlineSeconds) — the leak guard. Default 30min. */ timeoutMs?: number; /** Pod schedule+pull+start bound. Default 120s (image pull can be slow once per node). */ readyTimeoutMs?: number; /** Control-plane RPC wall-clock (pod create/get/delete). */ rpcTimeoutMs?: number; /** Zero-progress liveness bound for commands (no output AND not finished ⇒ hang). */ livenessMs?: number; /** File-transfer bound. */ dataTimeoutMs?: number; cpu?: string; memory?: string; retry?: { maxAttempts?: number; backoffMs?: number; }; /** * Workspace-snapshot store ([ref] §5 WorkspaceHandle/SnapshotId). When set, the Kata pod * gains a DURABLE workspace across suspend/resume: `suspendVM` tars the workspace and uploads it to S3, then * deletes the pod; `resumeVM` provisions a fresh pod and restores the tar. This raises `capabilities.suspendable` * to `true` — but only the *workspace* is durable (files restored byte-for-byte); in-VM process/memory state * (a running language server) is NOT, which `postResumeInit`'s honest rebuild + core's read-before-edit hashing * tolerate. Unset → `suspendable:false` (the original checkpoint + cold-rebuild degrade). * * 🔴 Credentials are ADAPTER-HELD: the sandbox only ever receives a short-lived, single-object PRESIGNED URL * (never the access/secret key) — a credential reaching code that runs untrusted work is the [ref] lethal * trifecta. The presigned URL is used only inside an adapter-issued exec at suspend/resume time, so it never * reaches an agent turn. */ s3Snapshot?: { endpoint: string; bucket: string; accessKey: string; secretKey: string; region?: string; keyPrefix?: string; presignTtlSec?: number; }; /** Set by the factory from the task's ExecutionEnvFactoryContext — namespaces snapshot keys per session. */ sessionId?: string; /** * 🔴 ADDITIVE pod-spec patch (a SERVICE-side adapter seam, NOT a core seam: the K8s pod shape is * provider-specific, so it stays in the adapter per [ref]). A deployment supplies extra volumes / volumeMounts * / securityContext so the pod can carry e.g. a REAL read-only oracle mount (the RSI L8 immutable-mount probe) or * a hardened grader env (the graderEnvFactory). DEFAULT absent ⇒ the pod is byte-identical. The patch is TRUSTED * deployment config (like the rest of K8sEnvConfig) AND is STRUCTURALLY CONFINED: {@link applyPodSpecPatch} can * ONLY ADD volumes/mounts/securityContext/labels — it can NEVER override the hard isolation invariants * (runtimeClassName, automountServiceAccountToken:false, restartPolicy:Never, activeDeadlineSeconds, the container * image/command), which are set OUTSIDE the patched fields. */ podSpecPatch?: PodSpecPatch; /** RFC B5 (sema-internal server/docs/design/ENV-SELECT-AND-REGION-SOURCE.md 抽象点②): operator-trusted env injected into the * sandbox container (e.g. `SEMA_PKG_SOURCE=cn|global` — the runtime package-source selector the image's * pkg-source.sh hook materializes at start). Deployment-region config, NEVER task-controlled. Absent = the * pod is byte-identical to today. */ podEnv?: Record; } export { type PodSpecPatch, applyPodSpecPatch } from "./k8s-exec-protocol.js"; type ConnectResult = { ok: true; value: WorkspaceHandle; } | { ok: false; error: RemoteExecutionError; }; /** Minimal exec-socket surface (what we use of `ws`) so tests can inject a fake. */ export interface ExecSocket { on(event: "open" | "message" | "close" | "error", listener: (...args: unknown[]) => void): void; close(): void; terminate?(): void; } /** * Test seam ([ref] §9.3 fault-injection): inject the REST `request` and/or the exec-socket factory, so pod * lifecycle, retry policy, channel framing and liveness are deterministically testable without a cluster. */ export interface K8sEnvDeps { request?: (method: string, apiPath: string, body?: unknown, opts?: { timeoutMs?: number; signal?: AbortSignal; }) => Promise<{ status: number; json: unknown; }>; openExec?: (url: string, headers: Record) => ExecSocket; connectOnce?: (signal?: AbortSignal) => Promise; } export { execSocketTlsOptions } from "./k8s-exec-protocol.js"; /** `Error`-or-event → message. Bun's ws shim emits browser-style ErrorEvents (NOT instanceof Error) whose * String() is "[object ErrorEvent]" — that masked the real TLS failure for a whole drill. */ export declare function errorMessageOf(e: unknown): string; export { exitCodeFromStatus } from "./k8s-exec-protocol.js"; export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, BackgroundShellCapability { /** * 🔴 **S-354 / core 7.20.0([ref])`canonicalPathAuthoritative: true`** —— 本适配器的 `canonicalPath` * 就是**命令真正跑的那块文件系统**的权威答案(`posix-shell-fs` 的 `canonicalPath` 原语,经 pod exec 解到落点)。声明它 = 告诉引擎:整链答不出来是**解析器出了事** * (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: true; }; cwd: string; private podName?; /** Cluster-internal pod address, captured when the pod reaches Running — the LSP `getHost` surface. * Lifecycle mirrors podName exactly (set on connect, cleared on suspend/destroy/dead-reconnect). */ private podIP?; private destroyed; /** Set after a successful resumeVM so workspaceHandle() carries the snapshot it was restored from. */ private handleSnapshotId?; /** In-flight exec count — `suspendVM` refuses while a command runs (council #4 suspend×in-flight). */ private inFlightExecs; /** Memoized in-flight connect (lazy-connect; prevents concurrent double pod-create — the E2B race lesson). */ private connectPromise?; /** core `ExecutionEnv.homeDir`([ref]):本沙箱/目标机上执行用户的 home。见配置同名字段。 */ readonly homeDir?: string | undefined; private readonly cfg; private readonly deps; constructor(config: K8sEnvConfig, deps?: K8sEnvDeps); /** 166-T2 — ExecutionEnv 寿命声明:pod `activeDeadlineSeconds = ceil(timeoutMs/1000)` 是平台真硬死线 * (k8s 到点杀 pod,无 keepalive 续期机制)⇒ 恒声明,core 在死线−60s 处 suspend(基建合格)或响亮 * 终局(env.lifetime_expired)。`lifetimeStartedAt` 有意不声明:core 只在 prepare 期读一次,lazy-connect * 下 pod 尚未创建 ⇒ core 回落铸造时刻为锚——比 pod 真启动早,提早 suspend 是保守方向。 */ get lifetimeMs(): number; private resolve; workspaceHandle(): WorkspaceHandle; /** Resolved lazily so an in-cluster pod picks up its service account without explicit config. */ private creds; /** Control-plane REST call (pod create/get/delete), bounded by rpcTimeoutMs. Injectable for tests. */ private request; private openExec; connect(config?: RemoteConnectConfig): Promise; /** * Lazy-connect on first use — 🔴 core NEVER calls `connect()` (verified: the Runner only calls `resumeVM` on * the resume path), so an adapter that demands an explicit connect can never work through the production * hand-tool path (lazy-connect belongs in the adapter). Memoized so concurrent first ops share ONE * pod create; a failed connect clears the memo (next op retries); `destroyed` is terminal. */ private ensureConnected; private connectOnce; /** * Build a validated, presigned URL for a snapshot object — `//.tar.gz`. * 🔴 sessionId comes from the client (`body.sessionId`, security.ts) and is NOT charset-validated upstream, so * each path segment is allowlisted here (`^[A-Za-z0-9][A-Za-z0-9._-]*$`) before it enters the object key: a * segment of `..` / `../x` / one containing `/` would otherwise let one tenant's snapshot key normalize into * another tenant's prefix (cross-tenant read on resume / clobber on suspend). Returns a typed error instead of * signing a traversing key. Folds the old snapshotKey()+s3Presign() pair so the two call sites can't diverge. */ private presignSnapshot; /** Redact a presigned URL (and any lingering signature) from text before it enters an error/log — the URL is a * short-lived bearer credential and must never reach a durable store (secret boundary). */ private redactUrl; /** * Workspace-durable suspend: tar the workspace, upload to S3 via a short-lived adapter-presigned * URL the pod curls (credentials never enter the sandbox), then delete the pod. The returned SnapshotId is * opaque to core; we use a fresh id and persist the bytes under {@link snapshotKey}. NOT supported without an * S3 store (returns the honest "unsupported" so durable suspend degrades to checkpoint + cold rebuild). * * 🔴 council #4 (suspend × in-flight exec): refuse while a command runs — tarring a mutating tree would * snapshot a torn workspace. Core also won't suspend mid-tool, but the adapter enforces it independently. */ suspendVM(_options?: VmLifecycleOptions): Promise<{ ok: true; value: SnapshotId; } | { ok: false; error: RemoteExecutionError; }>; /** * Resume a workspace snapshot onto a FRESH pod (resume = executionEnvFactory rebuild + resumeVM): * provision a pod, then download + untar the snapshot into the workspace. A missing object (empty/plan-only * snapshot) resumes as a clean workspace. * * [ref]①(priorHandle 根保真)——k8s 走**诚实臂**而非绑定臂:pod 的 workspace volume 挂在**当前** * 配置根(cfg.mountPath),往 priorHandle.mountPath 绑=往容器 rootfs untar 大工作区,ephemeral-storage * 触顶即 pod 驱逐(恢复中途死,比「根变了 fail-closed reopen」更糟)。所以 untar 落当前根、handle 回报 * 真生效根(core 契约明许:「做不到就诚实返回实际 mountPath,勿回显未兑现值」);跨 K8S_MOUNT_PATH * 变更窗的分歧由 core 侧 fail-closed reopen 兜底,审批不烧错根。 */ resumeVM(snapshotId: SnapshotId, options?: VmLifecycleOptions): Promise; /** * Honest post-resume consistency ([ref] §5 council #8): the workspace FILES are restored byte-for-byte * from the snapshot, so read-before-edit hashing already lines up — there's nothing to re-fetch. In-VM * process state (a language server) is gone by design; core/the LSP manager re-establishes it lazily. So * this is a successful no-op rather than the previous `unsupported` (which forced a cold rebuild). */ postResumeInit(): Promise<{ ok: true; value: void; } | { ok: false; error: RemoteExecutionError; }>; reconnect(_sessionToken: SessionToken): Promise; private deletePod; /** Delete the sandbox pod — the workspace is DISPOSABLE (vs SSH/ADB disconnect-only). Idempotent, never throws. */ destroy(): Promise; cleanup(): Promise; private execUrl; /** * Run a command over the exec WebSocket. Frames: byte0=channel (1 stdout / 2 stderr / 3 status JSON with the * REAL exit code). Bounds: caller wall-clock (explicit `timeout`) + zero-progress liveness (`livenessMs`) — * a socket killed by either does NOT kill the remote process (disposable-pod posture, see header). */ private runExec; exec(command: string, options?: ExecOpts): Promise>; /** * LSP surface (lsp/manager.ts `isLspCapable` duck-type, [ref] §13.1): cluster-internal address for a * port on the sandbox pod. The E2B counterpart returns a public proxy host; a pod has none — the WORKER * must be able to reach pod IPs (in-cluster deployment, or on-node), and the transport is plain `ws://` * (cluster network, no TLS — the per-session bridge token still gates access like on E2B). */ getHost(port: number): Promise; /** * Start a long-lived helper (the LSP bridge) detached from this exec session. Unlike E2B — which reaps a * foreground command's process group on completion, forcing a native background API — k8s exec just closes * the session: a `nohup`'d child with detached stdio survives, reparented to the container's PID 1 (the * gate#2 background-gradle runs proved this through the production path). Output goes to /tmp for postmortem. */ startBackground(command: string): Promise; execStream(command: string, options?: ExecStreamOptions): AsyncIterable; /** * A background job cannot outlive its pod (`activeDeadlineSeconds` ≈ `timeoutMs`), so the BG ceiling is clamped to * it (assigned in the constructor — `this.cfg` is set there, so a field initializer would read it pre-init). */ readonly backgroundCapabilities: { readonly supported: boolean; readonly maxConcurrent: number; readonly defaultBgTimeoutSec: number; readonly maxBgTimeoutSec: number; readonly supportsDetach: boolean; }; private _bgManager?; private get bgManager(); private makeBgDriver; spawnBackground(command: string, options?: BackgroundSpawnOptions): Promise>; pollBackground(shellId: BackgroundShellId): Promise>; killBackground(shellId: BackgroundShellId): Promise>; disposeBackgroundShells(opts?: { except?: readonly BackgroundShellId[]; }): Promise; absolutePath(p: string): Promise>; joinPath(parts: string[]): Promise>; readBinaryFile(p: string, abortSignal?: AbortSignal): Promise>; readTextFile(p: string, abortSignal?: AbortSignal): Promise>; readTextLines(p: string, options?: { maxLines?: number; abortSignal?: AbortSignal; }): Promise>; /** 父目录自建(与 host/local-docker/adb/ssh 同口径)。抽成共用是因为 `appendFile` 曾**跳过**它: * 🔴 2026-07-25 实测的跨腿分叉 —— `appendFile` 直接调 `writeChunked`,整条路上没有 `mkdir -p`,于是 * 「直接 append 建一个新日志路径」在 k8s 腿上返 `not_found`,而其余 5 腿都成功(host/local-docker/adb 实测, * ssh/e2b 由方法体判据)。core 的后台输出镜像正是"每拍 append 一次"的形,这条分叉会让它在 k8s 腿上从第一拍 * 就失败,而 `mirrorFailed` 只会表现成一句「output file is INCOMPLETE」。 */ private ensureParentDir; writeFile(p: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise>; appendFile(p: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise>; /** * Write `content` to `abs` via argv-embedded base64, chunked at WRITE_CHUNK_BYTES (no stdin on the exec * channel we use; v4 has no per-channel close). `truncateFirst` → the FIRST chunk uses `>` (overwrite), * the rest `>>` (append); `false` → all chunks `>>`. Empty content still runs exactly one command so an * empty file is created (write) / left intact (append). Shared by writeFile/appendFile (council DESIGN#2). */ private writeChunked; 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>; /** exec with the data-transfer bound (large reads/writes legitimately run longer than a control op). */ private execData; private withCwdEnv; } /** * `ExecutionEnvFactory` for the k8s pod-sandbox backend — one fresh (unconnected) env per task; the pod is * created lazily on connect and deleted by `destroy()` (Runner-owned lifetime; `activeDeadlineSeconds` is the * leak guard if destroy never runs). */ export declare function k8sExecutionEnvFactory(config: K8sEnvConfig): ExecutionEnvFactory; //# sourceMappingURL=remote-env-k8s.d.ts.map