diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index aa7bb23029..283097815d 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -86,7 +86,6 @@ export type { export { inferOpenAiCompatibleBiller } from "./billing.js"; export { ADAPTER_LOGIN_PANEL_MODES, - ADAPTER_LOGIN_SANDBOX_TRANSPORTS, ADAPTER_LOGIN_TIMEOUT_POLICIES, ADAPTER_LOGIN_COMPLETION_CLAIMS, assertValidAdapterLoginCapability, @@ -94,7 +93,6 @@ export { } from "./login-capability.js"; export type { AdapterLoginPanelMode, - AdapterLoginSandboxTransport, AdapterLoginTimeoutPolicy, AdapterLoginCompletionClaim, AdapterLoginPrompt, diff --git a/packages/adapter-utils/src/login-capability.test.ts b/packages/adapter-utils/src/login-capability.test.ts index a79a4e2dfa..617103defc 100644 --- a/packages/adapter-utils/src/login-capability.test.ts +++ b/packages/adapter-utils/src/login-capability.test.ts @@ -10,7 +10,6 @@ import { function validCapability(): AdapterLoginCapability { return { panelMode: "displayed_code", - sandboxTransport: "streamed_exec", timeoutPolicy: "caller_bounded", getCommand: () => "vendor login", parsePrompt: () => null, @@ -25,7 +24,6 @@ describe("assertValidAdapterLoginCapability", () => { it("accepts a well-formed capability with every optional member set", () => { const capability: AdapterLoginCapability = { panelMode: "submitted_browser_code", - sandboxTransport: "pseudo_terminal", timeoutPolicy: "fixed", getCommand: () => "vendor setup-token", parsePrompt: () => null, @@ -48,11 +46,6 @@ describe("assertValidAdapterLoginCapability", () => { expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/panelMode/); }); - it("rejects an unknown sandboxTransport", () => { - const bad = { ...validCapability(), sandboxTransport: "http" }; - expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/sandboxTransport/); - }); - it("rejects an unknown timeoutPolicy", () => { const bad = { ...validCapability(), timeoutPolicy: "unbounded" }; expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/timeoutPolicy/); diff --git a/packages/adapter-utils/src/login-capability.ts b/packages/adapter-utils/src/login-capability.ts index 986dc8a6b6..9eac8e1346 100644 --- a/packages/adapter-utils/src/login-capability.ts +++ b/packages/adapter-utils/src/login-capability.ts @@ -18,14 +18,6 @@ export const ADAPTER_LOGIN_PANEL_MODES = ["displayed_code", "submitted_browser_code"] as const; export type AdapterLoginPanelMode = (typeof ADAPTER_LOGIN_PANEL_MODES)[number]; -/** - * The sandbox transport that the login command needs. `streamed_exec` runs the - * command over the streamed exec channel. `pseudo_terminal` runs the command on - * a real pseudo-terminal, because a pipe emits no login prompt. - */ -export const ADAPTER_LOGIN_SANDBOX_TRANSPORTS = ["streamed_exec", "pseudo_terminal"] as const; -export type AdapterLoginSandboxTransport = (typeof ADAPTER_LOGIN_SANDBOX_TRANSPORTS)[number]; - /** * The host-side timeout policy. `caller_bounded` lets the caller set the * timeout. `fixed` binds the timeout to a fixed adapter value. @@ -68,8 +60,6 @@ export interface AdapterLoginCompletionContext { export interface AdapterLoginCapability { /** The login panel mode. */ panelMode: AdapterLoginPanelMode; - /** The sandbox transport that the login command needs. */ - sandboxTransport: AdapterLoginSandboxTransport; /** The host-side timeout policy. */ timeoutPolicy: AdapterLoginTimeoutPolicy; /** Returns the fixed, non-secret login command. */ @@ -122,11 +112,6 @@ export function assertValidAdapterLoginCapability( `${prefix}: "panelMode" must be one of ${ADAPTER_LOGIN_PANEL_MODES.join(", ")}.`, ); } - if (!isOneOf(ADAPTER_LOGIN_SANDBOX_TRANSPORTS, cap.sandboxTransport)) { - throw new Error( - `${prefix}: "sandboxTransport" must be one of ${ADAPTER_LOGIN_SANDBOX_TRANSPORTS.join(", ")}.`, - ); - } if (!isOneOf(ADAPTER_LOGIN_TIMEOUT_POLICIES, cap.timeoutPolicy)) { throw new Error( `${prefix}: "timeoutPolicy" must be one of ${ADAPTER_LOGIN_TIMEOUT_POLICIES.join(", ")}.`, diff --git a/packages/adapter-utils/src/setup-token-transport.test.ts b/packages/adapter-utils/src/login-pty-transport.test.ts similarity index 88% rename from packages/adapter-utils/src/setup-token-transport.test.ts rename to packages/adapter-utils/src/login-pty-transport.test.ts index 3644e35581..d73bddcb16 100644 --- a/packages/adapter-utils/src/setup-token-transport.test.ts +++ b/packages/adapter-utils/src/login-pty-transport.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; import { - createSetupTokenPtyTransport, - type SetupTokenPtySession, -} from "./setup-token-transport.js"; + createLoginPtyTransport, + type LoginPtySession, +} from "./login-pty-transport.js"; // The Enter byte the terminal login UI reads to submit the browser code. The // login runner appends this byte to the code. The transport forwards the bytes @@ -14,7 +14,7 @@ const ENTER = "\r"; * stream on demand, and records the stop and the close. The tests use it in * place of a real pseudo-terminal, so the transport runs with no sandbox. */ -function createFakePtySession(): SetupTokenPtySession & { +function createFakePtySession(): LoginPtySession & { writes: string[]; emit: (chunk: string) => void; finish: (exitCode: number | null) => void; @@ -74,10 +74,10 @@ function createFakePtySession(): SetupTokenPtySession & { }; } -describe("createSetupTokenPtyTransport", () => { +describe("createLoginPtyTransport", () => { it("delivers delayed input to the process", async () => { const session = createFakePtySession(); - const transport = createSetupTokenPtyTransport(async () => session); + const transport = createLoginPtyTransport(async () => session); const started = transport.start("claude setup-token", () => {}); await session.ready; @@ -94,7 +94,7 @@ describe("createSetupTokenPtyTransport", () => { it("returns incremental terminal output to the runner", async () => { const session = createFakePtySession(); const received: string[] = []; - const transport = createSetupTokenPtyTransport(async () => session); + const transport = createLoginPtyTransport(async () => session); const started = transport.start("claude setup-token", (chunk) => { received.push(chunk); @@ -114,7 +114,7 @@ describe("createSetupTokenPtyTransport", () => { it("delivers the Enter byte after the browser code", async () => { const session = createFakePtySession(); - const transport = createSetupTokenPtyTransport(async () => session); + const transport = createLoginPtyTransport(async () => session); const started = transport.start("claude setup-token", () => {}); await session.ready; @@ -133,7 +133,7 @@ describe("createSetupTokenPtyTransport", () => { it("resolves start with the child exit code", async () => { const session = createFakePtySession(); - const transport = createSetupTokenPtyTransport(async () => session); + const transport = createLoginPtyTransport(async () => session); const started = transport.start("claude setup-token", () => {}); session.finish(7); @@ -143,7 +143,7 @@ describe("createSetupTokenPtyTransport", () => { it("stops the child with a direct kill", async () => { const session = createFakePtySession(); - const transport = createSetupTokenPtyTransport(async () => session); + const transport = createLoginPtyTransport(async () => session); const started = transport.start("claude setup-token", () => {}); await session.ready; @@ -156,7 +156,7 @@ describe("createSetupTokenPtyTransport", () => { it("disposes the session resources", async () => { const session = createFakePtySession(); - const transport = createSetupTokenPtyTransport(async () => session); + const transport = createLoginPtyTransport(async () => session); const started = transport.start("claude setup-token", () => {}); session.finish(0); @@ -168,7 +168,7 @@ describe("createSetupTokenPtyTransport", () => { it("stays safe when stop and dispose run before start", async () => { const session = createFakePtySession(); - const transport = createSetupTokenPtyTransport(async () => session); + const transport = createLoginPtyTransport(async () => session); // The runner may stop or dispose before it starts the child. The transport // must not throw, and it must not open a session for a run it already stopped. @@ -185,7 +185,7 @@ describe("createSetupTokenPtyTransport", () => { const openGate = new Promise((resolve) => { resolveOpen = resolve; }); - const transport = createSetupTokenPtyTransport(async () => { + const transport = createLoginPtyTransport(async () => { await openGate; return session; }); @@ -207,7 +207,7 @@ describe("createSetupTokenPtyTransport", () => { const openGate = new Promise((resolve) => { resolveOpen = resolve; }); - const transport = createSetupTokenPtyTransport(async () => { + const transport = createLoginPtyTransport(async () => { await openGate; return session; }); diff --git a/packages/adapter-utils/src/setup-token-transport.ts b/packages/adapter-utils/src/login-pty-transport.ts similarity index 89% rename from packages/adapter-utils/src/setup-token-transport.ts rename to packages/adapter-utils/src/login-pty-transport.ts index af8b0c856c..48369bdbe4 100644 --- a/packages/adapter-utils/src/setup-token-transport.ts +++ b/packages/adapter-utils/src/login-pty-transport.ts @@ -1,4 +1,4 @@ -// The setup-token pseudo-terminal transport. It gives the Claude `setup-token` +// The login pseudo-terminal (PTY) transport. It gives the Claude `setup-token` // login runner a child that runs on a real pseudo-terminal (PTY). The command // needs a PTY: pipe stdio emits no login prompt. The transport starts the // command on a PTY, streams the incremental terminal output, delivers delayed @@ -7,7 +7,7 @@ // This module is provider-agnostic and pure. It holds no Node built-in import, // so the browser-safe root entry can re-export it. A sandbox provider (for // example the Daytona plugin) opens the concrete pseudo-terminal through a -// {@link SetupTokenPtySessionOpener}. A unit test opens a fake pseudo-terminal +// {@link LoginPtySessionOpener}. A unit test opens a fake pseudo-terminal // through the same opener. // // Boundary (ANSI and OSC 8): the transport forwards the raw terminal bytes @@ -20,7 +20,7 @@ * raw terminal output, accepts delayed input, and stops the child. The session * forwards the raw terminal bytes; it runs no ANSI or OSC 8 handling. */ -export interface SetupTokenPtySession { +export interface LoginPtySession { /** * Registers the one output listener. The session streams each raw terminal * output chunk to `listener`, in order, as the pseudo-terminal emits it. @@ -49,7 +49,7 @@ export interface SetupTokenPtySession { * Opens a pseudo-terminal session for `command`. A provider binds this to its * sandbox. The transport calls it one time, on start. */ -export type SetupTokenPtySessionOpener = (command: string) => Promise; +export type LoginPtySessionOpener = (command: string) => Promise; /** * The child side of the setup-token run, in the shape the login runner needs. @@ -60,7 +60,7 @@ export type SetupTokenPtySessionOpener = (command: string) => Promise { if (started) { - throw new Error("setup-token PTY transport already started."); + throw new Error("login PTY transport already started."); } started = true; const opened = await open(command); diff --git a/packages/adapters/codex-local/src/server/device-login-parse.test.ts b/packages/adapters/codex-local/src/server/device-login-parse.test.ts index cfd6fa9b98..8768d18332 100644 --- a/packages/adapters/codex-local/src/server/device-login-parse.test.ts +++ b/packages/adapters/codex-local/src/server/device-login-parse.test.ts @@ -42,6 +42,62 @@ describe("parseDeviceLoginPrompt", () => { expect(result?.code).toBe("ABCD-EFGHJ"); }); + it("parse_returns_url_and_code_from_carriage_return_line_endings", () => { + // A pseudo-terminal ends each line with a carriage return and a line feed. + // The carriage return is whitespace, so the URL token stops before it, and the + // code line trims it. The parser reads the tokens the same as line-feed output. + const text = [ + "1. Open this link in your browser and sign in to your account", + EXACT_URL, + "2. Enter this one-time code (expires in 15 minutes)", + "ABCD-EFGHJ", + ].join("\r\n"); + const result = parseDeviceLoginPrompt(text); + expect(result).not.toBeNull(); + expect(result?.url).toBe(EXACT_URL); + expect(result?.code).toBe("ABCD-EFGHJ"); + }); + + it("parse_returns_url_and_code_with_a_terminal_title_osc_sequence", () => { + // A pseudo-terminal can emit an Operating System Command (OSC) title sequence + // in the stream. The device-auth prompt still prints the URL and the code as + // plain standalone tokens on their own lines. The OSC title sequence carries no + // URL token, so it does not shift the URL or the code, and the parser reads + // both tokens. + const osc = "\x1b]0;Codex\x07"; + const text = [ + `${osc}1. Open this link in your browser and sign in to your account`, + EXACT_URL, + "2. Enter this one-time code (expires in 15 minutes)", + "ABCD-EFGHJ", + ].join("\n"); + const result = parseDeviceLoginPrompt(text); + expect(result).not.toBeNull(); + expect(result?.url).toBe(EXACT_URL); + expect(result?.code).toBe("ABCD-EFGHJ"); + }); + + it("parse_returns_url_and_code_from_a_merged_raw_pseudo_terminal_frame", () => { + // One buffer holds the whole prompt as a real pseudo-terminal emits it: a + // leading title Operating System Command (OSC) sequence, color Control + // Sequence Introducer (CSI) sequences around the URL and the code, and + // carriage-return line endings. The parser removes the color sequences and + // reads the plain tokens. + const title = "\x1b]0;Codex login\x07"; + const cyan = "\x1b[36m"; + const bold = "\x1b[1m"; + const reset = "\x1b[0m"; + const text = + `${title}1. Open this link in your browser and sign in to your account\r\n` + + `${cyan}${EXACT_URL}${reset}\r\n` + + `2. Enter this one-time code (expires in 15 minutes)\r\n` + + `${bold}ABCD-EFGHJ${reset}\r\n`; + const result = parseDeviceLoginPrompt(text); + expect(result).not.toBeNull(); + expect(result?.url).toBe(EXACT_URL); + expect(result?.code).toBe("ABCD-EFGHJ"); + }); + it("parse_returns_null_when_prompt_absent", () => { const text = "Some unrelated log line\nNothing to see here\n"; expect(parseDeviceLoginPrompt(text)).toBeNull(); diff --git a/packages/adapters/codex-local/src/server/device-login-runner.test.ts b/packages/adapters/codex-local/src/server/device-login-runner.test.ts index 7a14958149..b9393e8bba 100644 --- a/packages/adapters/codex-local/src/server/device-login-runner.test.ts +++ b/packages/adapters/codex-local/src/server/device-login-runner.test.ts @@ -24,10 +24,10 @@ interface FakeDriverOptions { function createFakeDriver(options: FakeDriverOptions = {}) { const disposeCalls = { count: 0 }; const driver: SandboxLoginDriver = { - async execStreaming(_command, onStdout) { + async start(_command, onData) { if (options.execError) throw options.execError; for (const chunk of options.chunks ?? []) { - onStdout(chunk); + onData(chunk); } if (options.hang) { // Never resolve. The runner must fall back to its timeout or signal. diff --git a/packages/adapters/codex-local/src/server/device-login-runner.ts b/packages/adapters/codex-local/src/server/device-login-runner.ts index 4c66a702c0..968031e113 100644 --- a/packages/adapters/codex-local/src/server/device-login-runner.ts +++ b/packages/adapters/codex-local/src/server/device-login-runner.ts @@ -38,18 +38,33 @@ const MAX_PARSE_BUFFER_CHARS = 64 * 1024; /** * The sandbox side of the device-login run. The runner never calls Daytona - * directly; a caller injects a concrete driver. A production driver binds these - * three methods to a non-persisting Daytona exec path, a file read, and a - * sandbox delete. + * directly; a caller injects a concrete driver. A production driver binds `start` + * to the shared login pseudo-terminal (PTY) transport, binds `readFile` to a + * descriptor-bound credential read, and binds `dispose` to the transport dispose. + * + * The `start` shape matches the shared login pseudo-terminal transport start + * method, so the driver runs the login command on a real pseudo-terminal. The + * login command + * needs a pseudo-terminal: pipe stdio emits no login prompt. The runner never + * calls `stop` or `write` on the driver; the device-login flow needs no delayed + * input, and the service owns the sandbox delete. The runner disposes the driver + * one time on every terminal state. */ export interface SandboxLoginDriver extends LoginRunnerDisposable { /** - * Runs `command` in the sandbox and streams standard output to `onStdout` in - * memory. Resolves with the command exit code when the command ends. A driver - * must not persist the raw output to any durable log. + * Starts `command` on a pseudo-terminal and streams the terminal output to + * `onData` in memory, in order, as the pseudo-terminal emits it. Resolves with + * the command exit code when the command ends. A driver must not persist the raw + * output to any durable log. + */ + start(command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }>; + /** + * Reads the credential bytes with one descriptor-bound read. The read is + * separate from the pseudo-terminal session; the session has no file-read + * method. A driver runs one fixed, server-controlled operation that opens the + * verified session home and the credential file with no symlink follow, checks + * the opened descriptor, and reads only from that same descriptor. */ - execStreaming(command: string, onStdout: (chunk: string) => void): Promise<{ exitCode: number | null }>; - /** Reads the bytes of one file from the sandbox. */ readFile(path: string): Promise; } @@ -123,8 +138,8 @@ export async function runDeviceLogin( return { outcome: "cancelled", exitCode: null, promptSurfaced }; } - const exec = driver.execStreaming(command, onStdout); - const raced = await raceLoginRunnerExit(exec, timeoutMs, signal); + const started = driver.start(command, onStdout); + const raced = await raceLoginRunnerExit(started, timeoutMs, signal); if (raced.kind === "timeout") { log("[paperclip] Device login timed out; disposing the sandbox."); diff --git a/packages/plugins/sandbox-providers/daytona/package.json b/packages/plugins/sandbox-providers/daytona/package.json index ddc5acd934..666300e93c 100644 --- a/packages/plugins/sandbox-providers/daytona/package.json +++ b/packages/plugins/sandbox-providers/daytona/package.json @@ -42,7 +42,7 @@ ], "scripts": { "prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps", - "build": "rm -rf dist && tsc", + "build": "rm -rf dist && tsc && mkdir -p dist/scripts && cp -R src/scripts/. dist/scripts/", "clean": "rm -rf dist", "typecheck": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit", "test": "vitest run --config vitest.config.ts", diff --git a/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.ts b/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.ts index 4507ba774e..3da94be8fc 100644 --- a/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.ts +++ b/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.ts @@ -27,7 +27,7 @@ // Dependency boundary: this provider plugin ships standalone (the workspace // excludes `packages/plugins/sandbox-providers/**`). So the module imports no // workspace package. It reuses the narrow Daytona PTY surface that -// `setup-token-pty.ts` declares (`DaytonaPtyProcess`, `DaytonaPtyHandle`, +// `login-pty.ts` declares (`DaytonaPtyProcess`, `DaytonaPtyHandle`, // `DaytonaPtyCreateOptions`), so a caller passes `sandbox.process` with no // adapter. The narrow surface keeps the module unit-testable with a fake PTY. @@ -37,13 +37,13 @@ import type { DaytonaPtyCreateOptions, DaytonaPtyHandle, DaytonaPtyProcess, -} from "./setup-token-pty.js"; +} from "./login-pty.js"; export type { DaytonaPtyCreateOptions, DaytonaPtyHandle, DaytonaPtyProcess, -} from "./setup-token-pty.js"; +} from "./login-pty.js"; import { sendPtyInputInChunks } from "./pty-chunked-input.js"; diff --git a/packages/plugins/sandbox-providers/daytona/src/login-pty.test.ts b/packages/plugins/sandbox-providers/daytona/src/login-pty.test.ts new file mode 100644 index 0000000000..398106dae3 --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/src/login-pty.test.ts @@ -0,0 +1,645 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + composeLaunchLine, + createDaytonaLoginHomeFs, + createDaytonaLoginPtySessionOpener, + encodePosixShellArg, + openDaytonaLoginPtySession, + type DaytonaExecResult, + type DaytonaLoginHomeFs, + type DaytonaPtyCreateOptions, + type DaytonaPtyHandle, + type DaytonaPtyProcess, + type DaytonaSandboxExec, + type LoginHomeInspection, + type LoginPtyLaunchDescriptor, +} from "./login-pty.js"; + +// The Enter byte the terminal login UI reads to submit the browser code. +const ENTER = "\r"; + +const HOME = "/tmp/paperclip-adapter-login/11111111-2222-4333-8444-555555555555"; +const LOGIN_UID = 1000; + +const CLAUDE: LoginPtyLaunchDescriptor = { loginCommandKey: "claude", sessionHome: HOME }; +const CODEX: LoginPtyLaunchDescriptor = { loginCommandKey: "codex", sessionHome: HOME }; + +/** A directory entry in the virtual filesystem. */ +type FakeEntry = Omit; + +const ABSENT: LoginHomeInspection = { + exists: false, + isSymlink: false, + isDirectory: false, + mode: "", + ownerUid: null, +}; + +/** + * A fake login-home filesystem. It models one virtual path. `createDirectory` + * fails when the path exists and otherwise stores `createAs` (a clean 0700 + * directory owned by the login user by default). A test seeds a pre-existing + * entry, forces a bad created entry, or swaps the entry through `onCreatePty`, so + * a test drives each rejection and the pre-launch re-check. + */ +function createFakeHomeFs(config?: { + loginUid?: number; + seed?: FakeEntry; + createAs?: FakeEntry; + createShouldFail?: boolean; +}): DaytonaLoginHomeFs & { + created: Array<{ path: string; mode: string }>; + inspectCount: number; + setEntry: (entry: FakeEntry | null) => void; +} { + const loginUid = config?.loginUid ?? LOGIN_UID; + const cleanDir: FakeEntry = { + isSymlink: false, + isDirectory: true, + mode: "700", + ownerUid: loginUid, + }; + let entry: FakeEntry | null = config?.seed ?? null; + const created: Array<{ path: string; mode: string }> = []; + const state = { inspectCount: 0 }; + return { + created, + get inspectCount() { + return state.inspectCount; + }, + setEntry(next: FakeEntry | null): void { + entry = next; + }, + async loginUserId(): Promise { + return loginUid; + }, + async inspect(): Promise { + state.inspectCount += 1; + return entry ? { exists: true, ...entry } : ABSENT; + }, + async createDirectory(path: string, mode: string): Promise { + if (config?.createShouldFail || entry) { + throw new Error("LOGIN_PTY_HOME_REJECTED"); + } + created.push({ path, mode }); + entry = config?.createAs ?? cleanDir; + }, + }; +} + +/** + * A fake Daytona PTY handle. It records each input write, drives the output + * stream on demand, and records the kill and the disconnect. The tests use it in + * place of the real SDK `PtyHandle`, so the session runs with no sandbox. + */ +function createFakePtyHandle( + onData: (data: Uint8Array) => void | Promise, + onWaitForConnection?: () => void, +): DaytonaPtyHandle & { + inputs: string[]; + emitText: (text: string) => void; + emitBytes: (bytes: Uint8Array) => void; + finish: (exitCode: number | undefined) => void; + killed: number; + disconnected: number; +} { + const encoder = new TextEncoder(); + const inputs: string[] = []; + let resolveWait: ((value: { exitCode?: number; error?: string }) => void) | null = null; + const waitPromise = new Promise<{ exitCode?: number; error?: string }>((resolve) => { + resolveWait = resolve; + }); + let killed = 0; + let disconnected = 0; + return { + async waitForConnection(): Promise { + onWaitForConnection?.(); + }, + async sendInput(data: string | Uint8Array): Promise { + inputs.push(typeof data === "string" ? data : new TextDecoder().decode(data)); + }, + wait(): Promise<{ exitCode?: number; error?: string }> { + return waitPromise; + }, + async kill(): Promise { + killed += 1; + }, + async disconnect(): Promise { + disconnected += 1; + }, + emitText(text: string): void { + onData(encoder.encode(text)); + }, + emitBytes(bytes: Uint8Array): void { + onData(bytes); + }, + finish(exitCode: number | undefined): void { + resolveWait?.({ exitCode }); + }, + get inputs(): string[] { + return inputs; + }, + get killed(): number { + return killed; + }, + get disconnected(): number { + return disconnected; + }, + }; +} + +/** + * A fake Daytona process. It opens one fake PTY handle and records the create + * options, so a test asserts the terminal size and the launch line. `onCreatePty` + * runs when the session opens the terminal, so a test swaps a symlink in after + * the directory creation but before the launch input. + */ +function createFakeProcess(onCreatePty?: () => void): DaytonaPtyProcess & { + handle: ReturnType | null; + createOptions: DaytonaPtyCreateOptions | null; + createCount: number; +} { + const state: { + handle: ReturnType | null; + createOptions: DaytonaPtyCreateOptions | null; + createCount: number; + } = { handle: null, createOptions: null, createCount: 0 }; + return { + get handle() { + return state.handle; + }, + get createOptions() { + return state.createOptions; + }, + get createCount() { + return state.createCount; + }, + async createPty(options: DaytonaPtyCreateOptions): Promise { + state.createCount += 1; + state.createOptions = options; + onCreatePty?.(); + const handle = createFakePtyHandle(options.onData); + state.handle = handle; + return handle; + }, + }; +} + +describe("encodePosixShellArg", () => { + it("wraps a value in single quotes and neutralizes metacharacters", () => { + expect(encodePosixShellArg("/tmp/x")).toBe("'/tmp/x'"); + // A metacharacter, a space, and a semicolon stay literal inside the quotes. + expect(encodePosixShellArg("; rm -rf / #")).toBe("'; rm -rf / #'"); + // An embedded single quote closes, escapes, and reopens the quote. + expect(encodePosixShellArg("a'b")).toBe("'a'\\''b'"); + expect(encodePosixShellArg("$(id)")).toBe("'$(id)'"); + }); +}); + +describe("composeLaunchLine", () => { + it("composes the Claude line with no CODEX_HOME", () => { + const line = composeLaunchLine(CLAUDE); + expect(line).toBe("exec claude setup-token"); + expect(line).not.toContain("CODEX_HOME"); + }); + + it("composes the Codex line with exactly one encoded CODEX_HOME", () => { + const line = composeLaunchLine(CODEX); + expect(line).toBe(`exec env CODEX_HOME='${HOME}' codex login --device-auth`); + // Exactly one CODEX_HOME assignment, and the exact approved Codex command. + expect(line.match(/CODEX_HOME=/g)).toHaveLength(1); + expect(line.endsWith("codex login --device-auth")).toBe(true); + }); +}); + +describe("openDaytonaLoginPtySession — session home", () => { + it("creates the exact UUID directory as a new 0700 directory owned by the login user", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs(); + + await openDaytonaLoginPtySession(process, fs, CLAUDE); + + // The provider created the exact directory as a new 0700 directory. + expect(fs.created).toEqual([{ path: HOME, mode: "700" }]); + // The terminal opened and the launch line ran. + expect(process.createCount).toBe(1); + expect(process.handle?.inputs[0]).toBe("exec claude setup-token" + ENTER); + }); + + it("sends the Codex launch line with the encoded CODEX_HOME", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs(); + + await openDaytonaLoginPtySession(process, fs, CODEX); + + expect(process.handle?.inputs[0]).toBe( + `exec env CODEX_HOME='${HOME}' codex login --device-auth` + ENTER, + ); + // The Claude line carries no CODEX_HOME; the Codex line carries exactly one. + expect((process.handle?.inputs[0]?.match(/CODEX_HOME=/g) ?? []).length).toBe(1); + }); + + it("rejects a pre-existing target before it creates the directory", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs({ + seed: { isSymlink: false, isDirectory: true, mode: "700", ownerUid: LOGIN_UID }, + }); + + await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow( + "LOGIN_PTY_HOME_REJECTED", + ); + // The provider never created the directory and never opened the terminal. + expect(fs.created).toEqual([]); + expect(process.createCount).toBe(0); + }); + + it("rejects a pre-existing symlink at the target", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs({ + seed: { isSymlink: true, isDirectory: false, mode: "777", ownerUid: LOGIN_UID }, + }); + + await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow( + "LOGIN_PTY_HOME_REJECTED", + ); + expect(process.createCount).toBe(0); + }); + + it("rejects a created target that is a symlink", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs({ + createAs: { isSymlink: true, isDirectory: false, mode: "700", ownerUid: LOGIN_UID }, + }); + + await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow( + "LOGIN_PTY_HOME_REJECTED", + ); + expect(process.createCount).toBe(0); + }); + + it("rejects a created target that is not a directory", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs({ + createAs: { isSymlink: false, isDirectory: false, mode: "700", ownerUid: LOGIN_UID }, + }); + + await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow( + "LOGIN_PTY_HOME_REJECTED", + ); + expect(process.createCount).toBe(0); + }); + + it("rejects a created directory with a wrong owner", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs({ + createAs: { isSymlink: false, isDirectory: true, mode: "700", ownerUid: LOGIN_UID + 1 }, + }); + + await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow( + "LOGIN_PTY_HOME_REJECTED", + ); + expect(process.createCount).toBe(0); + }); + + it("rejects a created directory with a wrong mode", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs({ + createAs: { isSymlink: false, isDirectory: true, mode: "755", ownerUid: LOGIN_UID }, + }); + + await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow( + "LOGIN_PTY_HOME_REJECTED", + ); + expect(process.createCount).toBe(0); + }); + + it("rejects a symlink swapped in after creation, before the launch input", async () => { + // The directory validates cleanly, then a symlink replaces it while the + // terminal opens. The no-symlink re-check before the launch input fails. + const fs = createFakeHomeFs(); + const process = createFakeProcess(() => { + fs.setEntry({ isSymlink: true, isDirectory: false, mode: "777", ownerUid: LOGIN_UID }); + }); + + await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow( + "LOGIN_PTY_HOME_REJECTED", + ); + // The terminal opened, but the launch input never ran. + expect(process.createCount).toBe(1); + expect(process.handle?.inputs).toEqual([]); + }); + + it("rejects a descriptor with a command key outside the closed set", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs(); + + await expect( + openDaytonaLoginPtySession(process, fs, { + loginCommandKey: "gemini" as unknown as "claude", + sessionHome: HOME, + }), + ).rejects.toThrow("LOGIN_PTY_DESCRIPTOR_REJECTED"); + // The provider never touched the filesystem or the terminal. + expect(fs.created).toEqual([]); + expect(process.createCount).toBe(0); + }); + + it("rejects a descriptor whose session home shape is wrong", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs(); + + await expect( + openDaytonaLoginPtySession(process, fs, { + loginCommandKey: "codex", + sessionHome: "/tmp/paperclip-adapter-login/../etc", + }), + ).rejects.toThrow("LOGIN_PTY_DESCRIPTOR_REJECTED"); + expect(process.createCount).toBe(0); + }); +}); + +describe("openDaytonaLoginPtySession — session mechanics", () => { + it("starts the login command on a pseudo-terminal with a fixed size", async () => { + const process = createFakeProcess(); + + await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE); + + expect(process.createOptions?.cols).toBe(120); + expect(process.createOptions?.rows).toBe(30); + expect(process.handle?.inputs[0]).toBe("exec claude setup-token" + ENTER); + }); + + it("returns incremental terminal output to the listener", async () => { + const process = createFakeProcess(); + const received: string[] = []; + + const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE); + session.onData((chunk) => received.push(chunk)); + + process.handle?.emitText("the url below to sign in\n"); + process.handle?.emitText("Paste code here if prompted\n"); + + expect(received).toEqual(["the url below to sign in\n", "Paste code here if prompted\n"]); + }); + + it("buffers early output until the listener registers", async () => { + const process = createFakeProcess(); + const received: string[] = []; + + const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE); + process.handle?.emitText("early output "); + process.handle?.emitText("more output"); + expect(received).toEqual([]); + + session.onData((chunk) => received.push(chunk)); + expect(received).toEqual(["early output more output"]); + }); + + it("delivers the browser code plus the Enter byte to the command", async () => { + const process = createFakeProcess(); + + const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE); + session.write("BROWSERCODE" + ENTER); + // The write runs through the serialized chunk chain, so it lands on a later + // microtask. Flush the chain before the assertion. + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(process.handle?.inputs[1]).toBe("BROWSERCODE" + ENTER); + expect(process.handle?.inputs[1]?.endsWith(ENTER)).toBe(true); + }); + + it("keeps a multibyte character whole across two output chunks", async () => { + const process = createFakeProcess(); + const received: string[] = []; + + const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE); + session.onData((chunk) => received.push(chunk)); + + const euro = new TextEncoder().encode("€"); + process.handle?.emitBytes(euro.subarray(0, 2)); + process.handle?.emitBytes(euro.subarray(2)); + + expect(received.join("")).toBe("€"); + }); + + it("resolves wait with the command exit code", async () => { + const process = createFakeProcess(); + + const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE); + process.handle?.finish(9); + + await expect(session.wait()).resolves.toEqual({ exitCode: 9 }); + }); + + it("maps an absent exit code to null", async () => { + const process = createFakeProcess(); + + const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE); + process.handle?.finish(undefined); + + await expect(session.wait()).resolves.toEqual({ exitCode: null }); + }); + + it("kills the child and closes the session", async () => { + const process = createFakeProcess(); + + const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE); + session.kill(); + expect(process.handle?.killed).toBe(1); + + await session.close(); + expect(process.handle?.disconnected).toBe(1); + }); + + it("passes the working directory to the pseudo-terminal", async () => { + const process = createFakeProcess(); + + await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE, { cwd: "/workspace" }); + + expect(process.createOptions?.cwd).toBe("/workspace"); + }); +}); + +describe("openDaytonaLoginPtySession — ancestor symlink", () => { + it("starts no pseudo-terminal when the home filesystem check fails closed", async () => { + // A symlinked login-root ancestor makes the descriptor-relative inspection fail + // closed. The session opener must reject before it opens the terminal, so no + // pseudo-terminal starts and the login command never runs. + const process = createFakeProcess(); + let created = false; + const fs: DaytonaLoginHomeFs = { + async loginUserId(): Promise { + return LOGIN_UID; + }, + async inspect(): Promise { + throw new Error("LOGIN_PTY_HOME_REJECTED"); + }, + async createDirectory(): Promise { + created = true; + }, + }; + + await expect(openDaytonaLoginPtySession(process, fs, CODEX)).rejects.toThrow( + "LOGIN_PTY_HOME_REJECTED", + ); + // The provider never created the directory and never opened the terminal. + expect(created).toBe(false); + expect(process.createCount).toBe(0); + }); +}); + +describe("createDaytonaLoginHomeFs — login-profile preamble", () => { + // A capturing exec surface. It records each command and returns a fixed result, + // so a test reads the exact command string the helper runs. + function createCapturingExec(result: DaytonaExecResult): DaytonaSandboxExec & { + commands: string[]; + } { + const commands: string[] = []; + return { + commands, + async executeCommand(command: string): Promise { + commands.push(command); + // `id -u` resolves the login user id. Return a fixed uid so the caller + // continues to the node helper command under test. + if (command.startsWith("id -u")) { + return { exitCode: 0, result: "1000" }; + } + return result; + }, + }; + } + + // The command sources /etc/profile before it runs node. The Daytona image may + // expose node only through a login profile, so node must run after the profile + // source. This assertion proves the helper runtime and the login PTY capability + // stay consistent. + function expectProfileBeforeNode(command: string | undefined): void { + expect(command).toBeDefined(); + const profileIndex = command!.indexOf("/etc/profile"); + const nodeIndex = command!.indexOf("node -e"); + expect(profileIndex).toBeGreaterThanOrEqual(0); + expect(nodeIndex).toBeGreaterThanOrEqual(0); + expect(profileIndex).toBeLessThan(nodeIndex); + } + + it("sources the login profiles before it runs the node inspect helper", async () => { + const exec = createCapturingExec({ exitCode: 0, result: "ABSENT" }); + const fs = createDaytonaLoginHomeFs(exec); + await fs.inspect(HOME); + expectProfileBeforeNode(exec.commands.find((command) => command.includes("node -e"))); + }); + + it("sources the login profiles before it runs the node create helper", async () => { + const exec = createCapturingExec({ exitCode: 0, result: "" }); + const fs = createDaytonaLoginHomeFs(exec); + await fs.createDirectory(HOME, "700"); + expectProfileBeforeNode(exec.commands.find((command) => command.includes("node -e"))); + }); +}); + +// The real login-home filesystem runs a node helper on the sandbox. The helper +// walks the path with `/proc/self/fd`, which is Linux only. A non-Linux host skips +// these tests. The fake exec runs the helper locally, so the descriptor-relative +// walk is tested against a real filesystem. The login sandbox is Linux. +const describeLinux = process.platform === "linux" ? describe : describe.skip; + +describeLinux("createDaytonaLoginHomeFs — descriptor-relative walk", () => { + let root: string; + + beforeAll(() => { + root = mkdtempSync(path.join(tmpdir(), "daytona-login-home-")); + }); + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + // A local exec surface. It runs the composed command with `/bin/sh -c`, so the + // real node helper runs against the local filesystem. + function createLocalExec(): DaytonaSandboxExec { + return { + async executeCommand(command: string): Promise { + const result = spawnSync("/bin/sh", ["-c", command], { encoding: "utf8" }); + return { exitCode: result.status ?? undefined, result: result.stdout ?? "" }; + }, + }; + } + + const UUID = "11111111-2222-4333-8444-555555555555"; + + it("reports a not-yet-created target as absent", async () => { + const fs = createDaytonaLoginHomeFs(createLocalExec()); + const home = path.join(root, "absent-root", UUID); + const inspection = await fs.inspect(home); + expect(inspection.exists).toBe(false); + }); + + it("creates the login root and the session home relative to a trusted descriptor", async () => { + const fs = createDaytonaLoginHomeFs(createLocalExec()); + const home = path.join(root, "clean-root", UUID); + await fs.createDirectory(home, "700"); + const inspection = await fs.inspect(home); + expect(inspection.exists).toBe(true); + expect(inspection.isSymlink).toBe(false); + expect(inspection.isDirectory).toBe(true); + expect(inspection.mode).toBe("700"); + expect(inspection.ownerUid).toBe(process.getuid ? process.getuid() : inspection.ownerUid); + }); + + it("rejects a pre-existing session home target", async () => { + const fs = createDaytonaLoginHomeFs(createLocalExec()); + const home = path.join(root, "exists-root", UUID); + await fs.createDirectory(home, "700"); + // A second create of the same target fails, because the target exists. + await expect(fs.createDirectory(home, "700")).rejects.toThrow("LOGIN_PTY_HOME_REJECTED"); + }); + + it("fails the inspection closed when the login root is a symlink", async () => { + // Pre-place a symlink at the login-root ancestor. The descriptor-relative walk + // opens the root with a no-follow open, so the inspection fails closed. + const fs = createDaytonaLoginHomeFs(createLocalExec()); + const attacker = path.join(root, "inspect-attacker"); + mkdirSync(attacker, { mode: 0o700 }); + const rootLink = path.join(root, "inspect-symlink-root"); + symlinkSync(attacker, rootLink); + const home = path.join(rootLink, UUID); + await expect(fs.inspect(home)).rejects.toThrow("LOGIN_PTY_HOME_REJECTED"); + }); + + it("fails the creation closed when the login root is a pre-placed symlink", async () => { + // Pre-place a symlink at the login-root ancestor that points at an attacker + // tree. The creation opens the root with a no-follow open after the tolerant + // `mkdir`, so it fails closed and creates no directory in the attacker tree. + const fs = createDaytonaLoginHomeFs(createLocalExec()); + const attacker = path.join(root, "create-attacker"); + mkdirSync(attacker, { mode: 0o700 }); + const rootLink = path.join(root, "create-symlink-root"); + symlinkSync(attacker, rootLink); + const home = path.join(rootLink, UUID); + await expect(fs.createDirectory(home, "700")).rejects.toThrow("LOGIN_PTY_HOME_REJECTED"); + // The creation placed no session home inside the attacker tree. + expect(existsSync(path.join(attacker, UUID))).toBe(false); + }); +}); + +describe("createDaytonaLoginPtySessionOpener", () => { + it("opens a session for the descriptor on each call", async () => { + const process = createFakeProcess(); + const opener = createDaytonaLoginPtySessionOpener(process, createFakeHomeFs(), { + cwd: "/workspace", + }); + + const session = await opener(CLAUDE); + + expect(process.createOptions?.cwd).toBe("/workspace"); + expect(process.handle?.inputs[0]).toBe("exec claude setup-token" + ENTER); + expect(typeof session.onData).toBe("function"); + expect(typeof session.write).toBe("function"); + expect(typeof session.wait).toBe("function"); + expect(typeof session.kill).toBe("function"); + expect(typeof session.close).toBe("function"); + }); +}); diff --git a/packages/plugins/sandbox-providers/daytona/src/login-pty.ts b/packages/plugins/sandbox-providers/daytona/src/login-pty.ts new file mode 100644 index 0000000000..803cafc729 --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/src/login-pty.ts @@ -0,0 +1,586 @@ +// The Daytona pseudo-terminal (PTY) session for the shared login flow. The login +// command needs a real pseudo-terminal: pipe stdio emits no login prompt. The +// Daytona SDK opens a PTY through `process.createPty`. This module binds that PTY +// to a small session that the login PTY transport consumes, so the login runner +// runs the command on a real terminal, streams the terminal output, and delivers +// the delayed browser code plus the Enter byte. +// +// Command contract: the host resolves a closed login command key and a validated +// session home, and it carries them in a launch descriptor. The descriptor holds +// no command string. This module maps the key to a compile-time command, so a +// caller cannot select or override the command. For the Codex key the module +// composes `exec env CODEX_HOME= `. For the +// Claude key it composes `exec ` with no CODEX_HOME. The +// module encodes the dynamic home with a POSIX shell-argument encoder, so the +// home cannot add a second shell token or a second command. +// +// Session home: the module revalidates the descriptor and the home shape, then +// creates the exact UUID directory as a NEW directory with mode 0700, owned by the +// login user. The inspection and the creation open the filesystem root, then walk +// each path component with a no-follow, directory-only open, so a symlink at any +// ancestor (the login root or a directory above it) fails closed. A single +// composite `stat` or `mkdir` follows a symlink at an ancestor; the per-component +// walk does not. The creation creates the login root if the root is absent and the +// UUID directory as a NEW directory, both relative to an open trusted descriptor. +// The module rejects a pre-existing target, a symlink, a non-directory, a wrong +// owner, and a wrong mode. It uses no recursive delete. It re-checks the directory +// with a no-symlink walk before it opens the terminal and again before it sends the +// launch input, so a symlink swapped in after creation fails before the launch. +// +// Dependency boundary: this provider plugin ships standalone (the workspace +// excludes `packages/plugins/sandbox-providers/**`). So the module imports no +// workspace package. It declares the small session shape locally through +// {@link LoginPtySession}. The shape matches the `LoginPtySession` interface in +// `@paperclipai/adapter-utils`, so the transport factory `createLoginPtyTransport` +// accepts the session opener from this module with no adapter. The runner drives +// the transport; the transport drives this session. +// +// SDK boundary: the module holds no Daytona SDK type import. It declares the small +// subset of the SDK PTY and filesystem surfaces that it uses through +// {@link DaytonaPtyProcess}, {@link DaytonaPtyHandle}, and {@link DaytonaSandboxExec}. +// The SDK `Process` and `PtyHandle` satisfy those subsets, so a caller passes +// `sandbox.process` with no adapter. The narrow surface keeps the module +// unit-testable with a fake PTY and a fake filesystem. +// +// Security (secret handling): the login runner delivers the browser code and the +// Enter byte through {@link DaytonaPtyHandle.sendInput}. The SDK sends the input +// over the PTY socket, so the code never rides a command line and never reaches a +// process argument list. The session forwards the raw terminal bytes with no ANSI +// or OSC 8 handling; the login parser owns that handling. + +import { randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { sendPtyInputInChunks } from "./pty-chunked-input.js"; + +/** + * The closed set of login command identities. The worker maps each key to a + * compile-time command. A value outside this set fails closed before the module + * touches the filesystem. + */ +export type LoginCommandKey = "claude" | "codex"; + +/** + * The host-resolved launch descriptor. It carries the closed command key and the + * server-controlled session home. It holds no command string. + */ +export interface LoginPtyLaunchDescriptor { + /** The host-resolved fixed command identity. */ + loginCommandKey: LoginCommandKey; + /** + * The server-controlled session home. The shape is exact: + * `/tmp/paperclip-adapter-login/`. + */ + sessionHome: string; +} + +/** + * The compile-time command for each login command key. The Daytona plugin ships + * standalone, so it holds its own copy of the fixed commands. A future change to + * a command lands here and in the adapter constant together. + */ +const LOGIN_COMMAND_BY_KEY: Readonly> = { + claude: "claude setup-token", + codex: "codex login --device-auth", +}; + +/** The octal mode string for a new session home directory. */ +const LOGIN_HOME_MODE = "700"; + +/** The fixed root for a login session home. */ +const LOGIN_SESSION_HOME_ROOT = "/tmp/paperclip-adapter-login"; + +/** + * The exact absolute path shape for a login session home. The path is the fixed + * root, one slash, and one UUID. The anchors reject a relative path, a traversal, + * whitespace, a control character, and a shell metacharacter, because none of + * those match the UUID or the fixed root. + */ +const SESSION_HOME_PATTERN = + /^\/tmp\/paperclip-adapter-login\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** The fixed non-secret error a rejected launch descriptor or session home returns. */ +export const LOGIN_PTY_DESCRIPTOR_REJECTED = "LOGIN_PTY_DESCRIPTOR_REJECTED"; +/** The fixed non-secret error a rejected session home directory returns. */ +export const LOGIN_PTY_HOME_REJECTED = "LOGIN_PTY_HOME_REJECTED"; + +/** + * Encodes one value as a single POSIX shell argument. It wraps the value in + * single quotes and escapes each embedded single quote. The shell treats every + * other character inside single quotes as a literal, so an encoded value cannot + * add a second shell token or a second command. + */ +export function encodePosixShellArg(value: string): string { + return `'${value.split("'").join("'\\''")}'`; +} + +/** Reports whether a value is a member of the closed login command key set. */ +export function isLoginCommandKey(value: unknown): value is LoginCommandKey { + return value === "claude" || value === "codex"; +} + +/** + * Composes the launch line for the descriptor. For the Codex key it prefixes one + * safely encoded `CODEX_HOME` assignment with `env`. For the Claude key it adds no + * `CODEX_HOME`. It replaces the interactive shell with the command through `exec`, + * so the pseudo-terminal runs the command directly and its exit code becomes the + * PTY exit code. + */ +export function composeLaunchLine(descriptor: LoginPtyLaunchDescriptor): string { + const command = LOGIN_COMMAND_BY_KEY[descriptor.loginCommandKey]; + if (descriptor.loginCommandKey === "codex") { + const encodedHome = encodePosixShellArg(descriptor.sessionHome); + return `exec env CODEX_HOME=${encodedHome} ${command}`; + } + return `exec ${command}`; +} + +/** + * A live pseudo-terminal session for one login command. The session allocates a + * real pseudo-terminal, streams the raw terminal output, accepts delayed input, + * and stops the child. The shape matches the `LoginPtySession` interface in + * `@paperclipai/adapter-utils`, so the transport factory there accepts a session + * opener that returns this session. + */ +export interface LoginPtySession { + /** Registers the one output listener. The session streams each raw chunk in order. */ + onData(listener: (chunk: string) => void): void; + /** Writes raw input bytes to the pseudo-terminal. */ + write(data: string): void; + /** Resolves with the child exit code when the command ends. */ + wait(): Promise<{ exitCode: number | null }>; + /** Stops the child process. Safe to call more than one time. */ + kill(): void; + /** Releases the session resources. Safe to call more than one time. */ + close(): Promise; +} + +/** Opens a {@link LoginPtySession} for `descriptor`. The transport calls it one time. */ +export type LoginPtySessionOpener = ( + descriptor: LoginPtyLaunchDescriptor, +) => Promise; + +/** + * The subset of the Daytona SDK `PtyHandle` that the session uses. The SDK + * `PtyHandle` satisfies this interface, so a caller passes the real handle with + * no adapter. + */ +export interface DaytonaPtyHandle { + /** Resolves when the PTY socket connection is ready for input and output. */ + waitForConnection(): Promise; + /** Sends raw input bytes to the pseudo-terminal. */ + sendInput(data: string | Uint8Array): Promise; + /** Resolves with the exit result when the pseudo-terminal process ends. */ + wait(): Promise<{ exitCode?: number; error?: string }>; + /** Kills the pseudo-terminal process. */ + kill(): Promise; + /** Closes the PTY socket and releases the resources. */ + disconnect(): Promise; +} + +/** + * The options the session passes to `createPty`. The session sets a fixed + * terminal size and one output callback. It sets the working directory when the + * caller provides one. + */ +export interface DaytonaPtyCreateOptions { + id: string; + cwd?: string; + cols: number; + rows: number; + onData: (data: Uint8Array) => void | Promise; +} + +/** + * The subset of the Daytona SDK `Process` that the session uses. The SDK + * `Process` satisfies this interface, so a caller passes `sandbox.process`. + */ +export interface DaytonaPtyProcess { + createPty(options: DaytonaPtyCreateOptions): Promise; +} + +/** The result of one command run on the sandbox. */ +export interface DaytonaExecResult { + exitCode?: number; + result?: string; +} + +/** + * The subset of the Daytona SDK `Process` that the filesystem helper uses. The + * SDK `Process` satisfies this interface through `executeCommand`, so a caller + * passes `sandbox.process`. + */ +export interface DaytonaSandboxExec { + executeCommand( + command: string, + cwd?: string, + env?: Record, + timeout?: number, + ): Promise; +} + +/** One no-follow inspection of a filesystem path. */ +export interface LoginHomeInspection { + /** True when the path exists (as any type). */ + exists: boolean; + /** True when the path itself is a symbolic link. */ + isSymlink: boolean; + /** True when the path itself is a directory. */ + isDirectory: boolean; + /** The octal permission string, for example `700`. Empty when the path is absent. */ + mode: string; + /** The owner user id, or null when the path is absent. */ + ownerUid: number | null; +} + +/** + * The narrow filesystem surface the session home operations need. The production + * surface runs commands on the sandbox; a unit test injects a fake. The inspection + * and the creation walk each path component with a no-follow open, so a symlink at + * any ancestor fails closed and a symlink at the target reports the link. + */ +export interface DaytonaLoginHomeFs { + /** Resolves the user id of the login user that runs the pseudo-terminal. */ + loginUserId(): Promise; + /** + * Inspects `path` without following a symlink at the target or at any ancestor. + * It rejects (throws) when an ancestor is a symlink or a non-directory. + */ + inspect(path: string): Promise; + /** + * Creates `path` as a NEW directory with the octal `mode`. It creates the login + * root ancestor if the root is absent. It fails when the target path exists or + * when an ancestor is a symlink. It follows no composite pathname. + */ + createDirectory(path: string, mode: string): Promise; +} + +/** The options for the Daytona login PTY session. */ +export interface DaytonaLoginPtyOptions { + /** The working directory for the login PTY. Defaults to the sandbox default. */ + cwd?: string; +} + +// The terminal size for the login PTY. The login UI prints a short prompt, so a +// standard terminal size is enough. A fixed size keeps the output stable. +const LOGIN_PTY_COLS = 120; +const LOGIN_PTY_ROWS = 30; + +/** + * The input terminator that replaces the interactive shell with the login + * command. The `exec` builtin replaces the shell process image with the command, + * so the PTY runs the command directly. When the command ends, the PTY ends with + * the command exit code, and the delayed input reaches the command, not a shell. + * The terminal reads the carriage return as the Enter key. + */ +const PTY_COMMAND_TERMINATOR = "\r"; + +/** Normalizes an octal permission string to its numeric value for comparison. */ +function octalMode(value: string): number { + return Number.parseInt(value, 8); +} + +/** + * Revalidates the launch descriptor. It fails closed when the command key is + * outside the closed set or the session home shape is wrong. A unit test that + * bypasses the host boundary hits this check, so the provider never trusts an + * unvalidated descriptor. + */ +function assertDescriptor(descriptor: LoginPtyLaunchDescriptor): void { + if (!isLoginCommandKey(descriptor.loginCommandKey)) { + throw new Error(LOGIN_PTY_DESCRIPTOR_REJECTED); + } + if (!SESSION_HOME_PATTERN.test(descriptor.sessionHome)) { + throw new Error(LOGIN_PTY_DESCRIPTOR_REJECTED); + } +} + +/** + * Creates and validates the exact session home directory. It rejects a + * pre-existing target (including a symlink), creates the directory as a NEW + * directory with mode 0700, then verifies the directory type, the no-symlink + * state, the mode, and the login-user ownership. It uses no recursive delete. + */ +async function prepareSessionHome(fs: DaytonaLoginHomeFs, sessionHome: string): Promise { + const loginUid = await fs.loginUserId(); + + // Reject a pre-existing target. A no-follow inspection reports a symlink, a + // file, or a directory as present, so any pre-existing target fails here. + const before = await fs.inspect(sessionHome); + if (before.exists) { + throw new Error(LOGIN_PTY_HOME_REJECTED); + } + + // Create the exact directory as a NEW directory with mode 0700. The create + // fails when the path exists, so the create never follows a symlink. + await fs.createDirectory(sessionHome, LOGIN_HOME_MODE); + + // Verify the created directory. Reject a symlink, a non-directory, a wrong + // owner, and a wrong mode. Do not delete on a rejection; the sandbox is + // ephemeral and the provider uses no recursive delete. + const after = await fs.inspect(sessionHome); + if ( + !after.exists || + after.isSymlink || + !after.isDirectory || + after.ownerUid !== loginUid || + octalMode(after.mode) !== octalMode(LOGIN_HOME_MODE) + ) { + throw new Error(LOGIN_PTY_HOME_REJECTED); + } +} + +/** + * Re-checks the directory with a no-symlink check. It rejects a symlink and a + * non-directory, so a symlink swapped in after creation fails before the launch. + */ +async function assertHomeStillSafe(fs: DaytonaLoginHomeFs, sessionHome: string): Promise { + const now = await fs.inspect(sessionHome); + if (!now.exists || now.isSymlink || !now.isDirectory) { + throw new Error(LOGIN_PTY_HOME_REJECTED); + } +} + +/** + * Opens a Daytona PTY session for `descriptor` and returns it as a + * {@link LoginPtySession}. The function revalidates the descriptor and the home + * shape, creates and validates the session home, opens a real pseudo-terminal, + * re-checks the directory before the launch, composes the safe launch line, and + * sends it. The session streams the raw terminal output, delivers delayed input, + * and stops the child. + * + * The function decodes the terminal bytes as a UTF-8 stream, so a multibyte + * character that splits across two output chunks stays whole. It buffers the + * output until the transport registers the listener, so no early chunk is lost. + */ +export async function openDaytonaLoginPtySession( + process: DaytonaPtyProcess, + fs: DaytonaLoginHomeFs, + descriptor: LoginPtyLaunchDescriptor, + options?: DaytonaLoginPtyOptions, +): Promise { + assertDescriptor(descriptor); + // Create and validate the session home before the terminal opens. + await prepareSessionHome(fs, descriptor.sessionHome); + // Re-check the directory with a no-symlink check before `createPty`. + await assertHomeStillSafe(fs, descriptor.sessionHome); + + const decoder = new TextDecoder("utf-8"); + let listener: ((chunk: string) => void) | null = null; + let buffered = ""; + + const handle = await process.createPty({ + id: `paperclip-login-pty-${randomUUID()}`, + ...(options?.cwd ? { cwd: options.cwd } : {}), + cols: LOGIN_PTY_COLS, + rows: LOGIN_PTY_ROWS, + onData: (data: Uint8Array): void => { + // Decode the terminal bytes as a stream, so a split multibyte character + // stays whole across two chunks. Forward the raw text; the parser owns the + // ANSI and OSC 8 handling. + const text = decoder.decode(data, { stream: true }); + if (text.length === 0) return; + if (listener) listener(text); + else buffered += text; + }, + }); + + await handle.waitForConnection(); + // Re-check the directory with a no-symlink check immediately before the launch + // input, so a symlink swapped in after creation fails before `sendInput`. + await assertHomeStillSafe(fs, descriptor.sessionHome); + // Replace the interactive shell with the login command, so the pseudo-terminal + // runs the command directly. The runner then writes the delayed browser code + // to the command, not to a shell. + await handle.sendInput(composeLaunchLine(descriptor) + PTY_COMMAND_TERMINATOR); + + // The tail of the write chain. The chunker awaits each chunk, so one write can + // suspend between its chunks. The chain runs each write after the previous + // write ends, so the chunks of two writes never interleave on the wire. + let writeChain: Promise = Promise.resolve(); + + return { + onData(next: (chunk: string) => void): void { + listener = next; + if (buffered.length > 0) { + const pending = buffered; + buffered = ""; + next(pending); + } + }, + write(data: string): void { + // Send the input as byte-bounded chunks under the provider message cap. The + // login input is short today, so the latent defect never fires, but the same + // unbounded write goes through the one chunker. The chain runs this write + // after the previous write ends, so two writes keep their order and never + // interleave their chunks. A write error must not throw into the runner, so + // the runner's fixed status stays the single result path. + writeChain = writeChain.then(() => + sendPtyInputInChunks((chunk) => handle.sendInput(chunk), data), + ); + }, + async wait(): Promise<{ exitCode: number | null }> { + const result = await handle.wait(); + return { exitCode: typeof result.exitCode === "number" ? result.exitCode : null }; + }, + kill(): void { + void handle.kill().catch(() => undefined); + }, + async close(): Promise { + await handle.disconnect().catch(() => undefined); + }, + }; +} + +/** + * The inspection helper source. The provider reads it from its own script file and + * runs it on the sandbox as ` && node -e