Files
PaperClipAI/packages/plugins/sandbox-providers/kubernetes/src/pod-exec.ts
T
9edde68373 feat(kubernetes): native file-sync lifecycle hooks over pod exec (#10053)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - AI agents run in sandboxed execution environments (Kubernetes pods,
Daytona workspaces, etc.) and need to sync files between the host and
those environments — for workspace setup, asset delivery, and output
retrieval
> - The existing sync path for Kubernetes uses a base64-over-exec chunk
loop: each ~4 MB chunk requires its own `execInPod` round-trip, so large
syncs balloon into many exec calls with corresponding overhead
> - `execInPod` supports piped stdin/stdout, meaning the full transfer
can be done as a single exec that streams a raw `tar` archive over the
data channel — one round-trip regardless of file size, with nothing
base64-encoded and nothing buffered whole in memory on either side
> - PR-1 (#10013, merged) added the
`onEnvironmentSyncIn`/`onEnvironmentSyncOut` opt-in hook API to the
sandbox provider interface and documented the protocol; PR-2 (#10028,
merged) implemented these hooks for the Daytona provider
> - This pull request implements the same two lifecycle hooks in the
Kubernetes sandbox provider, so workspace/asset file sync streams
through one `execInPod` per operation instead of the chunk loop
> - The benefit is significantly fewer exec round-trips for large syncs
and flat memory use on both host and pod, with security properties
preserved: atomic replace, secret-mode enforcement, path confinement,
TOCTOU-safe snapshot, and member-confinement on host-assembled archives
from sandbox-authored tar output

## Linked Issues or Issue Description

This is the third and final PR in a sequential series:
- Refs #10013 — PR-1: opt-in sync hook API + provider docs (merged)
- Refs #10028 — PR-2: native file-sync lifecycle hooks for Daytona
provider (merged)

**Feature:** Native single-exec file-sync lifecycle hooks for the
Kubernetes sandbox provider.

*Motivation:* The existing Kubernetes sync path encodes files as base64
and loops over `execInPod` one chunk at a time (~4 MB per exec). For
large workspaces or asset sets this is slow and resource-intensive. The
Kubernetes `execInPod` API supports piped stdin/stdout, enabling a
raw-`tar` streaming transfer that needs only one exec regardless of file
count or size and never buffers the whole payload in memory.

*Proposed solution:* Implement `onEnvironmentSyncIn` and
`onEnvironmentSyncOut` in the Kubernetes provider using a streaming
`execInPod` with a tar pipeline — for syncIn the host builds the archive
on disk and streams its raw bytes into the pod's stdin (`head -c
<exact-size> | tar -x`, no base64); for syncOut in-pod `tar` writes to
the exec's stdout and the host streams those bytes straight to a file.
Path confinement, atomic replace, secret-mode enforcement, TOCTOU
protection, and a streamed-bytes fail-closed guard are all enforced.

## What Changed

- **New `src/file-sync.ts`** in
`packages/plugins/sandbox-providers/kubernetes/` — `performSyncIn` and
`performSyncOut` over an injected pod-exec closure, keeping transfer
logic hermetically unit-testable
- **New `execInPodStreaming` in `src/pod-exec.ts`** — a streaming exec
primitive that binds a caller-supplied stdin readable and a stdout
writable to the exec WebSocket data channel, added alongside the
existing `execInPod` (which is unchanged). This lets a transfer stream
raw bytes to/from disk instead of buffering the payload as a single
string
- **Updated `src/plugin.ts`** — registers
`onEnvironmentSyncIn`/`onEnvironmentSyncOut`; resolves the `sandbox-cr`
pod exactly like `onEnvironmentExecute` and delegates; `job` backend
rejects file-sync calls explicitly (out of scope)
- **syncIn path:** host builds the tarball to a temp file → streams its
raw bytes over exec stdin, bounded in-pod by `head -c
<exact-archive-size> | tar -x` (no base64 anywhere) → extract into a
`/proc/self/fd`-pinned reserved `0700` staging dir → `chmod`-before-`mv
-f` atomic replace per file (directory mappings use
`followSymlinks`→`-h`)
- **syncOut path:** in-pod validate + realpath-snapshot each source
(closes the validation→copy TOCTOU window) → single-exec `tar -c`
streamed over exec stdout → host streams that stdout straight to a temp
file through a byte-counting transform → member-confined extraction of
the sandbox-authored archive
- **Security properties:** secret files land at requested mode with no
widened window; every interpolated path is shell-quoted and confined
lexically plus via in-pod `realpath`; the outbound stream is bounded by
a **streamed-bytes disk guard** (`MAX_SYNC_OUTPUT_BYTES`, 8 GiB default,
per-call overridable) that fails the transfer closed — writing no target
file — if an untrusted pod emits more bytes than allowed. Neither host
nor pod buffers the whole payload, so there is no in-memory size cap on
the transfer
- **No changes** to `execInPod`, `wrapCommandWithEnv`, or
`FastUploadInterceptor` (the `environmentExecute` path is untouched)
- **No dependency or lockfile changes**
- **New tests** in `test/unit/file-sync.test.ts` (atomic-replace, `0600`
secret mode, symlink preserve/deref, dir-mapping, exclude,
path-confinement rejection, streamed-output guard fail-closed) and
`test/unit/pod-exec.test.ts` (streaming stdin/stdout, caller-sink error
fail-closed), plus extended `test/unit/plugin.test.ts`

## Follow-up: Legacy Job-Lease Base64 Fallback Fix

Addresses the Greptile 4/5 blocking finding ("Handle existing job
leases", `server/src/services/environment-runtime.ts`).

Job leases provisioned before the `nativeFileSyncUnsupported` metadata
flag existed carry `backend: "job"` but no flag, so `supportsSync()`
treated them as native-capable and routed their sync to the pod-exec
hook — which the job backend rejects (it has no exec channel) instead of
using the byte-identical base64 fallback. The fix adds a
belt-and-suspenders gate on the persisted `backend === "job"` field
alongside the existing `nativeFileSyncUnsupported` flag check, so
pre-existing job leases continue syncing via the base64 fallback after
deployment. No behaviour change for `sandbox-cr` leases.

## Verification

- `pnpm --filter @paperclipai/sandbox-provider-kubernetes test` — 19
files / 182 tests green, including the existing `upload-interceptor` and
`pod-exec` suites
- `tsc --noEmit` in the kubernetes package — 0 errors
- The sync hooks are opt-in; existing `environmentExecute` behaviour is
unaffected and tested by the unchanged existing suites

## Risks

- **Opt-in only:** `onEnvironmentSyncIn`/`onEnvironmentSyncOut` are
registered conditionally; providers that do not register them fall back
to the existing chunk loop. No regression risk on the existing path.
- **Shell-injection surface:** all path interpolation uses
shell-quoting; paths are additionally confined lexically and via in-pod
`realpath` before use.
- **TOCTOU on syncOut:** the in-pod snapshot validates and records file
metadata before the tar call, closing the window between validation and
copy.
- **Archive member confinement:** host-side reassembly rejects any tar
member whose resolved path escapes the target directory, preventing a
malicious in-pod tar from writing outside the intended destination.
- **Untrusted-output volume:** an over-large outbound stream trips the
streamed-bytes disk guard and fails closed (no target written and the
temp sink is swept) rather than filling host disk or memory; the guard
bounds disk unconditionally and bounds memory insofar as WebSocket
write-backpressure holds.

## Model Used

Anthropic Claude Sonnet 4.6 (`claude-sonnet-4-6`) — produced by a
Claude-based AI agent using agentic tool use and multi-step code
generation. 200K context window, extended reasoning, code execution and
verification capabilities.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Harold Kim <harold@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-22 18:52:47 -07:00

445 lines
19 KiB
TypeScript

/**
* Exec a command inside a running pod container using the Kubernetes exec API.
*
* Uses @kubernetes/client-node's Exec class, which opens a WebSocket to the
* kube-apiserver and streams stdout/stderr. The statusCallback receives a V1Status
* with status="Success" or status="Failure" + details.causes[{reason:"ExitCode"}].
*
* NOTE: tty=false so stdout and stderr arrive on separate channels. If tty=true
* were used, they would be merged onto stdout and the exit code would not be
* reliable from the status callback on older cluster versions.
*
* Stdin handling: @kubernetes/client-node v1.x attaches `stdin.on("end", ()
* => ws.close())`, which closes the entire WebSocket as soon as our PassThrough
* ends — BEFORE the pod's command has a chance to flush and BEFORE the
* statusCallback fires. We work around this by removing that listener after
* exec setup completes so EOF on stdin only signals the pod (via a stdin-
* channel close frame implicit in our flow) without tearing down the
* connection. We then close the WebSocket explicitly inside the statusCallback.
*/
import { Exec } from "@kubernetes/client-node";
import { PassThrough } from "node:stream";
import type { Readable, Writable } from "node:stream";
import type { KubeConfig } from "@kubernetes/client-node";
// Minimal WebSocket-like shape covering what we touch (close()). The full type
// comes from @kubernetes/client-node's transitive ws/isomorphic-ws dep but
// importing it directly couples this file to that internal choice.
type WebSocketLike = { close(): void };
// Single-quote a string for safe interpolation into a sh -c script. Wraps in
// '...' and escapes any embedded single quotes via '\'' (close, escape, reopen).
export function shQuote(segment: string): string {
return `'${segment.replace(/'/g, "'\\''")}'`;
}
// Wrap a command so the given env vars are exported before it runs. The Kubernetes
// exec API has no env field, so the only way to give an exec'd process additional
// env is to run it under a shell that exports the vars and then `exec`s the real
// command. PATH is deliberately skipped (the caller's PATH is the orchestrator's,
// not the sandbox image's, and overriding it would break command resolution), and
// only valid shell identifiers are exported. Returns the original command unchanged
// when there is nothing to apply.
export function wrapCommandWithEnv(
command: string[],
env: Record<string, string> | undefined | null,
): string[] {
const entries = Object.entries(env && typeof env === "object" ? env : {}).filter(
([key, value]) =>
typeof value === "string" && key !== "PATH" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(key),
);
if (entries.length === 0) return command;
const exports = entries.map(([k, v]) => `export ${k}=${shQuote(v)};`).join(" ");
return ["/bin/sh", "-c", `${exports} exec ${command.map(shQuote).join(" ")}`];
}
export async function execInPod(
kc: KubeConfig,
namespace: string,
podName: string,
containerName: string,
command: string[],
stdin?: string | Buffer,
timeoutMs?: number,
// Optional host-side bound on accumulated stdout. The pod is an untrusted,
// attacker-controlled endpoint: a malicious pod can emit unbounded stdout
// during an exec (e.g. a native file-sync `syncOut` tarball), which would grow
// the in-memory accumulator without limit — blowing up the worker's RSS and,
// past V8's ~512 MB max string length, throwing a synchronous RangeError inside
// the stream `data` listener that no caller can catch (an uncaught exception =
// worker crash = cross-tenant DoS). When set, accumulation fails closed the
// instant it would exceed the cap so the caller can fall back. In-pod size
// checks are worthless here — only the host can be trusted to enforce this.
maxStdoutBytes?: number,
// Same bound for the stderr channel. stderr is equally pod-controlled: a
// malicious pod can emit an unbounded stderr stream during the SAME exec (e.g.
// crafted tar/realpath diagnostics on the `syncOut` path) and trigger the
// identical uncaught-`RangeError` worker crash. When set, stderr accumulation
// fails closed at the cap; regardless of the cap, the `+=` is guarded so a
// max-string-length `RangeError` can never escape as an uncaught exception.
maxStderrBytes?: number,
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const exec = new Exec(kc);
const stdoutStream = new PassThrough();
const stderrStream = new PassThrough();
const stdinPayload: Buffer | null =
Buffer.isBuffer(stdin) ? stdin
: typeof stdin === "string" && stdin.length > 0 ? Buffer.from(stdin, "utf-8")
: null;
const stdinStream: PassThrough | null = stdinPayload ? new PassThrough() : null;
// When stdin is provided, wrap the command so its stdin is bounded by
// `head -c <N>`. Any program reading stdin (`cat`, `claude --print -`,
// `base64 -d`, etc.) waits for EOF to terminate the read. With the k8s
// client v0.21.0 stdin-end -> ws.close() limitation (see comment above)
// we can't reliably deliver EOF without tearing down the exec — so we
// pipe `head -c <N>` (which exits after exactly N bytes) into the
// original command. The pipe propagates the exit code of the RHS so the
// statusCallback still reflects the real command's exit status.
//
// NOTE: `stdinPayload.length` is the Buffer's BYTE length (correct for
// `head -c` which counts bytes). Do NOT substitute `string.length` here
// if the input is ever non-ASCII — UTF-8 multi-byte sequences would
// give a byte count that differs from JS character count.
const effectiveCommand = stdinPayload
? ["/bin/sh", "-c", `head -c ${stdinPayload.length} | ${command.map(shQuote).join(" ")}`]
: command;
let stdoutData = "";
let stderrData = "";
let stdoutBytes = 0;
let stderrBytes = 0;
return await new Promise<{ exitCode: number; stdout: string; stderr: string }>(
(resolve, reject) => {
let ws: WebSocketLike | null = null;
let resolved = false;
let pendingExitCode: number | null = null;
let stdoutEnded = false;
let stderrEnded = false;
// The k8s client writes stdout/stderr to our PassThroughs synchronously
// and calls statusCallback in the same WS message handler. But `data`
// events on the PassThroughs fire on process.nextTick, so resolving
// inside statusCallback captures stdoutData/stderrData BEFORE the final
// bytes have been appended. Wait for both streams' `end` events (which
// the k8s client triggers via `stream.end()` when it processes the
// status frame) so all buffered data has been drained into our string
// accumulators before resolving.
//
// Watchdog: if the WebSocket drops silently after setup (network blip,
// pod OOM-killed mid-exec, apiserver restart) the statusCallback never
// fires and the stream `end` events never arrive. Without a timer the
// calling worker hangs forever. Reject with a clear error so the caller
// can retry or surface the failure.
let watchdog: ReturnType<typeof setTimeout> | null = null;
if (typeof timeoutMs === "number" && timeoutMs > 0) {
watchdog = setTimeout(() => {
if (resolved) return;
resolved = true;
try { ws?.close(); } catch { /* ignore */ }
reject(new Error(
`execInPod timed out after ${timeoutMs}ms (pod=${podName}, container=${containerName}, cmd0=${effectiveCommand[0] ?? ""}). The WebSocket likely dropped before the command produced a status frame.`,
));
}, timeoutMs);
}
const tryFinish = () => {
if (resolved) return;
if (pendingExitCode === null) return;
if (!stdoutEnded || !stderrEnded) return;
resolved = true;
if (watchdog) clearTimeout(watchdog);
try { ws?.close(); } catch { /* ignore */ }
resolve({ exitCode: pendingExitCode, stdout: stdoutData, stderr: stderrData });
};
// Fail the whole exec closed, tearing down the WebSocket so the pod stops
// streaming. Used by the stdout cap below; the `resolved` guard makes it a
// no-op if the exec already finished.
const failClosed = (err: Error) => {
if (resolved) return;
resolved = true;
if (watchdog) clearTimeout(watchdog);
try { ws?.close(); } catch { /* ignore */ }
reject(err);
};
// Accumulate stdout inside the executor so the cap can reject before an
// unbounded pod payload exhausts memory. Once resolved (finished, timed
// out, or capped) further chunks are dropped rather than appended.
stdoutStream.on("data", (chunk: Buffer) => {
if (resolved) return;
if (typeof maxStdoutBytes === "number" && maxStdoutBytes >= 0) {
stdoutBytes += chunk.length;
if (stdoutBytes > maxStdoutBytes) {
failClosed(new Error(
`execInPod stdout exceeded the ${maxStdoutBytes}-byte cap (pod=${podName}, container=${containerName}); the sandbox produced more output than the buffer allows.`,
));
return;
}
}
try {
stdoutData += chunk.toString("utf-8");
} catch (err) {
// Belt-and-suspenders: even under an explicit cap (or with none set),
// never let a `+=` RangeError at V8's max string length escape this
// listener as an uncaught exception.
failClosed(err instanceof Error ? err : new Error(String(err)));
}
});
// stderr is pod-controlled too — bound it with the same fail-closed policy
// and, unconditionally, guard the `+=` so a max-string-length `RangeError`
// can never escape this listener as an uncaught (worker-crashing) exception.
stderrStream.on("data", (chunk: Buffer) => {
if (resolved) return;
if (typeof maxStderrBytes === "number" && maxStderrBytes >= 0) {
stderrBytes += chunk.length;
if (stderrBytes > maxStderrBytes) {
failClosed(new Error(
`execInPod stderr exceeded the ${maxStderrBytes}-byte cap (pod=${podName}, container=${containerName}); the sandbox produced more output than the buffer allows.`,
));
return;
}
}
try {
stderrData += chunk.toString("utf-8");
} catch (err) {
failClosed(err instanceof Error ? err : new Error(String(err)));
}
});
stdoutStream.on("end", () => { stdoutEnded = true; tryFinish(); });
stderrStream.on("end", () => { stderrEnded = true; tryFinish(); });
const execPromise = exec.exec(
namespace,
podName,
containerName,
effectiveCommand,
stdoutStream,
stderrStream,
stdinStream,
false, // tty=false: keep stdout/stderr on separate channels
(status) => {
if (status.status === "Success") {
pendingExitCode = 0;
} else {
const causes = status.details?.causes ?? [];
const exitCodeCause = causes.find(
(c: { reason?: string; message?: string }) =>
c.reason === "ExitCode",
);
pendingExitCode = exitCodeCause?.message
? Number(exitCodeCause.message)
: 1;
}
tryFinish();
},
);
execPromise
.then((webSocket) => {
ws = webSocket as unknown as WebSocketLike;
if (stdinStream && stdinPayload) {
// Remove the default `end -> ws.close()` listener that k8s client
// attaches in handleStandardInput; it tears down the connection
// before the pod's command can finish flushing. We manage ws
// closure inside `tryFinish()` instead.
stdinStream.removeAllListeners("end");
stdinStream.end(stdinPayload);
} else if (stdinStream) {
stdinStream.removeAllListeners("end");
stdinStream.end();
}
})
.catch((err) => {
if (resolved) return;
resolved = true;
if (watchdog) clearTimeout(watchdog);
reject(err);
});
},
);
}
/**
* Streaming variant of {@link execInPod} for bulk file transfer over the pod
* exec data channel.
*
* Where `execInPod` buffers the command's whole stdout into an in-memory string
* (fine for small command output, fatal for a multi-gigabyte tar), this variant
* PIPES the exec's stdin from a caller `Readable` and its stdout into a caller
* `Writable` — so a native file-sync `syncIn`/`syncOut` streams raw tar bytes
* straight to/from a host file on disk and neither the host nor the pod ever
* holds the whole payload in memory. Both are intentionally kept side by side:
* `execInPod` still backs the `environmentExecute` path unchanged.
*
* stderr is still accumulated into a (bounded) string: it carries only the
* script's fail-loud diagnostics, and the pod controls how many bytes it emits,
* so `maxStderrBytes` fails the exec closed the instant the pod floods the
* channel — the same DoS guard `execInPod` applies. stdout carries no such cap
* here because it is streamed to disk, not a string; the caller bounds it with
* its own streamed-bytes guard on the sink `Writable`.
*
* Stdin EOF: the same k8s-client `stdin.on("end", () => ws.close())` quirk noted
* on `execInPod` applies, so we strip that listener and drive the pod command to
* self-terminate on the byte count instead of relying on EOF (the caller's
* `syncIn` script bounds its read with `head -c <N>`). We close the WebSocket
* ourselves from the status callback.
*
* Resolves once the command's exit status is known AND both the stdout sink has
* finished draining and stderr has ended, so a caller that reads the sink file
* back after `await` always sees the complete archive.
*/
export async function execInPodStreaming(
kc: KubeConfig,
namespace: string,
podName: string,
containerName: string,
command: string[],
io: {
stdin?: Readable;
stdout?: Writable;
timeoutMs?: number;
maxStderrBytes?: number;
},
): Promise<{ exitCode: number; stderr: string }> {
const exec = new Exec(kc);
const stdoutStream = new PassThrough();
const stderrStream = new PassThrough();
const stdinStream: PassThrough | null = io.stdin ? new PassThrough() : null;
let stderrData = "";
let stderrBytes = 0;
return await new Promise<{ exitCode: number; stderr: string }>((resolve, reject) => {
let ws: WebSocketLike | null = null;
let resolved = false;
let pendingExitCode: number | null = null;
let stdoutDone = false;
let stderrEnded = false;
let watchdog: ReturnType<typeof setTimeout> | null = null;
if (typeof io.timeoutMs === "number" && io.timeoutMs > 0) {
watchdog = setTimeout(() => {
if (resolved) return;
resolved = true;
try { ws?.close(); } catch { /* ignore */ }
reject(new Error(
`execInPodStreaming timed out after ${io.timeoutMs}ms (pod=${podName}, container=${containerName}, cmd0=${command[0] ?? ""}). The WebSocket likely dropped before the command produced a status frame.`,
));
}, io.timeoutMs);
}
const tryFinish = () => {
if (resolved) return;
if (pendingExitCode === null) return;
if (!stdoutDone || !stderrEnded) return;
resolved = true;
if (watchdog) clearTimeout(watchdog);
try { ws?.close(); } catch { /* ignore */ }
resolve({ exitCode: pendingExitCode, stderr: stderrData });
};
// Fail the whole exec closed, tearing down the WebSocket so the pod stops
// streaming. Fired by a sink error (e.g. the caller's disk guard tripping)
// or a stream error; the `resolved` guard makes it a no-op once finished.
const failClosed = (err: Error) => {
if (resolved) return;
resolved = true;
if (watchdog) clearTimeout(watchdog);
try { ws?.close(); } catch { /* ignore */ }
try { stdinStream?.destroy(); } catch { /* ignore */ }
reject(err);
};
// Pipe stdout to the caller sink (streamed to disk), or drain it if the
// caller wants none. Piping with the default `end: true` ends the sink when
// the pod's stdout closes, so the sink's `finish` marks the archive fully
// written. A sink error (the caller's streamed-bytes guard, a full disk)
// fails the exec closed and stops the pod.
if (io.stdout) {
const sink = io.stdout;
sink.on("error", failClosed);
stdoutStream.on("error", failClosed);
sink.on("finish", () => { stdoutDone = true; tryFinish(); });
stdoutStream.pipe(sink);
} else {
stdoutStream.on("data", () => { /* drain */ });
stdoutStream.on("end", () => { stdoutDone = true; tryFinish(); });
stdoutStream.on("error", failClosed);
}
// stderr is pod-controlled — bound it with the same fail-closed policy as
// execInPod and guard the `+=` so a max-string-length RangeError can never
// escape as an uncaught (worker-crashing) exception.
stderrStream.on("data", (chunk: Buffer) => {
if (resolved) return;
if (typeof io.maxStderrBytes === "number" && io.maxStderrBytes >= 0) {
stderrBytes += chunk.length;
if (stderrBytes > io.maxStderrBytes) {
failClosed(new Error(
`execInPodStreaming stderr exceeded the ${io.maxStderrBytes}-byte cap (pod=${podName}, container=${containerName}); the sandbox produced more diagnostics than the buffer allows.`,
));
return;
}
}
try {
stderrData += chunk.toString("utf-8");
} catch (err) {
failClosed(err instanceof Error ? err : new Error(String(err)));
}
});
stderrStream.on("end", () => { stderrEnded = true; tryFinish(); });
const execPromise = exec.exec(
namespace,
podName,
containerName,
command,
stdoutStream,
stderrStream,
stdinStream,
false, // tty=false: keep stdout/stderr on separate channels
(status) => {
if (status.status === "Success") {
pendingExitCode = 0;
} else {
const causes = status.details?.causes ?? [];
const exitCodeCause = causes.find(
(c: { reason?: string; message?: string }) => c.reason === "ExitCode",
);
pendingExitCode = exitCodeCause?.message ? Number(exitCodeCause.message) : 1;
}
tryFinish();
},
);
execPromise
.then((webSocket) => {
ws = webSocket as unknown as WebSocketLike;
if (stdinStream && io.stdin) {
// Strip the default `end -> ws.close()` listener (see execInPod) so
// EOF on our stdin only signals the pod, then stream the caller's
// source into the exec stdin channel. A source error fails closed.
stdinStream.removeAllListeners("end");
io.stdin.on("error", failClosed);
io.stdin.pipe(stdinStream);
} else if (stdinStream) {
stdinStream.removeAllListeners("end");
stdinStream.end();
}
})
.catch((err) => {
if (resolved) return;
resolved = true;
if (watchdog) clearTimeout(watchdog);
reject(err);
});
});
}