feat(login): use the login pseudo-terminal for Codex device login and de-Claude the shared channel (#12020)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agent adapters use provider-specific login flows > - Codex device login needs a live pseudo-terminal (PTY), while the shared channel still uses Claude-specific names > - The old streamed-exec path does not provide the prompt transport that Codex needs > - This pull request moves Codex device login to the shared login PTY and removes the dead streamed-exec path > - The benefit is one controlled login transport with fail-closed capability checks and safer credential reads ## Linked Issues or Issue Description **Problem or motivation** Codex device login used a streamed-exec path that did not provide the required prompt transport. The shared login channel also exposed Claude-specific names outside Claude code. **Expected behavior** The host selects a fixed login command from trusted adapter data. Codex login uses the provider login PTY. Providers without that capability fail closed. **Proposed solution** Use a server-controlled session home, create and validate it as a fresh 0700 directory, read credentials from one validated descriptor, and rename shared channel names to the neutral login PTY family. **Alternatives considered** Keep the shared login PTY as the single transport. Do not keep the removed streamed-exec path because it cannot provide the required prompt transport. **Roadmap alignment** This change supports the planned login transport work. It does not add a separate roadmap item. ## What Changed - Route Codex device login through the shared login PTY transport. - Select the login command from a closed internal command key. - Carry a server-controlled session home through the launch contract. - Create and validate the session home as a fresh 0700 directory owned by the login user. - Read the credential file with descriptor-relative, no-follow path walking and final descriptor checks. - Gate the login route and run lease on the provider login PTY capability. - Rename shared channel names to the neutral login PTY family. - Remove the streamed-exec transport value, selector field, driver branch, and related tests. - Hide Codex login in the user interface when the provider lacks the login PTY capability. ## Verification - Server unit suites pass: 89/89. - Adapter-utils suites pass: 262/262. - Codex-local suites pass: 326/326. - Credential-read reader suite passes: 20/20. - Daytona login PTY suite passes: 30/30. - Device-login suites pass: 56/56. - TypeScript checks pass for server, adapter-utils, and UI. - GitHub Actions must pass after pull request creation. - Greptile review must reach 5/5 with no open P2 findings, recommendations, or follow-ups. ## Risks - Providers without a login PTY capability lose Codex login support by design. - The credential read rejects invalid ownership, mode, type, path, and size. - The launch-time sandbox directory race remains outside the threat model because the login runs inside the sandbox and a hostile sandbox already controls its credential. ## Model Used OpenAI Codex, GPT-5, tool use and code review assistance. The exact context window and reasoning mode are not exposed by the runtime. ## 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: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
16b59c9315
commit
63df7ad2b3
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
|
|
@ -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(", ")}.`,
|
||||
|
|
|
|||
|
|
@ -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<void>((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<void>((resolve) => {
|
||||
resolveOpen = resolve;
|
||||
});
|
||||
const transport = createSetupTokenPtyTransport(async () => {
|
||||
const transport = createLoginPtyTransport(async () => {
|
||||
await openGate;
|
||||
return session;
|
||||
});
|
||||
|
|
@ -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<SetupTokenPtySession>;
|
||||
export type LoginPtySessionOpener = (command: string) => Promise<LoginPtySession>;
|
||||
|
||||
/**
|
||||
* 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<SetupToken
|
|||
* accepts the transport with no adapter. The transport never imports the runner,
|
||||
* so the runner package keeps its one-way dependency on this package.
|
||||
*/
|
||||
export interface SetupTokenPtyTransport {
|
||||
export interface LoginPtyTransport {
|
||||
/**
|
||||
* Opens the pseudo-terminal session for `command` and streams the terminal
|
||||
* output to `onData` in order. Resolves with the child exit code when the
|
||||
|
|
@ -86,15 +86,15 @@ export interface SetupTokenPtyTransport {
|
|||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link SetupTokenPtyTransport} over `open`. The transport adapts a
|
||||
* Creates a {@link LoginPtyTransport} over `open`. The transport adapts a
|
||||
* pseudo-terminal session into the runner driver shape. It buffers an early
|
||||
* input write, it honors an early stop, and it forwards the raw terminal bytes
|
||||
* with no ANSI or OSC 8 handling.
|
||||
*/
|
||||
export function createSetupTokenPtyTransport(
|
||||
open: SetupTokenPtySessionOpener,
|
||||
): SetupTokenPtyTransport {
|
||||
let session: SetupTokenPtySession | null = null;
|
||||
export function createLoginPtyTransport(
|
||||
open: LoginPtySessionOpener,
|
||||
): LoginPtyTransport {
|
||||
let session: LoginPtySession | null = null;
|
||||
let started = false;
|
||||
let stopped = false;
|
||||
// Input that the runner writes before the session opens. The transport flushes
|
||||
|
|
@ -113,7 +113,7 @@ export function createSetupTokenPtyTransport(
|
|||
return {
|
||||
async start(command, onData): Promise<{ exitCode: number | null }> {
|
||||
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);
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<Buffer>;
|
||||
}
|
||||
|
||||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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<LoginHomeInspection, "exists">;
|
||||
|
||||
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<number> {
|
||||
return loginUid;
|
||||
},
|
||||
async inspect(): Promise<LoginHomeInspection> {
|
||||
state.inspectCount += 1;
|
||||
return entry ? { exists: true, ...entry } : ABSENT;
|
||||
},
|
||||
async createDirectory(path: string, mode: string): Promise<void> {
|
||||
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<void>,
|
||||
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<void> {
|
||||
onWaitForConnection?.();
|
||||
},
|
||||
async sendInput(data: string | Uint8Array): Promise<void> {
|
||||
inputs.push(typeof data === "string" ? data : new TextDecoder().decode(data));
|
||||
},
|
||||
wait(): Promise<{ exitCode?: number; error?: string }> {
|
||||
return waitPromise;
|
||||
},
|
||||
async kill(): Promise<void> {
|
||||
killed += 1;
|
||||
},
|
||||
async disconnect(): Promise<void> {
|
||||
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<typeof createFakePtyHandle> | null;
|
||||
createOptions: DaytonaPtyCreateOptions | null;
|
||||
createCount: number;
|
||||
} {
|
||||
const state: {
|
||||
handle: ReturnType<typeof createFakePtyHandle> | 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<DaytonaPtyHandle> {
|
||||
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<number> {
|
||||
return LOGIN_UID;
|
||||
},
|
||||
async inspect(): Promise<LoginHomeInspection> {
|
||||
throw new Error("LOGIN_PTY_HOME_REJECTED");
|
||||
},
|
||||
async createDirectory(): Promise<void> {
|
||||
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<DaytonaExecResult> {
|
||||
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<DaytonaExecResult> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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=<encoded-home> <fixed-codex-command>`. For the
|
||||
// Claude key it composes `exec <fixed-claude-command>` 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/<uuid>`.
|
||||
*/
|
||||
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<Record<LoginCommandKey, string>> = {
|
||||
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<void>;
|
||||
}
|
||||
|
||||
/** Opens a {@link LoginPtySession} for `descriptor`. The transport calls it one time. */
|
||||
export type LoginPtySessionOpener = (
|
||||
descriptor: LoginPtyLaunchDescriptor,
|
||||
) => Promise<LoginPtySession>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
/** Sends raw input bytes to the pseudo-terminal. */
|
||||
sendInput(data: string | Uint8Array): Promise<void>;
|
||||
/** Resolves with the exit result when the pseudo-terminal process ends. */
|
||||
wait(): Promise<{ exitCode?: number; error?: string }>;
|
||||
/** Kills the pseudo-terminal process. */
|
||||
kill(): Promise<void>;
|
||||
/** Closes the PTY socket and releases the resources. */
|
||||
disconnect(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<DaytonaPtyHandle>;
|
||||
}
|
||||
|
||||
/** 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<string, string>,
|
||||
timeout?: number,
|
||||
): Promise<DaytonaExecResult>;
|
||||
}
|
||||
|
||||
/** 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<number>;
|
||||
/**
|
||||
* 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<LoginHomeInspection>;
|
||||
/**
|
||||
* 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<void>;
|
||||
}
|
||||
|
||||
/** 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<void> {
|
||||
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<void> {
|
||||
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<LoginPtySession> {
|
||||
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<void> = 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<void> {
|
||||
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 `<login-profile-preamble> && node -e <script> <path>`.
|
||||
* The preamble sources the login profiles first, so `node` resolves on an image
|
||||
* that exposes node only through a login profile. The sandbox already runs node
|
||||
* for the Paperclip bridge, so the helper needs no extra runtime. The helper
|
||||
* source lives in `scripts/login-home-inspect.cjs`, so no large script stays as a
|
||||
* string literal in this module. The helper opens the filesystem root, then walks
|
||||
* each ancestor of the final path component with a no-follow, directory-only open,
|
||||
* and reads the final component with `lstat`.
|
||||
*
|
||||
* The helper prints one line and sets one exit code:
|
||||
* - a missing ancestor or a missing final component prints `ABSENT` and exits 0;
|
||||
* - an existing final component prints `<type>|<octal-mode>|<uid>` and exits 0,
|
||||
* where `<type>` is `symlink`, `directory`, or `other`;
|
||||
* - a symlink or a non-directory at any ancestor prints nothing and exits 3, so
|
||||
* the caller fails closed on an ancestor symlink.
|
||||
*/
|
||||
const LOGIN_HOME_INSPECT_SCRIPT = readFileSync(
|
||||
fileURLToPath(new URL("./scripts/login-home-inspect.cjs", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
/**
|
||||
* The creation helper source. The provider reads it from its own script file and
|
||||
* runs it on the sandbox as
|
||||
* `<login-profile-preamble> && node -e <script> <path> <octal-mode>`. The preamble
|
||||
* sources the login profiles first, so `node` resolves on an image that exposes
|
||||
* node only through a login profile. The helper source lives in
|
||||
* `scripts/login-home-create.cjs`. The helper opens the filesystem
|
||||
* root, then walks each ancestor above the login root with a no-follow,
|
||||
* directory-only open. It creates the login root (the second-to-last component) if
|
||||
* the root is absent, then opens the root with a no-follow open, so a symlink at
|
||||
* the root fails closed. It creates the final component as a NEW directory relative
|
||||
* to the root descriptor, so a pre-existing target fails and the create never
|
||||
* follows a symlink. It exits 0 on success and non-zero on every failure.
|
||||
*
|
||||
* The no-follow open of the root after the create closes the swap window: an
|
||||
* attacker that replaces the root with a symlink between the create and the open
|
||||
* fails the open.
|
||||
*/
|
||||
const LOGIN_HOME_CREATE_SCRIPT = readFileSync(
|
||||
fileURLToPath(new URL("./scripts/login-home-create.cjs", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
/**
|
||||
* The login-profile preamble for a node helper command. Daytona's
|
||||
* `executeCommand` runs the command in a non-login shell, so the shell does not
|
||||
* source `/etc/profile` on its own. The Daytona reference image puts `node` on
|
||||
* the PATH through `/etc/profile.d`, which only a login profile sources. The
|
||||
* login pseudo-terminal resolves the login command through the same profiles, so
|
||||
* the helper sources the login profiles first and a non-login shell then resolves
|
||||
* `node`. This keeps the helper runtime and the login PTY capability consistent:
|
||||
* an image that exposes `node` only through a login profile runs the helper. The
|
||||
* main exec path uses the same profile chain (see `buildLoginShellScript` in
|
||||
* `plugin.ts`). Each source line fails open (`|| true`), so a missing profile
|
||||
* does not fail the helper.
|
||||
*/
|
||||
const LOGIN_PROFILE_PREAMBLE = [
|
||||
"if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi",
|
||||
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
|
||||
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; elif [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc" >/dev/null 2>&1 || true; fi',
|
||||
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
|
||||
].join(" && ");
|
||||
|
||||
/**
|
||||
* Composes a `node` helper command that runs after the login-profile preamble.
|
||||
* The preamble puts `node` on the PATH, then the `&&` chain runs `node -e` with
|
||||
* the already shell-encoded argument list. The `node` exit code becomes the
|
||||
* command exit code, and `2>/dev/null` suppresses only the node stderr. The
|
||||
* caller passes an argument string that is already shell-encoded.
|
||||
*/
|
||||
function composeNodeHelperCommand(encodedArgs: string): string {
|
||||
return `${LOGIN_PROFILE_PREAMBLE} && node -e ${encodedArgs} 2>/dev/null`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link DaytonaLoginHomeFs} bound to a Daytona `process`. It runs a
|
||||
* fixed node helper on the sandbox to inspect and create the session home. Each
|
||||
* helper opens the filesystem root, then walks each path component with a
|
||||
* no-follow, directory-only open, so a symlink at any ancestor fails closed. The
|
||||
* inspection reads the final component with `lstat`, so a symlink at the target
|
||||
* reports the link. The creation creates the login root if absent and the session
|
||||
* home as a NEW directory, both relative to an open trusted descriptor, so no
|
||||
* operation follows a composite pathname. The real filesystem operations land
|
||||
* against a live sandbox; a unit test injects a fake surface instead.
|
||||
*/
|
||||
export function createDaytonaLoginHomeFs(exec: DaytonaSandboxExec): DaytonaLoginHomeFs {
|
||||
return {
|
||||
async loginUserId(): Promise<number> {
|
||||
const out = await exec.executeCommand("id -u");
|
||||
const uid = Number.parseInt((out.result ?? "").trim(), 10);
|
||||
if (!Number.isInteger(uid)) {
|
||||
throw new Error(LOGIN_PTY_HOME_REJECTED);
|
||||
}
|
||||
return uid;
|
||||
},
|
||||
async inspect(path: string): Promise<LoginHomeInspection> {
|
||||
// Read the file type, the octal mode, and the owner uid with a descriptor
|
||||
// relative no-follow walk. A non-zero exit means a symlink or a non-directory
|
||||
// at an ancestor, so the read fails closed. An `ABSENT` line means the target
|
||||
// does not exist yet, which is the safe precondition for a create.
|
||||
const script = encodePosixShellArg(LOGIN_HOME_INSPECT_SCRIPT);
|
||||
const encodedPath = encodePosixShellArg(path);
|
||||
const out = await exec.executeCommand(composeNodeHelperCommand(`${script} ${encodedPath}`));
|
||||
if ((out.exitCode ?? 0) !== 0) {
|
||||
throw new Error(LOGIN_PTY_HOME_REJECTED);
|
||||
}
|
||||
const text = (out.result ?? "").trim();
|
||||
if (text.length === 0 || text === "ABSENT") {
|
||||
return { exists: false, isSymlink: false, isDirectory: false, mode: "", ownerUid: null };
|
||||
}
|
||||
const [fileType = "", mode = "", ownerText = ""] = text.split("|");
|
||||
const ownerUid = Number.parseInt(ownerText, 10);
|
||||
return {
|
||||
exists: true,
|
||||
isSymlink: fileType === "symlink",
|
||||
isDirectory: fileType === "directory",
|
||||
mode,
|
||||
ownerUid: Number.isInteger(ownerUid) ? ownerUid : null,
|
||||
};
|
||||
},
|
||||
async createDirectory(path: string, mode: string): Promise<void> {
|
||||
const script = encodePosixShellArg(LOGIN_HOME_CREATE_SCRIPT);
|
||||
const encodedPath = encodePosixShellArg(path);
|
||||
const encodedMode = encodePosixShellArg(mode);
|
||||
const out = await exec.executeCommand(
|
||||
composeNodeHelperCommand(`${script} ${encodedPath} ${encodedMode}`),
|
||||
);
|
||||
if ((out.exitCode ?? 0) !== 0) {
|
||||
throw new Error(LOGIN_PTY_HOME_REJECTED);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link LoginPtySessionOpener} bound to a Daytona `process` and a
|
||||
* {@link DaytonaLoginHomeFs}. The opener runs the login command on a real
|
||||
* pseudo-terminal, streams the terminal output, delivers the delayed browser code
|
||||
* plus the Enter byte, and stops the child for a terminal state.
|
||||
*/
|
||||
export function createDaytonaLoginPtySessionOpener(
|
||||
process: DaytonaPtyProcess,
|
||||
fs: DaytonaLoginHomeFs,
|
||||
options?: DaytonaLoginPtyOptions,
|
||||
): LoginPtySessionOpener {
|
||||
return (descriptor: LoginPtyLaunchDescriptor) =>
|
||||
openDaytonaLoginPtySession(process, fs, descriptor, options);
|
||||
}
|
||||
|
|
@ -67,7 +67,11 @@ const manifest: PaperclipPluginManifestV1 = {
|
|||
supportsTemplateDelete: true,
|
||||
// Daytona hosts an interactive login on a real pseudo-terminal. It is the
|
||||
// only bundled provider that implements the login pseudo-terminal methods,
|
||||
// so it advertises the capability.
|
||||
// so it advertises the capability. The session home helpers and the
|
||||
// credential reader run on node, and the sandbox already runs node for the
|
||||
// Paperclip bridge. So the login has no extra runtime prerequisite, and the
|
||||
// advertised capability matches the runtime contract for every configured
|
||||
// image or snapshot.
|
||||
supportsLoginPty: true,
|
||||
configSchema: {
|
||||
type: "object",
|
||||
|
|
|
|||
|
|
@ -46,25 +46,32 @@ import { performSyncIn, performSyncOut, withProviderSpan } from "./file-sync.js"
|
|||
// The session runs the login command on a real pseudo-terminal, streams the
|
||||
// terminal output, and delivers the delayed browser code plus the Enter byte. A
|
||||
// later phase binds the opener to `sandbox.process` and wraps it with the
|
||||
// `createSetupTokenPtyTransport` factory from `@paperclipai/adapter-utils` to
|
||||
// `createLoginPtyTransport` factory from `@paperclipai/adapter-utils` to
|
||||
// build the transport the login runner drives.
|
||||
export {
|
||||
createDaytonaSetupTokenPtySessionOpener,
|
||||
openDaytonaSetupTokenPtySession,
|
||||
} from "./setup-token-pty.js";
|
||||
createDaytonaLoginPtySessionOpener,
|
||||
openDaytonaLoginPtySession,
|
||||
createDaytonaLoginHomeFs,
|
||||
} from "./login-pty.js";
|
||||
export type {
|
||||
SetupTokenPtySession,
|
||||
SetupTokenPtySessionOpener,
|
||||
LoginPtySession,
|
||||
LoginPtySessionOpener,
|
||||
LoginPtyLaunchDescriptor,
|
||||
DaytonaPtyHandle,
|
||||
DaytonaPtyProcess,
|
||||
DaytonaPtyCreateOptions,
|
||||
DaytonaSetupTokenPtyOptions,
|
||||
} from "./setup-token-pty.js";
|
||||
import { openDaytonaSetupTokenPtySession as openSetupTokenPtySession } from "./setup-token-pty.js";
|
||||
DaytonaLoginPtyOptions,
|
||||
DaytonaLoginHomeFs,
|
||||
} from "./login-pty.js";
|
||||
import {
|
||||
openDaytonaLoginPtySession as openLoginPtySession,
|
||||
createDaytonaLoginHomeFs,
|
||||
} from "./login-pty.js";
|
||||
import type {
|
||||
SetupTokenPtySession as SetupTokenPtyWorkerSession,
|
||||
LoginPtySession as LoginPtyWorkerSession,
|
||||
DaytonaPtyProcess,
|
||||
} from "./setup-token-pty.js";
|
||||
DaytonaSandboxExec,
|
||||
} from "./login-pty.js";
|
||||
|
||||
// The Daytona duplex command stream for the sandbox callback bridge. The channel
|
||||
// runs the gateway command on a raw pseudo-terminal, streams the frames, and
|
||||
|
|
@ -1904,17 +1911,17 @@ async function executeInSession(
|
|||
// create time, so the host closes the exact terminal by that identifier even
|
||||
// when the open reply was lost. It also indexes by the worker session identifier
|
||||
// for input and stop. The `onShutdown` hook closes every open session here.
|
||||
interface DaytonaSetupTokenPtyEntry {
|
||||
interface DaytonaLoginPtyEntry {
|
||||
hostRouteId: string;
|
||||
workerSessionId: string;
|
||||
session: SetupTokenPtyWorkerSession;
|
||||
session: LoginPtyWorkerSession;
|
||||
}
|
||||
const daytonaSetupTokenPtyByRoute = new Map<string, DaytonaSetupTokenPtyEntry>();
|
||||
const daytonaSetupTokenPtyBySession = new Map<string, DaytonaSetupTokenPtyEntry>();
|
||||
const daytonaLoginPtyByRoute = new Map<string, DaytonaLoginPtyEntry>();
|
||||
const daytonaLoginPtyBySession = new Map<string, DaytonaLoginPtyEntry>();
|
||||
|
||||
function forgetDaytonaSetupTokenPty(entry: DaytonaSetupTokenPtyEntry): void {
|
||||
daytonaSetupTokenPtyByRoute.delete(entry.hostRouteId);
|
||||
daytonaSetupTokenPtyBySession.delete(entry.workerSessionId);
|
||||
function forgetDaytonaLoginPty(entry: DaytonaLoginPtyEntry): void {
|
||||
daytonaLoginPtyByRoute.delete(entry.hostRouteId);
|
||||
daytonaLoginPtyBySession.delete(entry.workerSessionId);
|
||||
}
|
||||
|
||||
// The worker-side registry of live duplex channels. The worker registers each
|
||||
|
|
@ -2731,55 +2738,60 @@ const plugin = definePlugin({
|
|||
});
|
||||
},
|
||||
|
||||
// Open one live Claude `setup-token` login pseudo-terminal. Resolve
|
||||
// the cached sandbox by the provider lease id, run the fixed login command on a
|
||||
// Open one live login pseudo-terminal. Resolve the cached sandbox by the
|
||||
// provider lease id, revalidate the host launch descriptor and the session
|
||||
// home, create and validate the session home, run the fixed login command on a
|
||||
// real pseudo-terminal, and register the session under the host route id. Stream
|
||||
// the raw output and the exit through `ctx.setupTokenPty`, bound to the returned
|
||||
// the raw output and the exit through `ctx.loginPty`, bound to the returned
|
||||
// worker session id. Fail closed when no cached sandbox matches the lease.
|
||||
async onSetupTokenPtyOpen(params) {
|
||||
async onLoginPtyOpen(params) {
|
||||
const sandbox = await sandboxHandleCache.findByProviderLeaseId(params.providerLeaseId);
|
||||
if (!sandbox) {
|
||||
throw new Error(
|
||||
"Daytona setup-token login: no cached sandbox resolves the provider lease.",
|
||||
"Daytona login pseudo-terminal: no cached sandbox resolves the provider lease.",
|
||||
);
|
||||
}
|
||||
const session = await openSetupTokenPtySession(
|
||||
const homeFs = createDaytonaLoginHomeFs(
|
||||
sandbox.process as unknown as DaytonaSandboxExec,
|
||||
);
|
||||
const session = await openLoginPtySession(
|
||||
sandbox.process as unknown as DaytonaPtyProcess,
|
||||
params.command,
|
||||
homeFs,
|
||||
{ loginCommandKey: params.loginCommandKey, sessionHome: params.sessionHome },
|
||||
);
|
||||
const workerSessionId = `pty-${randomUUID()}`;
|
||||
const entry: DaytonaSetupTokenPtyEntry = {
|
||||
const entry: DaytonaLoginPtyEntry = {
|
||||
hostRouteId: params.hostRouteId,
|
||||
workerSessionId,
|
||||
session,
|
||||
};
|
||||
daytonaSetupTokenPtyByRoute.set(params.hostRouteId, entry);
|
||||
daytonaSetupTokenPtyBySession.set(workerSessionId, entry);
|
||||
daytonaLoginPtyByRoute.set(params.hostRouteId, entry);
|
||||
daytonaLoginPtyBySession.set(workerSessionId, entry);
|
||||
// Register the output listener before the first input, so no early output
|
||||
// chunk is lost. The client stamps the worker session id, so the host binds
|
||||
// the output to the open route.
|
||||
session.onData((chunk) => {
|
||||
pluginContext?.setupTokenPty.output(workerSessionId, chunk);
|
||||
pluginContext?.loginPty.output(workerSessionId, chunk);
|
||||
});
|
||||
// Forward the child exit one time. The host resolves the login run on it.
|
||||
void session.wait().then(
|
||||
(result) => pluginContext?.setupTokenPty.exit(workerSessionId, result.exitCode),
|
||||
() => pluginContext?.setupTokenPty.exit(workerSessionId, null),
|
||||
(result) => pluginContext?.loginPty.exit(workerSessionId, result.exitCode),
|
||||
() => pluginContext?.loginPty.exit(workerSessionId, null),
|
||||
);
|
||||
return { workerSessionId };
|
||||
},
|
||||
|
||||
// Write delayed input to an open login pseudo-terminal, keyed by the worker
|
||||
// session id. Drop the input for an unknown session.
|
||||
async onSetupTokenPtyInput(params) {
|
||||
const entry = daytonaSetupTokenPtyBySession.get(params.workerSessionId);
|
||||
async onLoginPtyInput(params) {
|
||||
const entry = daytonaLoginPtyBySession.get(params.workerSessionId);
|
||||
if (!entry) return;
|
||||
entry.session.write(params.data);
|
||||
},
|
||||
|
||||
// Stop an open login pseudo-terminal child, keyed by the worker session id.
|
||||
async onSetupTokenPtyStop(params) {
|
||||
const entry = daytonaSetupTokenPtyBySession.get(params.workerSessionId);
|
||||
async onLoginPtyStop(params) {
|
||||
const entry = daytonaLoginPtyBySession.get(params.workerSessionId);
|
||||
if (!entry) return;
|
||||
entry.session.kill();
|
||||
},
|
||||
|
|
@ -2788,10 +2800,10 @@ const plugin = definePlugin({
|
|||
// close with the same identifier. The close is idempotent: it returns the
|
||||
// acknowledgement even when the entry is already gone, so the host confirms the
|
||||
// terminal is closed. The worker never keys the close on the worker session id.
|
||||
async onSetupTokenPtyClose(params) {
|
||||
const entry = daytonaSetupTokenPtyByRoute.get(params.hostRouteId);
|
||||
async onLoginPtyClose(params) {
|
||||
const entry = daytonaLoginPtyByRoute.get(params.hostRouteId);
|
||||
if (entry) {
|
||||
forgetDaytonaSetupTokenPty(entry);
|
||||
forgetDaytonaLoginPty(entry);
|
||||
await entry.session.close().catch(() => undefined);
|
||||
}
|
||||
return { hostRouteId: params.hostRouteId };
|
||||
|
|
@ -2884,9 +2896,9 @@ const plugin = definePlugin({
|
|||
// orderly shutdown, then drain the sandbox handle cache, so a graceful shutdown
|
||||
// holds no live terminal and no live channel.
|
||||
async onShutdown() {
|
||||
const openSessions = [...daytonaSetupTokenPtyByRoute.values()];
|
||||
daytonaSetupTokenPtyByRoute.clear();
|
||||
daytonaSetupTokenPtyBySession.clear();
|
||||
const openSessions = [...daytonaLoginPtyByRoute.values()];
|
||||
daytonaLoginPtyByRoute.clear();
|
||||
daytonaLoginPtyBySession.clear();
|
||||
for (const entry of openSessions) {
|
||||
await entry.session.close().catch(() => undefined);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
// The session-home creation helper. The provider runs it in the sandbox as
|
||||
// `node -e <this file> <path> <octal-mode>`. The sandbox already runs node for the
|
||||
// Paperclip bridge, so the helper needs no extra runtime.
|
||||
//
|
||||
// The helper opens the filesystem root, then walks each ancestor above the login
|
||||
// root with a no-follow, directory-only open. Node has no `openat`, so the helper
|
||||
// opens each next component through `/proc/self/fd/<dfd>/<component>`: the kernel
|
||||
// resolves the magic descriptor link, then applies `O_NOFOLLOW` to the final
|
||||
// component. It creates the login root (the second-to-last component) if the root
|
||||
// is absent, then opens the root with a no-follow open, so a symlink at the root
|
||||
// fails closed. It creates the final component as a NEW directory relative to the
|
||||
// root descriptor, so a pre-existing target fails and the create never follows a
|
||||
// symlink. It exits 0 on success and non-zero on every failure.
|
||||
//
|
||||
// The no-follow open of the root after the create closes the swap window: an
|
||||
// attacker that replaces the root with a symlink between the create and the open
|
||||
// fails the open.
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
|
||||
const path = process.argv[1];
|
||||
const mode = Number.parseInt(process.argv[2], 8);
|
||||
if (typeof path !== "string" || !path.startsWith("/")) process.exit(1);
|
||||
if (!Number.isInteger(mode)) process.exit(1);
|
||||
const parts = path.split("/").filter((component) => component.length > 0);
|
||||
if (parts.length < 2) process.exit(1);
|
||||
|
||||
let dfd;
|
||||
try {
|
||||
dfd = fs.openSync("/", fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
for (const part of parts.slice(0, -2)) {
|
||||
try {
|
||||
dfd = fs.openSync(
|
||||
`/proc/self/fd/${dfd}/${part}`,
|
||||
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const root = parts[parts.length - 2];
|
||||
try {
|
||||
fs.mkdirSync(`/proc/self/fd/${dfd}/${root}`, { mode: 0o700 });
|
||||
} catch (error) {
|
||||
if (!error || error.code !== "EEXIST") process.exit(1);
|
||||
}
|
||||
|
||||
let rfd;
|
||||
try {
|
||||
rfd = fs.openSync(
|
||||
`/proc/self/fd/${dfd}/${root}`,
|
||||
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(`/proc/self/fd/${rfd}/${parts[parts.length - 1]}`, { mode });
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
// The session-home inspection helper. The provider runs it in the sandbox as
|
||||
// `node -e <this file> <path>`. The sandbox already runs node for the Paperclip
|
||||
// bridge, so the helper needs no extra runtime.
|
||||
//
|
||||
// The helper opens the filesystem root, then walks each ancestor of the final
|
||||
// path component with a no-follow, directory-only open. Node has no `openat`, so
|
||||
// the helper opens each next component through `/proc/self/fd/<dfd>/<component>`:
|
||||
// the kernel resolves the magic descriptor link, then applies `O_NOFOLLOW` to the
|
||||
// final component. This binds each open to the descriptor inode, so a symlink at
|
||||
// any ancestor fails closed. The helper reads the final component with `lstat`, so
|
||||
// a symlink at the target reports the link, not the target.
|
||||
//
|
||||
// The helper prints one line and sets one exit code:
|
||||
// - a missing ancestor or a missing final component prints `ABSENT` and exits 0;
|
||||
// - an existing final component prints `<type>|<octal-mode>|<uid>` and exits 0,
|
||||
// where `<type>` is `symlink`, `directory`, or `other`;
|
||||
// - a symlink or a non-directory at any ancestor prints nothing and exits 3, so
|
||||
// the caller fails closed on an ancestor symlink.
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
|
||||
const path = process.argv[1];
|
||||
if (typeof path !== "string" || !path.startsWith("/")) process.exit(3);
|
||||
const parts = path.split("/").filter((component) => component.length > 0);
|
||||
if (parts.length === 0) process.exit(3);
|
||||
|
||||
function absent() {
|
||||
process.stdout.write("ABSENT");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let dfd;
|
||||
try {
|
||||
dfd = fs.openSync("/", fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
|
||||
} catch {
|
||||
process.exit(3);
|
||||
}
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
try {
|
||||
dfd = fs.openSync(
|
||||
`/proc/self/fd/${dfd}/${part}`,
|
||||
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error && error.code === "ENOENT") absent();
|
||||
process.exit(3);
|
||||
}
|
||||
}
|
||||
|
||||
const last = parts[parts.length - 1];
|
||||
let st;
|
||||
try {
|
||||
st = fs.lstatSync(`/proc/self/fd/${dfd}/${last}`);
|
||||
} catch (error) {
|
||||
if (error && error.code === "ENOENT") absent();
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
let fileType;
|
||||
if (st.isSymbolicLink()) fileType = "symlink";
|
||||
else if (st.isDirectory()) fileType = "directory";
|
||||
else fileType = "other";
|
||||
process.stdout.write(`${fileType}|${(st.mode & 0o7777).toString(8)}|${st.uid}`);
|
||||
process.exit(0);
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDaytonaSetupTokenPtySessionOpener,
|
||||
openDaytonaSetupTokenPtySession,
|
||||
type DaytonaPtyCreateOptions,
|
||||
type DaytonaPtyHandle,
|
||||
type DaytonaPtyProcess,
|
||||
} from "./setup-token-pty.js";
|
||||
|
||||
// The Enter byte the terminal login UI reads to submit the browser code.
|
||||
const ENTER = "\r";
|
||||
|
||||
/**
|
||||
* 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<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<void> {},
|
||||
async sendInput(data: string | Uint8Array): Promise<void> {
|
||||
inputs.push(typeof data === "string" ? data : new TextDecoder().decode(data));
|
||||
},
|
||||
wait(): Promise<{ exitCode?: number; error?: string }> {
|
||||
return waitPromise;
|
||||
},
|
||||
async kill(): Promise<void> {
|
||||
killed += 1;
|
||||
},
|
||||
async disconnect(): Promise<void> {
|
||||
disconnected += 1;
|
||||
},
|
||||
// Test control below. The session never reads these fields.
|
||||
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 command start.
|
||||
*/
|
||||
function createFakeProcess(): DaytonaPtyProcess & {
|
||||
handle: ReturnType<typeof createFakePtyHandle> | null;
|
||||
createOptions: DaytonaPtyCreateOptions | null;
|
||||
} {
|
||||
const state: {
|
||||
handle: ReturnType<typeof createFakePtyHandle> | null;
|
||||
createOptions: DaytonaPtyCreateOptions | null;
|
||||
} = { handle: null, createOptions: null };
|
||||
return {
|
||||
get handle() {
|
||||
return state.handle;
|
||||
},
|
||||
get createOptions() {
|
||||
return state.createOptions;
|
||||
},
|
||||
async createPty(options: DaytonaPtyCreateOptions): Promise<DaytonaPtyHandle> {
|
||||
state.createOptions = options;
|
||||
const handle = createFakePtyHandle(options.onData);
|
||||
state.handle = handle;
|
||||
return handle;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("openDaytonaSetupTokenPtySession", () => {
|
||||
it("starts the login command on a pseudo-terminal with a fixed size", async () => {
|
||||
const process = createFakeProcess();
|
||||
|
||||
await openDaytonaSetupTokenPtySession(process, "claude setup-token");
|
||||
|
||||
expect(process.createOptions?.cols).toBe(120);
|
||||
expect(process.createOptions?.rows).toBe(30);
|
||||
// The session replaces the shell with the command through `exec`, so the
|
||||
// pseudo-terminal runs the command directly and its exit code is the PTY
|
||||
// exit code.
|
||||
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 openDaytonaSetupTokenPtySession(process, "claude setup-token");
|
||||
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 openDaytonaSetupTokenPtySession(process, "claude setup-token");
|
||||
// Output arrives before the transport registers the listener.
|
||||
process.handle?.emitText("early output ");
|
||||
process.handle?.emitText("more output");
|
||||
expect(received).toEqual([]);
|
||||
|
||||
session.onData((chunk) => received.push(chunk));
|
||||
// The session flushes the buffered output in order on registration.
|
||||
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 openDaytonaSetupTokenPtySession(process, "claude setup-token");
|
||||
session.write("BROWSERCODE" + ENTER);
|
||||
|
||||
// The first input is the command start; the second input is the delayed
|
||||
// browser code plus the Enter byte.
|
||||
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 openDaytonaSetupTokenPtySession(process, "claude setup-token");
|
||||
session.onData((chunk) => received.push(chunk));
|
||||
|
||||
// The euro sign is three UTF-8 bytes. Split it across two chunks, so the
|
||||
// stream decoder must join the bytes.
|
||||
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 openDaytonaSetupTokenPtySession(process, "claude setup-token");
|
||||
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 openDaytonaSetupTokenPtySession(process, "claude setup-token");
|
||||
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 openDaytonaSetupTokenPtySession(process, "claude setup-token");
|
||||
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 openDaytonaSetupTokenPtySession(process, "claude setup-token", { cwd: "/workspace" });
|
||||
|
||||
expect(process.createOptions?.cwd).toBe("/workspace");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDaytonaSetupTokenPtySessionOpener", () => {
|
||||
it("opens a session for the command on each call", async () => {
|
||||
const process = createFakeProcess();
|
||||
const opener = createDaytonaSetupTokenPtySessionOpener(process, { cwd: "/workspace" });
|
||||
|
||||
const session = await opener("claude setup-token");
|
||||
|
||||
expect(process.createOptions?.cwd).toBe("/workspace");
|
||||
expect(process.handle?.inputs[0]).toBe("exec claude setup-token" + ENTER);
|
||||
// The opener returns a session the transport can drive.
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
// The Daytona pseudo-terminal (PTY) session for the Claude `setup-token` login.
|
||||
// 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 setup-token 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.
|
||||
//
|
||||
// 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 SetupTokenPtySession}. The shape matches the `SetupTokenPtySession`
|
||||
// interface in `@paperclipai/adapter-utils`, so the transport factory
|
||||
// `createSetupTokenPtyTransport` 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 surface that it uses through
|
||||
// {@link DaytonaPtyProcess} and {@link DaytonaPtyHandle}. The SDK `Process` and
|
||||
// `PtyHandle` satisfy that subset, so a caller passes `sandbox.process` with no
|
||||
// adapter. The narrow surface keeps the module unit-testable with a fake PTY.
|
||||
//
|
||||
// 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 setup-token parser owns that handling.
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { sendPtyInputInChunks } from "./pty-chunked-input.js";
|
||||
|
||||
/**
|
||||
* A live pseudo-terminal session for one setup-token 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
|
||||
* `SetupTokenPtySession` interface in `@paperclipai/adapter-utils`, so the
|
||||
* transport factory there accepts a session opener that returns this session.
|
||||
*/
|
||||
export interface SetupTokenPtySession {
|
||||
/** 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<void>;
|
||||
}
|
||||
|
||||
/** Opens a {@link SetupTokenPtySession} for `command`. The transport calls it one time. */
|
||||
export type SetupTokenPtySessionOpener = (command: string) => Promise<SetupTokenPtySession>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
/** Sends raw input bytes to the pseudo-terminal. */
|
||||
sendInput(data: string | Uint8Array): Promise<void>;
|
||||
/** Resolves with the exit result when the pseudo-terminal process ends. */
|
||||
wait(): Promise<{ exitCode?: number; error?: string }>;
|
||||
/** Kills the pseudo-terminal process. */
|
||||
kill(): Promise<void>;
|
||||
/** Closes the PTY socket and releases the resources. */
|
||||
disconnect(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<DaytonaPtyHandle>;
|
||||
}
|
||||
|
||||
/** The options for the Daytona setup-token PTY session. */
|
||||
export interface DaytonaSetupTokenPtyOptions {
|
||||
/** 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 SETUP_TOKEN_PTY_COLS = 120;
|
||||
const SETUP_TOKEN_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";
|
||||
|
||||
/**
|
||||
* Opens a Daytona PTY session for `command` and returns it as a
|
||||
* {@link SetupTokenPtySession}. The session allocates a real pseudo-terminal,
|
||||
* streams the raw terminal output, delivers delayed input, and stops the child.
|
||||
*
|
||||
* The function starts the login command with `exec`, so the pseudo-terminal runs
|
||||
* the command directly and the command exit code becomes the PTY exit code. 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 openDaytonaSetupTokenPtySession(
|
||||
process: DaytonaPtyProcess,
|
||||
command: string,
|
||||
options?: DaytonaSetupTokenPtyOptions,
|
||||
): Promise<SetupTokenPtySession> {
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let listener: ((chunk: string) => void) | null = null;
|
||||
let buffered = "";
|
||||
|
||||
const handle = await process.createPty({
|
||||
id: `paperclip-setup-token-${randomUUID()}`,
|
||||
...(options?.cwd ? { cwd: options.cwd } : {}),
|
||||
cols: SETUP_TOKEN_PTY_COLS,
|
||||
rows: SETUP_TOKEN_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();
|
||||
// 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(`exec ${command}${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<void> = 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<void> {
|
||||
await handle.disconnect().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link SetupTokenPtySessionOpener} bound to a Daytona `process`. Pass
|
||||
* `sandbox.process` from the Daytona SDK. A later phase wraps the opener with the
|
||||
* `createSetupTokenPtyTransport` factory from `@paperclipai/adapter-utils` and
|
||||
* hands the transport to the login runner. The opener runs the login command on
|
||||
* a real pseudo-terminal, streams the terminal output, delivers the delayed
|
||||
* browser code plus the Enter byte, and stops the child for a terminal state.
|
||||
*/
|
||||
export function createDaytonaSetupTokenPtySessionOpener(
|
||||
process: DaytonaPtyProcess,
|
||||
options?: DaytonaSetupTokenPtyOptions,
|
||||
): SetupTokenPtySessionOpener {
|
||||
return (command: string) => openDaytonaSetupTokenPtySession(process, command, options);
|
||||
}
|
||||
|
|
@ -82,12 +82,12 @@ import type {
|
|||
PluginExternalObjectResolveResult,
|
||||
RefreshExternalObjectsParams,
|
||||
RefreshExternalObjectsResult,
|
||||
PluginSetupTokenPtyOpenParams,
|
||||
PluginSetupTokenPtyOpenResult,
|
||||
PluginSetupTokenPtyInputParams,
|
||||
PluginSetupTokenPtyStopParams,
|
||||
PluginSetupTokenPtyCloseParams,
|
||||
PluginSetupTokenPtyCloseResult,
|
||||
PluginLoginPtyOpenParams,
|
||||
PluginLoginPtyOpenResult,
|
||||
PluginLoginPtyInputParams,
|
||||
PluginLoginPtyStopParams,
|
||||
PluginLoginPtyCloseParams,
|
||||
PluginLoginPtyCloseResult,
|
||||
PluginDuplexChannelOpenParams,
|
||||
PluginDuplexChannelOpenResult,
|
||||
PluginDuplexChannelWriteParams,
|
||||
|
|
@ -449,27 +449,27 @@ export interface PluginDefinition {
|
|||
* Called to open one live Claude `setup-token` login pseudo-terminal.
|
||||
* The worker registers the terminal under the host route identifier and returns a
|
||||
* worker session identifier for the output notification binding only. The worker
|
||||
* streams output and the exit through `ctx.setupTokenPty`, never as a reply.
|
||||
* Defining the four `onSetupTokenPty*` hooks advertises the four methods.
|
||||
* streams output and the exit through `ctx.loginPty`, never as a reply.
|
||||
* Defining the four `onLoginPty*` hooks advertises the four methods.
|
||||
*/
|
||||
onSetupTokenPtyOpen?(
|
||||
params: PluginSetupTokenPtyOpenParams,
|
||||
): Promise<PluginSetupTokenPtyOpenResult>;
|
||||
onLoginPtyOpen?(
|
||||
params: PluginLoginPtyOpenParams,
|
||||
): Promise<PluginLoginPtyOpenResult>;
|
||||
|
||||
/** Called to write delayed input to an open login pseudo-terminal, keyed by the worker session identifier. */
|
||||
onSetupTokenPtyInput?(params: PluginSetupTokenPtyInputParams): Promise<void>;
|
||||
onLoginPtyInput?(params: PluginLoginPtyInputParams): Promise<void>;
|
||||
|
||||
/** Called to stop an open login pseudo-terminal child, keyed by the worker session identifier. */
|
||||
onSetupTokenPtyStop?(params: PluginSetupTokenPtyStopParams): Promise<void>;
|
||||
onLoginPtyStop?(params: PluginLoginPtyStopParams): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called to close an open login pseudo-terminal by the host route identifier. The
|
||||
* worker closes the exact terminal registered under that identifier and returns a
|
||||
* close acknowledgement that carries the same identifier.
|
||||
*/
|
||||
onSetupTokenPtyClose?(
|
||||
params: PluginSetupTokenPtyCloseParams,
|
||||
): Promise<PluginSetupTokenPtyCloseResult>;
|
||||
onLoginPtyClose?(
|
||||
params: PluginLoginPtyCloseParams,
|
||||
): Promise<PluginLoginPtyCloseResult>;
|
||||
|
||||
/**
|
||||
* Called to open one persistent duplex channel. The worker registers the
|
||||
|
|
|
|||
|
|
@ -82,8 +82,8 @@ export {
|
|||
parseMessage,
|
||||
JsonRpcParseError,
|
||||
JsonRpcCallError,
|
||||
SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION,
|
||||
SETUP_TOKEN_PTY_EXIT_NOTIFICATION,
|
||||
LOGIN_PTY_OUTPUT_NOTIFICATION,
|
||||
LOGIN_PTY_EXIT_NOTIFICATION,
|
||||
DUPLEX_CHANNEL_DATA_NOTIFICATION,
|
||||
DUPLEX_CHANNEL_EXIT_NOTIFICATION,
|
||||
_resetIdCounter,
|
||||
|
|
|
|||
|
|
@ -972,7 +972,7 @@ export interface PluginRenderCloseEvent {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup-token login pseudo-terminal (PTY) worker methods.
|
||||
// Login pseudo-terminal (PTY) worker methods.
|
||||
// ---------------------------------------------------------------------------
|
||||
// The host drives one live Claude `setup-token` login pseudo-terminal inside a
|
||||
// sandbox provider worker. The host owns the route. It mints an opaque host
|
||||
|
|
@ -985,11 +985,19 @@ export interface PluginRenderCloseEvent {
|
|||
// output and exit as notifications, never as a reply, so the host binds them by
|
||||
// the worker session identifier while the route is open.
|
||||
|
||||
/**
|
||||
* The closed set of login command identities. The host resolves the key from the
|
||||
* trusted adapter type and carries it in the open request. The worker maps the
|
||||
* key to a compile-time command. The open request carries no command string, so a
|
||||
* caller cannot select or override the command.
|
||||
*/
|
||||
export type PluginLoginCommandKey = "claude" | "codex";
|
||||
|
||||
/** The open request for one live login pseudo-terminal. The worker registers the terminal by `hostRouteId`. */
|
||||
export interface PluginSetupTokenPtyOpenParams {
|
||||
export interface PluginLoginPtyOpenParams {
|
||||
/** The host-owned opaque route identifier. The worker registers the terminal by it. */
|
||||
hostRouteId: string;
|
||||
/** The environment driver key, for the worker sandbox scope. */
|
||||
/** The environment driver key, for the worker sandbox scope. It routes the worker; it confers no command authority. */
|
||||
driverKey: string;
|
||||
/** The company that owns the login session. */
|
||||
companyId: string;
|
||||
|
|
@ -997,18 +1005,27 @@ export interface PluginSetupTokenPtyOpenParams {
|
|||
environmentId: string;
|
||||
/** The provider lease the sandbox is cached under. The worker resolves the sandbox by it. */
|
||||
providerLeaseId: string;
|
||||
/** The fixed login command. The worker runs only this command on the terminal. */
|
||||
command: string;
|
||||
/**
|
||||
* The host-resolved fixed command identity. The worker maps it to a
|
||||
* compile-time command. The open request carries no command string.
|
||||
*/
|
||||
loginCommandKey: PluginLoginCommandKey;
|
||||
/**
|
||||
* The server-controlled, validated session home. The shape is exact:
|
||||
* `/tmp/paperclip-adapter-login/<uuid>`. The worker revalidates the shape
|
||||
* before it touches the filesystem.
|
||||
*/
|
||||
sessionHome: string;
|
||||
}
|
||||
|
||||
/** The open reply. It returns the worker session identifier for output binding only. */
|
||||
export interface PluginSetupTokenPtyOpenResult {
|
||||
export interface PluginLoginPtyOpenResult {
|
||||
/** The worker session identifier. It binds the output and the exit notification only. */
|
||||
workerSessionId: string;
|
||||
}
|
||||
|
||||
/** The input request. It carries the worker session identifier and the raw input bytes. */
|
||||
export interface PluginSetupTokenPtyInputParams {
|
||||
export interface PluginLoginPtyInputParams {
|
||||
/** The worker session identifier that the open reply returned. */
|
||||
workerSessionId: string;
|
||||
/** The raw input bytes to write to the terminal. */
|
||||
|
|
@ -1016,13 +1033,13 @@ export interface PluginSetupTokenPtyInputParams {
|
|||
}
|
||||
|
||||
/** The stop request. It carries the worker session identifier. */
|
||||
export interface PluginSetupTokenPtyStopParams {
|
||||
export interface PluginLoginPtyStopParams {
|
||||
/** The worker session identifier that the open reply returned. */
|
||||
workerSessionId: string;
|
||||
}
|
||||
|
||||
/** The close request. The host route identifier is the authoritative key. */
|
||||
export interface PluginSetupTokenPtyCloseParams {
|
||||
export interface PluginLoginPtyCloseParams {
|
||||
/**
|
||||
* The host-owned opaque route identifier. This is the authoritative close key,
|
||||
* so the host closes the terminal even when no worker session identifier
|
||||
|
|
@ -1038,13 +1055,13 @@ export interface PluginSetupTokenPtyCloseParams {
|
|||
}
|
||||
|
||||
/** The close reply. It acknowledges the close and carries the same host route identifier. */
|
||||
export interface PluginSetupTokenPtyCloseResult {
|
||||
export interface PluginLoginPtyCloseResult {
|
||||
/** The close acknowledgement. It carries the same host route identifier the close sent. */
|
||||
hostRouteId: string;
|
||||
}
|
||||
|
||||
/** The worker→host pseudo-terminal output notification parameters. Modeled on `execute.log`. */
|
||||
export interface PluginSetupTokenPtyOutputParams {
|
||||
export interface PluginLoginPtyOutputParams {
|
||||
/** The worker session identifier that the open reply returned. */
|
||||
workerSessionId: string;
|
||||
/** The raw terminal output bytes. */
|
||||
|
|
@ -1052,7 +1069,7 @@ export interface PluginSetupTokenPtyOutputParams {
|
|||
}
|
||||
|
||||
/** The worker→host pseudo-terminal exit notification parameters. */
|
||||
export interface PluginSetupTokenPtyExitParams {
|
||||
export interface PluginLoginPtyExitParams {
|
||||
/** The worker session identifier that the open reply returned. */
|
||||
workerSessionId: string;
|
||||
/** The child exit code, or null when the child ended with no code. */
|
||||
|
|
@ -1061,10 +1078,10 @@ export interface PluginSetupTokenPtyExitParams {
|
|||
|
||||
/**
|
||||
* One live login pseudo-terminal session in the worker. The worker opener returns
|
||||
* it. The shape matches the sandbox provider setup-token pseudo-terminal session,
|
||||
* it. The shape matches the sandbox provider login pseudo-terminal session,
|
||||
* so a provider passes its session with no adapter.
|
||||
*/
|
||||
export interface PluginSetupTokenPtyWorkerSession {
|
||||
export interface PluginLoginPtyWorkerSession {
|
||||
/** 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. */
|
||||
|
|
@ -1078,9 +1095,9 @@ export interface PluginSetupTokenPtyWorkerSession {
|
|||
}
|
||||
|
||||
/** The worker→host notification method for one pseudo-terminal output chunk. */
|
||||
export const SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION = "setupTokenPty.output";
|
||||
export const LOGIN_PTY_OUTPUT_NOTIFICATION = "loginPty.output";
|
||||
/** The worker→host notification method for one pseudo-terminal exit. */
|
||||
export const SETUP_TOKEN_PTY_EXIT_NOTIFICATION = "setupTokenPty.exit";
|
||||
export const LOGIN_PTY_EXIT_NOTIFICATION = "loginPty.exit";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic duplex channel worker methods.
|
||||
|
|
@ -1088,7 +1105,7 @@ export const SETUP_TOKEN_PTY_EXIT_NOTIFICATION = "setupTokenPty.exit";
|
|||
// The host drives one persistent duplex channel inside a sandbox provider
|
||||
// worker. The channel replaces the file transport of the sandbox callback bridge
|
||||
// with one live bidirectional stream. These messages are generic. They model the
|
||||
// setup-token pseudo-terminal contract above, but they carry no login command
|
||||
// login pseudo-terminal contract above, but they carry no login command
|
||||
// allowlist. The host owns the route. It mints an opaque host route identifier,
|
||||
// carries that identifier in the open request, and keys the close on that
|
||||
// identifier. The worker registers the channel under the host route identifier
|
||||
|
|
@ -1309,18 +1326,18 @@ export interface HostToWorkerMethods {
|
|||
result: PluginEnvironmentDeleteTemplateResult,
|
||||
];
|
||||
/** Open one live login pseudo-terminal keyed by a host-owned route identifier. */
|
||||
setupTokenPtyOpen: [
|
||||
params: PluginSetupTokenPtyOpenParams,
|
||||
result: PluginSetupTokenPtyOpenResult,
|
||||
loginPtyOpen: [
|
||||
params: PluginLoginPtyOpenParams,
|
||||
result: PluginLoginPtyOpenResult,
|
||||
];
|
||||
/** Write delayed input to a live login pseudo-terminal, keyed by the worker session identifier. */
|
||||
setupTokenPtyInput: [params: PluginSetupTokenPtyInputParams, result: void];
|
||||
loginPtyInput: [params: PluginLoginPtyInputParams, result: void];
|
||||
/** Stop a live login pseudo-terminal child, keyed by the worker session identifier. */
|
||||
setupTokenPtyStop: [params: PluginSetupTokenPtyStopParams, result: void];
|
||||
loginPtyStop: [params: PluginLoginPtyStopParams, result: void];
|
||||
/** Close a live login pseudo-terminal by the host route identifier and return a bound acknowledgement. */
|
||||
setupTokenPtyClose: [
|
||||
params: PluginSetupTokenPtyCloseParams,
|
||||
result: PluginSetupTokenPtyCloseResult,
|
||||
loginPtyClose: [
|
||||
params: PluginLoginPtyCloseParams,
|
||||
result: PluginLoginPtyCloseResult,
|
||||
];
|
||||
/** Open one persistent duplex channel keyed by a host-owned route identifier. */
|
||||
duplexChannelOpen: [
|
||||
|
|
@ -1377,10 +1394,10 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[]
|
|||
"environmentCaptureTemplate",
|
||||
"environmentCancelInteractiveSetup",
|
||||
"environmentDeleteTemplate",
|
||||
"setupTokenPtyOpen",
|
||||
"setupTokenPtyInput",
|
||||
"setupTokenPtyStop",
|
||||
"setupTokenPtyClose",
|
||||
"loginPtyOpen",
|
||||
"loginPtyInput",
|
||||
"loginPtyStop",
|
||||
"loginPtyClose",
|
||||
"duplexChannelOpen",
|
||||
"duplexChannelWrite",
|
||||
"duplexChannelStop",
|
||||
|
|
|
|||
|
|
@ -2478,7 +2478,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||
// No-op in test harness — the host runner log sink is not wired here.
|
||||
},
|
||||
},
|
||||
setupTokenPty: {
|
||||
loginPty: {
|
||||
output(_workerSessionId: string, _chunk: string) {
|
||||
// No-op in test harness — the host login route is not wired here.
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2009,7 +2009,7 @@ export interface PluginExecutionClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* `ctx.setupTokenPty` — stream one live login pseudo-terminal's output and exit
|
||||
* `ctx.loginPty` — stream one live login pseudo-terminal's output and exit
|
||||
* from a sandbox provider worker to the host.
|
||||
*
|
||||
* The worker opener registers the output listener on the session and forwards
|
||||
|
|
@ -2020,7 +2020,7 @@ export interface PluginExecutionClient {
|
|||
* chunk or an exit that carries an unknown or a mismatched identifier, and it
|
||||
* never logs the raw bytes. The default is a no-op that never throws.
|
||||
*/
|
||||
export interface PluginSetupTokenPtyClient {
|
||||
export interface PluginLoginPtyClient {
|
||||
/**
|
||||
* Deliver one raw output chunk of a live login pseudo-terminal.
|
||||
*
|
||||
|
|
@ -2048,7 +2048,7 @@ export interface PluginSetupTokenPtyClient {
|
|||
* by that identifier while the route is open. The host drops a chunk or an exit
|
||||
* that carries an unknown or a mismatched identifier, and it never logs the raw
|
||||
* bytes. The default is a no-op that never throws. This client models the
|
||||
* `setupTokenPty` client, but it carries no login command allowlist.
|
||||
* `loginPty` client, but it carries no login command allowlist.
|
||||
*/
|
||||
export interface PluginDuplexChannelClient {
|
||||
/**
|
||||
|
|
@ -2193,7 +2193,7 @@ export interface PluginContext {
|
|||
/** Stream one live login pseudo-terminal's output and exit to the host.
|
||||
* The default is a no-op for a provider that opens no login
|
||||
* pseudo-terminal. */
|
||||
setupTokenPty: PluginSetupTokenPtyClient;
|
||||
loginPty: PluginLoginPtyClient;
|
||||
|
||||
/** Stream one persistent duplex channel's data and exit to the host. The
|
||||
* default is a no-op for a provider that opens no duplex channel. */
|
||||
|
|
|
|||
|
|
@ -100,10 +100,10 @@ import type {
|
|||
PluginEnvironmentCaptureTemplateParams,
|
||||
PluginEnvironmentCancelInteractiveSetupParams,
|
||||
PluginEnvironmentDeleteTemplateParams,
|
||||
PluginSetupTokenPtyOpenParams,
|
||||
PluginSetupTokenPtyInputParams,
|
||||
PluginSetupTokenPtyStopParams,
|
||||
PluginSetupTokenPtyCloseParams,
|
||||
PluginLoginPtyOpenParams,
|
||||
PluginLoginPtyInputParams,
|
||||
PluginLoginPtyStopParams,
|
||||
PluginLoginPtyCloseParams,
|
||||
PluginDuplexChannelOpenParams,
|
||||
PluginDuplexChannelWriteParams,
|
||||
PluginDuplexChannelStopParams,
|
||||
|
|
@ -113,8 +113,8 @@ import type {
|
|||
WorkerToHostMethods,
|
||||
} from "./protocol.js";
|
||||
import {
|
||||
SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION,
|
||||
SETUP_TOKEN_PTY_EXIT_NOTIFICATION,
|
||||
LOGIN_PTY_OUTPUT_NOTIFICATION,
|
||||
LOGIN_PTY_EXIT_NOTIFICATION,
|
||||
DUPLEX_CHANNEL_DATA_NOTIFICATION,
|
||||
DUPLEX_CHANNEL_EXIT_NOTIFICATION,
|
||||
JSONRPC_VERSION,
|
||||
|
|
@ -1378,7 +1378,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||
},
|
||||
},
|
||||
|
||||
setupTokenPty: {
|
||||
loginPty: {
|
||||
output(workerSessionId: string, chunk: string): void {
|
||||
// Forward one raw output chunk of a live login pseudo-terminal. The
|
||||
// notification carries the worker session identifier, so the host binds
|
||||
|
|
@ -1388,14 +1388,14 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||
// because it fires after the open reply returns.
|
||||
if (typeof workerSessionId !== "string" || workerSessionId.length === 0) return;
|
||||
if (typeof chunk !== "string" || chunk.length === 0) return;
|
||||
notifyHost(SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION, { workerSessionId, chunk });
|
||||
notifyHost(LOGIN_PTY_OUTPUT_NOTIFICATION, { workerSessionId, chunk });
|
||||
},
|
||||
exit(workerSessionId: string, exitCode: number | null): void {
|
||||
// Forward the child exit of a live login pseudo-terminal. The host
|
||||
// resolves the open route's wait promise by the worker session
|
||||
// identifier while the route is open.
|
||||
if (typeof workerSessionId !== "string" || workerSessionId.length === 0) return;
|
||||
notifyHost(SETUP_TOKEN_PTY_EXIT_NOTIFICATION, {
|
||||
notifyHost(LOGIN_PTY_EXIT_NOTIFICATION, {
|
||||
workerSessionId,
|
||||
exitCode: typeof exitCode === "number" ? exitCode : null,
|
||||
});
|
||||
|
|
@ -1652,17 +1652,17 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||
case "environmentDeleteTemplate":
|
||||
return handleEnvironmentDeleteTemplate(params as PluginEnvironmentDeleteTemplateParams);
|
||||
|
||||
case "setupTokenPtyOpen":
|
||||
return handleSetupTokenPtyOpen(params as PluginSetupTokenPtyOpenParams);
|
||||
case "loginPtyOpen":
|
||||
return handleLoginPtyOpen(params as PluginLoginPtyOpenParams);
|
||||
|
||||
case "setupTokenPtyInput":
|
||||
return handleSetupTokenPtyInput(params as PluginSetupTokenPtyInputParams);
|
||||
case "loginPtyInput":
|
||||
return handleLoginPtyInput(params as PluginLoginPtyInputParams);
|
||||
|
||||
case "setupTokenPtyStop":
|
||||
return handleSetupTokenPtyStop(params as PluginSetupTokenPtyStopParams);
|
||||
case "loginPtyStop":
|
||||
return handleLoginPtyStop(params as PluginLoginPtyStopParams);
|
||||
|
||||
case "setupTokenPtyClose":
|
||||
return handleSetupTokenPtyClose(params as PluginSetupTokenPtyCloseParams);
|
||||
case "loginPtyClose":
|
||||
return handleLoginPtyClose(params as PluginLoginPtyCloseParams);
|
||||
|
||||
case "duplexChannelOpen":
|
||||
return handleDuplexChannelOpen(params as PluginDuplexChannelOpenParams);
|
||||
|
|
@ -1727,10 +1727,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||
if (plugin.definition.onEnvironmentCaptureTemplate) supportedMethods.push("environmentCaptureTemplate");
|
||||
if (plugin.definition.onEnvironmentCancelInteractiveSetup) supportedMethods.push("environmentCancelInteractiveSetup");
|
||||
if (plugin.definition.onEnvironmentDeleteTemplate) supportedMethods.push("environmentDeleteTemplate");
|
||||
if (plugin.definition.onSetupTokenPtyOpen) supportedMethods.push("setupTokenPtyOpen");
|
||||
if (plugin.definition.onSetupTokenPtyInput) supportedMethods.push("setupTokenPtyInput");
|
||||
if (plugin.definition.onSetupTokenPtyStop) supportedMethods.push("setupTokenPtyStop");
|
||||
if (plugin.definition.onSetupTokenPtyClose) supportedMethods.push("setupTokenPtyClose");
|
||||
if (plugin.definition.onLoginPtyOpen) supportedMethods.push("loginPtyOpen");
|
||||
if (plugin.definition.onLoginPtyInput) supportedMethods.push("loginPtyInput");
|
||||
if (plugin.definition.onLoginPtyStop) supportedMethods.push("loginPtyStop");
|
||||
if (plugin.definition.onLoginPtyClose) supportedMethods.push("loginPtyClose");
|
||||
if (plugin.definition.onDuplexChannelOpen) supportedMethods.push("duplexChannelOpen");
|
||||
if (plugin.definition.onDuplexChannelWrite) supportedMethods.push("duplexChannelWrite");
|
||||
if (plugin.definition.onDuplexChannelStop) supportedMethods.push("duplexChannelStop");
|
||||
|
|
@ -2083,32 +2083,32 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||
return plugin.definition.onEnvironmentDeleteTemplate(params);
|
||||
}
|
||||
|
||||
async function handleSetupTokenPtyOpen(params: PluginSetupTokenPtyOpenParams) {
|
||||
if (!plugin.definition.onSetupTokenPtyOpen) {
|
||||
throw methodNotImplemented("setupTokenPtyOpen");
|
||||
async function handleLoginPtyOpen(params: PluginLoginPtyOpenParams) {
|
||||
if (!plugin.definition.onLoginPtyOpen) {
|
||||
throw methodNotImplemented("loginPtyOpen");
|
||||
}
|
||||
return plugin.definition.onSetupTokenPtyOpen(params);
|
||||
return plugin.definition.onLoginPtyOpen(params);
|
||||
}
|
||||
|
||||
async function handleSetupTokenPtyInput(params: PluginSetupTokenPtyInputParams) {
|
||||
if (!plugin.definition.onSetupTokenPtyInput) {
|
||||
throw methodNotImplemented("setupTokenPtyInput");
|
||||
async function handleLoginPtyInput(params: PluginLoginPtyInputParams) {
|
||||
if (!plugin.definition.onLoginPtyInput) {
|
||||
throw methodNotImplemented("loginPtyInput");
|
||||
}
|
||||
return plugin.definition.onSetupTokenPtyInput(params);
|
||||
return plugin.definition.onLoginPtyInput(params);
|
||||
}
|
||||
|
||||
async function handleSetupTokenPtyStop(params: PluginSetupTokenPtyStopParams) {
|
||||
if (!plugin.definition.onSetupTokenPtyStop) {
|
||||
throw methodNotImplemented("setupTokenPtyStop");
|
||||
async function handleLoginPtyStop(params: PluginLoginPtyStopParams) {
|
||||
if (!plugin.definition.onLoginPtyStop) {
|
||||
throw methodNotImplemented("loginPtyStop");
|
||||
}
|
||||
return plugin.definition.onSetupTokenPtyStop(params);
|
||||
return plugin.definition.onLoginPtyStop(params);
|
||||
}
|
||||
|
||||
async function handleSetupTokenPtyClose(params: PluginSetupTokenPtyCloseParams) {
|
||||
if (!plugin.definition.onSetupTokenPtyClose) {
|
||||
throw methodNotImplemented("setupTokenPtyClose");
|
||||
async function handleLoginPtyClose(params: PluginLoginPtyCloseParams) {
|
||||
if (!plugin.definition.onLoginPtyClose) {
|
||||
throw methodNotImplemented("loginPtyClose");
|
||||
}
|
||||
return plugin.definition.onSetupTokenPtyClose(params);
|
||||
return plugin.definition.onLoginPtyClose(params);
|
||||
}
|
||||
|
||||
async function handleDuplexChannelOpen(params: PluginDuplexChannelOpenParams) {
|
||||
|
|
|
|||
|
|
@ -774,28 +774,32 @@ describe("worker setup-token pseudo-terminal dispatch", () => {
|
|||
let killed = 0;
|
||||
let closed = 0;
|
||||
|
||||
// The worker emits output and exit through `ctx.setupTokenPty`, bound to the
|
||||
// The worker emits output and exit through `ctx.loginPty`, bound to the
|
||||
// worker session id. The test drives them through the captured emitters.
|
||||
const controllablePlugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
emitOutput = (chunk: string) => ctx.setupTokenPty.output("ws-1", chunk);
|
||||
resolveWait = (value) => ctx.setupTokenPty.exit("ws-1", value.exitCode);
|
||||
emitOutput = (chunk: string) => ctx.loginPty.output("ws-1", chunk);
|
||||
resolveWait = (value) => ctx.loginPty.exit("ws-1", value.exitCode);
|
||||
},
|
||||
async onSetupTokenPtyOpen(params) {
|
||||
// The open carries the host route id and the fixed command. The worker
|
||||
// returns a worker session id for the output binding only.
|
||||
async onLoginPtyOpen(params) {
|
||||
// The open carries the host route id, the closed command key, and the
|
||||
// validated session home. The worker returns a worker session id for the
|
||||
// output binding only. The open carries no command string.
|
||||
expect(params.hostRouteId).toBe("route-1");
|
||||
expect(params.command).toBe("claude setup-token");
|
||||
expect(params.loginCommandKey).toBe("claude");
|
||||
expect(params.sessionHome).toBe(
|
||||
"/tmp/paperclip-adapter-login/11111111-2222-4333-8444-555555555555",
|
||||
);
|
||||
expect(params.providerLeaseId).toBe("lease-1");
|
||||
return { workerSessionId: "ws-1" };
|
||||
},
|
||||
async onSetupTokenPtyInput(params) {
|
||||
async onLoginPtyInput(params) {
|
||||
inputs.push(params.data);
|
||||
},
|
||||
async onSetupTokenPtyStop() {
|
||||
async onLoginPtyStop() {
|
||||
killed += 1;
|
||||
},
|
||||
async onSetupTokenPtyClose(params) {
|
||||
async onLoginPtyClose(params) {
|
||||
// The close keys on the host route id and returns a bound acknowledgement.
|
||||
closed += 1;
|
||||
return { hostRouteId: params.hostRouteId };
|
||||
|
|
@ -839,10 +843,10 @@ describe("worker setup-token pseudo-terminal dispatch", () => {
|
|||
await expect(
|
||||
callWorker("initialize", {
|
||||
manifest: {
|
||||
id: "paperclip.setup-token-pty",
|
||||
id: "paperclip.login-pty",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Setup Token PTY Test",
|
||||
displayName: "Login PTY Test",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
|
|
@ -855,31 +859,32 @@ describe("worker setup-token pseudo-terminal dispatch", () => {
|
|||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
supportedMethods: expect.arrayContaining([
|
||||
"setupTokenPtyOpen",
|
||||
"setupTokenPtyInput",
|
||||
"setupTokenPtyStop",
|
||||
"setupTokenPtyClose",
|
||||
"loginPtyOpen",
|
||||
"loginPtyInput",
|
||||
"loginPtyStop",
|
||||
"loginPtyClose",
|
||||
]),
|
||||
});
|
||||
|
||||
await expect(
|
||||
callWorker("setupTokenPtyOpen", {
|
||||
callWorker("loginPtyOpen", {
|
||||
hostRouteId: "route-1",
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: "lease-1",
|
||||
command: "claude setup-token",
|
||||
loginCommandKey: "claude",
|
||||
sessionHome: "/tmp/paperclip-adapter-login/11111111-2222-4333-8444-555555555555",
|
||||
}),
|
||||
).resolves.toEqual({ workerSessionId: "ws-1" });
|
||||
|
||||
// The worker streams output as a notification bound to the worker session id.
|
||||
emitOutput?.("prompt output");
|
||||
await callWorker("setupTokenPtyInput", { workerSessionId: "ws-1", data: "browser-code" });
|
||||
await callWorker("setupTokenPtyStop", { workerSessionId: "ws-1" });
|
||||
await callWorker("loginPtyInput", { workerSessionId: "ws-1", data: "browser-code" });
|
||||
await callWorker("loginPtyStop", { workerSessionId: "ws-1" });
|
||||
resolveWait?.({ exitCode: 0 });
|
||||
await expect(
|
||||
callWorker("setupTokenPtyClose", { hostRouteId: "route-1" }),
|
||||
callWorker("loginPtyClose", { hostRouteId: "route-1" }),
|
||||
).resolves.toEqual({ hostRouteId: "route-1" });
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
|
@ -888,13 +893,13 @@ describe("worker setup-token pseudo-terminal dispatch", () => {
|
|||
expect(killed).toBe(1);
|
||||
expect(closed).toBe(1);
|
||||
const outputNotes = notifications.filter(
|
||||
(note) => note.method === "setupTokenPty.output",
|
||||
(note) => note.method === "loginPty.output",
|
||||
);
|
||||
expect(outputNotes.map((note) => note.params)).toEqual([
|
||||
{ workerSessionId: "ws-1", chunk: "prompt output" },
|
||||
]);
|
||||
const exitNotes = notifications.filter(
|
||||
(note) => note.method === "setupTokenPty.exit",
|
||||
(note) => note.method === "loginPty.exit",
|
||||
);
|
||||
expect(exitNotes.map((note) => note.params)).toEqual([
|
||||
{ workerSessionId: "ws-1", exitCode: 0 },
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
"dev": "tsx src/index.ts",
|
||||
"dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts",
|
||||
"prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh",
|
||||
"build": "tsc && mkdir -p dist/onboarding-assets dist/built-ins && cp -R src/onboarding-assets/. dist/onboarding-assets/ && cp -R src/built-ins/. dist/built-ins/ && node scripts/write-build-stamp.mjs",
|
||||
"build": "tsc && mkdir -p dist/onboarding-assets dist/built-ins dist/services/scripts && cp -R src/onboarding-assets/. dist/onboarding-assets/ && cp -R src/built-ins/. dist/built-ins/ && cp -R src/services/scripts/. dist/services/scripts/ && node scripts/write-build-stamp.mjs",
|
||||
"prepack": "pnpm run prepare:ui-dist",
|
||||
"postpack": "rm -rf ui-dist",
|
||||
"clean": "rm -rf dist",
|
||||
|
|
|
|||
|
|
@ -73,6 +73,10 @@ const mockEnvironmentRuntime = vi.hoisted(() => ({
|
|||
}));
|
||||
|
||||
const mockResolveEnvironmentExecutionTarget = vi.hoisted(() => vi.fn());
|
||||
// The provider capability resolver the device-login gate reads. A supported
|
||||
// provider advertises the login pseudo-terminal capability. A test overrides this
|
||||
// to prove an unsupported provider fails closed before any session or lease.
|
||||
const mockResolvePluginSandboxProviderDriverByKey = vi.hoisted(() => vi.fn());
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
|
||||
}));
|
||||
|
|
@ -136,6 +140,16 @@ vi.mock("../services/environment-runtime.js", () => ({
|
|||
environmentRuntimeService: () => mockEnvironmentRuntime,
|
||||
}));
|
||||
|
||||
vi.mock("../services/plugin-environment-driver.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../services/plugin-environment-driver.js")>(
|
||||
"../services/plugin-environment-driver.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../services/environment-execution-target.js", () => ({
|
||||
resolveEnvironmentExecutionTarget: mockResolveEnvironmentExecutionTarget,
|
||||
}));
|
||||
|
|
@ -263,8 +277,8 @@ function createFakeRuntime(): LoginSessionRuntime {
|
|||
providerLeaseId: `provider-lease-${input.sessionId}`,
|
||||
authPath: `/tmp/paperclip-adapter-login/${input.sessionId}/auth.json`,
|
||||
driver: {
|
||||
async execStreaming(_command, onStdout) {
|
||||
onStdout(PROMPT_OUTPUT);
|
||||
async start(_command, onData) {
|
||||
onData(PROMPT_OUTPUT);
|
||||
await harness.gate;
|
||||
return { exitCode: 0 };
|
||||
},
|
||||
|
|
@ -340,6 +354,14 @@ describe("adapter device-login routes", () => {
|
|||
config: { provider: "daytona" },
|
||||
envVars: {},
|
||||
}));
|
||||
// The default provider advertises the login pseudo-terminal capability. A
|
||||
// test overrides this to prove an unsupported provider fails closed.
|
||||
mockResolvePluginSandboxProviderDriverByKey.mockImplementation(
|
||||
async ({ driverKey }: { driverKey: string }) =>
|
||||
driverKey === "daytona"
|
||||
? { plugin: { id: "plugin-daytona" }, driver: { supportsLoginPty: true } }
|
||||
: null,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -396,10 +418,10 @@ describe("adapter device-login routes", () => {
|
|||
expect(store.rows.get(res.body.sessionId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects an adapter whose login capability drives a different transport", async () => {
|
||||
// The Claude adapter declares a pseudo-terminal login, not a streamed-exec
|
||||
// device login. The guard reads the capability transport, so it rejects the
|
||||
// adapter with a fixed 400 before any lease.
|
||||
it("rejects an adapter whose login capability drives a different panel mode", async () => {
|
||||
// The Claude adapter declares a submitted-browser-code login, not a
|
||||
// displayed-code device login. The guard reads the capability panel mode, so
|
||||
// it rejects the adapter with a fixed 400 before any lease.
|
||||
const app = await createApp();
|
||||
|
||||
const res = await request(app)
|
||||
|
|
@ -410,12 +432,14 @@ describe("adapter device-login routes", () => {
|
|||
expect(harness.acquisitions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("starts a device login for a third adapter that declares the streamed-exec capability", async () => {
|
||||
// A third adapter, not the Codex adapter, declares a streamed-exec login
|
||||
// capability. The guard reads the registry capability, not the adapter name,
|
||||
// so the adapter passes the guard and starts a session. This proves no
|
||||
// adapter-name branch remains in the guard path. The test overrides an
|
||||
// existing adapter type so the strict request schema accepts it.
|
||||
it("rejects a displayed-code adapter that has no mapped login command key", async () => {
|
||||
// A third adapter declares a displayed-code login capability, but its adapter
|
||||
// type maps to no login command key. The login opener resolves the command
|
||||
// key from the closed command map, so this adapter would fail at command
|
||||
// resolution after the route creates session state. The guard keeps admission
|
||||
// consistent with the command map, so it rejects the adapter with a fixed 400
|
||||
// before any lease. The test overrides an existing adapter type so the strict
|
||||
// request schema accepts it.
|
||||
const app = await createApp();
|
||||
const { registerServerAdapter, unregisterServerAdapter } = await import("../adapters/index.js");
|
||||
registerServerAdapter({
|
||||
|
|
@ -428,7 +452,6 @@ describe("adapter device-login routes", () => {
|
|||
},
|
||||
loginCapability: {
|
||||
panelMode: "displayed_code",
|
||||
sandboxTransport: "streamed_exec",
|
||||
timeoutPolicy: "caller_bounded",
|
||||
getCommand: () => "vendor login",
|
||||
parsePrompt: () => null,
|
||||
|
|
@ -439,15 +462,74 @@ describe("adapter device-login routes", () => {
|
|||
.post(loginPath(COMPANY_1, "gemini_local"))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(res.body).toMatchObject({ environmentId: SANDBOX_ENV_1, status: "starting" });
|
||||
expect(harness.acquisitions).toHaveLength(1);
|
||||
expect(harness.acquisitions[0]).toMatchObject({ adapterType: "gemini_local" });
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(400);
|
||||
expect(harness.acquisitions).toHaveLength(0);
|
||||
} finally {
|
||||
unregisterServerAdapter("gemini_local");
|
||||
}
|
||||
});
|
||||
|
||||
it("starts a device login for a non-Codex adapter that maps to a login command key", async () => {
|
||||
// A non-Codex adapter type that maps to a login command key declares a
|
||||
// displayed-code login capability. The guard reads the registry capability
|
||||
// and the command map, not the adapter name, so the adapter passes the guard
|
||||
// and starts a session. This proves no adapter-name branch remains in the
|
||||
// guard path. The test overrides the mapped `claude_local` type with a
|
||||
// displayed-code capability so the guard admits it.
|
||||
const app = await createApp();
|
||||
const { registerServerAdapter, unregisterServerAdapter } = await import("../adapters/index.js");
|
||||
registerServerAdapter({
|
||||
type: "claude_local",
|
||||
execute: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
testEnvironment: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
loginCapability: {
|
||||
panelMode: "displayed_code",
|
||||
timeoutPolicy: "caller_bounded",
|
||||
getCommand: () => "vendor login",
|
||||
parsePrompt: () => null,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post(loginPath(COMPANY_1, "claude_local"))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(res.body).toMatchObject({ environmentId: SANDBOX_ENV_1, status: "starting" });
|
||||
expect(harness.acquisitions).toHaveLength(1);
|
||||
expect(harness.acquisitions[0]).toMatchObject({ adapterType: "claude_local" });
|
||||
} finally {
|
||||
unregisterServerAdapter("claude_local");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a provider that does not advertise the login pseudo-terminal capability", async () => {
|
||||
// The device login runs on a real pseudo-terminal, so it needs a provider
|
||||
// that advertises the login pseudo-terminal capability. The provider resolves
|
||||
// to a driver with no capability, so the route gate fails closed before any
|
||||
// session row or lease.
|
||||
mockResolvePluginSandboxProviderDriverByKey.mockImplementation(
|
||||
async ({ driverKey }: { driverKey: string }) =>
|
||||
driverKey === "daytona"
|
||||
? { plugin: { id: "plugin-daytona" }, driver: { supportsLoginPty: false } }
|
||||
: null,
|
||||
);
|
||||
const app = await createApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(res.body).toMatchObject({ code: "codex_device_login_provider_unsupported" });
|
||||
// The gate ran before any session row or lease, so the runtime acquired none.
|
||||
expect(harness.acquisitions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a malformed start body with the strict schema before any side effect", async () => {
|
||||
const app = await createApp();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,307 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, symlinkSync, chmodSync, linkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
AUTH_JSON_FILE_NAME,
|
||||
DEVICE_LOGIN_AUTH_READ_ERROR,
|
||||
DEVICE_LOGIN_AUTH_READ_SCRIPT,
|
||||
MAX_AUTH_JSON_BYTES,
|
||||
decodeAuthReadOutput,
|
||||
runDescriptorBoundAuthRead,
|
||||
} from "../services/codex-device-login-credential-read.ts";
|
||||
|
||||
// The helper runs on node and walks the path with `/proc/self/fd`, which is
|
||||
// Linux only. A non-Linux host skips the script tests and keeps the pure-decode
|
||||
// tests. The login sandbox is Linux, so the walk runs there.
|
||||
const scriptRunnable = process.platform === "linux";
|
||||
const describeLinux = scriptRunnable ? describe : describe.skip;
|
||||
|
||||
if (!scriptRunnable) {
|
||||
console.warn(
|
||||
"Skipping descriptor-bound read script tests on this host: the /proc walk needs Linux.",
|
||||
);
|
||||
}
|
||||
|
||||
describe("decodeAuthReadOutput", () => {
|
||||
it("decodes strict base64 to the exact bytes", () => {
|
||||
const payload = Buffer.from('{"tokens":{"refresh_token":"r"}}', "utf8");
|
||||
const decoded = decodeAuthReadOutput(payload.toString("base64"));
|
||||
expect(decoded.equals(payload)).toBe(true);
|
||||
});
|
||||
|
||||
it("tolerates a trailing newline from the helper", () => {
|
||||
const payload = Buffer.from("abc", "utf8");
|
||||
const decoded = decodeAuthReadOutput(`${payload.toString("base64")}\n`);
|
||||
expect(decoded.toString("utf8")).toBe("abc");
|
||||
});
|
||||
|
||||
it("returns an empty buffer for empty output", () => {
|
||||
expect(decodeAuthReadOutput("").length).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a non-base64 payload", () => {
|
||||
expect(() => decodeAuthReadOutput("not*base64*text")).toThrow(DEVICE_LOGIN_AUTH_READ_ERROR);
|
||||
});
|
||||
|
||||
it("rejects a wrong-padding payload", () => {
|
||||
// A single stray character is not a multiple of four, so it is malformed.
|
||||
expect(() => decodeAuthReadOutput("QQ")).toThrow(DEVICE_LOGIN_AUTH_READ_ERROR);
|
||||
});
|
||||
|
||||
it("rejects a decoded payload over the bound", () => {
|
||||
const oversize = Buffer.alloc(MAX_AUTH_JSON_BYTES + 1, 0x61).toString("base64");
|
||||
expect(() => decodeAuthReadOutput(oversize)).toThrow(DEVICE_LOGIN_AUTH_READ_ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
describeLinux("the descriptor-bound read helper script", () => {
|
||||
let root: string;
|
||||
|
||||
beforeAll(() => {
|
||||
root = mkdtempSync(path.join(tmpdir(), "codex-auth-read-"));
|
||||
});
|
||||
afterAll(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Run the helper against `home`. An explicit `expectedUid` forces the owner
|
||||
// check; the production caller omits it and the helper uses its own uid.
|
||||
function runScript(home: string, expectedUid?: number): { status: number | null; stdout: string } {
|
||||
const args = ["-e", DEVICE_LOGIN_AUTH_READ_SCRIPT, home, String(MAX_AUTH_JSON_BYTES)];
|
||||
if (expectedUid !== undefined) args.push(String(expectedUid));
|
||||
const result = spawnSync("node", args, { encoding: "utf8" });
|
||||
return { status: result.status, stdout: result.stdout ?? "" };
|
||||
}
|
||||
|
||||
// Build a fresh session home with a `0700` directory. The caller writes the
|
||||
// credential file inside it.
|
||||
function makeHome(name: string): string {
|
||||
const home = path.join(root, name);
|
||||
mkdirSync(home, { mode: 0o700 });
|
||||
return home;
|
||||
}
|
||||
|
||||
it("reads a regular 0600 credential owned by the login user", () => {
|
||||
const home = makeHome("valid");
|
||||
const payload = '{"tokens":{"refresh_token":"r"}}';
|
||||
const file = path.join(home, AUTH_JSON_FILE_NAME);
|
||||
writeFileSync(file, payload, { mode: 0o600 });
|
||||
chmodSync(file, 0o600);
|
||||
const { status, stdout } = runScript(home);
|
||||
expect(status).toBe(0);
|
||||
expect(Buffer.from(stdout.trim(), "base64").toString("utf8")).toBe(payload);
|
||||
});
|
||||
|
||||
it("rejects a missing credential", () => {
|
||||
const home = makeHome("missing");
|
||||
expect(runScript(home).status).not.toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a symlink credential (a swap between the check and the read)", () => {
|
||||
// The attacker swaps `auth.json` for a symlink to a file outside the home.
|
||||
// The single no-follow open rejects the symlink, so the read never follows
|
||||
// it. There is no separate check to race.
|
||||
const home = makeHome("symlink");
|
||||
const secret = path.join(root, "outside-secret");
|
||||
writeFileSync(secret, "outside", { mode: 0o600 });
|
||||
symlinkSync(secret, path.join(home, AUTH_JSON_FILE_NAME));
|
||||
expect(runScript(home).status).not.toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a directory in the credential position", () => {
|
||||
const home = makeHome("dir");
|
||||
mkdirSync(path.join(home, AUTH_JSON_FILE_NAME), { mode: 0o700 });
|
||||
expect(runScript(home).status).not.toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a wrong-mode credential", () => {
|
||||
const home = makeHome("mode");
|
||||
const file = path.join(home, AUTH_JSON_FILE_NAME);
|
||||
writeFileSync(file, "{}", { mode: 0o644 });
|
||||
chmodSync(file, 0o644);
|
||||
expect(runScript(home).status).not.toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a wrong-owner credential", () => {
|
||||
const home = makeHome("owner");
|
||||
const file = path.join(home, AUTH_JSON_FILE_NAME);
|
||||
writeFileSync(file, "{}", { mode: 0o600 });
|
||||
chmodSync(file, 0o600);
|
||||
// Force a uid that differs from the file owner. The check rejects it.
|
||||
const bogusUid = process.getuid ? process.getuid()! + 1 : 999999;
|
||||
expect(runScript(home, bogusUid).status).not.toBe(0);
|
||||
});
|
||||
|
||||
it("rejects an oversize credential", () => {
|
||||
const home = makeHome("oversize");
|
||||
const file = path.join(home, AUTH_JSON_FILE_NAME);
|
||||
writeFileSync(file, Buffer.alloc(MAX_AUTH_JSON_BYTES + 1, 0x61), { mode: 0o600 });
|
||||
chmodSync(file, 0o600);
|
||||
expect(runScript(home).status).not.toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a symlink session home", () => {
|
||||
const real = makeHome("real-home");
|
||||
const file = path.join(real, AUTH_JSON_FILE_NAME);
|
||||
writeFileSync(file, "{}", { mode: 0o600 });
|
||||
chmodSync(file, 0o600);
|
||||
const link = path.join(root, "home-symlink");
|
||||
symlinkSync(real, link);
|
||||
expect(runScript(link).status).not.toBe(0);
|
||||
});
|
||||
|
||||
it("rejects an intermediate-path symlink ancestor and never follows it", () => {
|
||||
// The attacker swaps an ancestor directory of the session home for a symlink
|
||||
// to an attacker-controlled tree. That tree holds a valid-looking 0600
|
||||
// credential. A single composite-path open with `O_NOFOLLOW` would follow the
|
||||
// ancestor symlink, because `O_NOFOLLOW` protects only the final component.
|
||||
// The per-component walk opens the ancestor with its own no-follow open, so
|
||||
// the read fails closed and returns no bytes.
|
||||
const evil = path.join(root, "evil-target");
|
||||
mkdirSync(path.join(evil, "session"), { recursive: true, mode: 0o700 });
|
||||
const file = path.join(evil, "session", AUTH_JSON_FILE_NAME);
|
||||
writeFileSync(file, '{"stolen":true}', { mode: 0o600 });
|
||||
chmodSync(file, 0o600);
|
||||
// `ancestor` is a symlink to the attacker tree. The home path routes through it.
|
||||
const ancestor = path.join(root, "ancestor-symlink");
|
||||
symlinkSync(evil, ancestor);
|
||||
const home = path.join(ancestor, "session");
|
||||
const { status, stdout } = runScript(home);
|
||||
expect(status).not.toBe(0);
|
||||
// The read leaked no bytes from the attacker tree.
|
||||
expect(stdout.trim()).toBe("");
|
||||
});
|
||||
|
||||
it("fails closed when an ancestor is swapped to a symlink between two reads", () => {
|
||||
// The first read sees a real ancestor and returns the private credential. An
|
||||
// attacker then swaps the ancestor for a symlink to an attacker tree with a
|
||||
// valid-looking credential. The second read walks the ancestor with a
|
||||
// no-follow open, so it fails closed and never returns the attacker bytes.
|
||||
const base = mkdtempSync(path.join(root, "swap-"));
|
||||
const mid = path.join(base, "mid");
|
||||
mkdirSync(path.join(mid, "session"), { recursive: true, mode: 0o700 });
|
||||
const goodFile = path.join(mid, "session", AUTH_JSON_FILE_NAME);
|
||||
writeFileSync(goodFile, '{"good":true}', { mode: 0o600 });
|
||||
chmodSync(goodFile, 0o600);
|
||||
const home = path.join(mid, "session");
|
||||
|
||||
const first = runScript(home);
|
||||
expect(first.status).toBe(0);
|
||||
expect(Buffer.from(first.stdout.trim(), "base64").toString("utf8")).toBe('{"good":true}');
|
||||
|
||||
// The attacker tree carries a same-shape credential.
|
||||
const evil = path.join(base, "evil");
|
||||
mkdirSync(path.join(evil, "session"), { recursive: true, mode: 0o700 });
|
||||
const evilFile = path.join(evil, "session", AUTH_JSON_FILE_NAME);
|
||||
writeFileSync(evilFile, '{"evil":true}', { mode: 0o600 });
|
||||
chmodSync(evilFile, 0o600);
|
||||
|
||||
// Swap the ancestor `mid` for a symlink to the attacker tree.
|
||||
rmSync(mid, { recursive: true, force: true });
|
||||
symlinkSync(evil, mid);
|
||||
|
||||
const second = runScript(home);
|
||||
expect(second.status).not.toBe(0);
|
||||
expect(second.stdout.trim()).toBe("");
|
||||
});
|
||||
|
||||
it("reads a hardlink credential inside the private home", () => {
|
||||
// A hardlink is not a symlink. The no-follow open allows it, and the fstat
|
||||
// sees a regular file. A hardlink inside the private `0700` home is not a
|
||||
// privilege escalation, so the read returns the linked inode content.
|
||||
const home = makeHome("hardlink");
|
||||
const backing = path.join(home, "backing.json");
|
||||
writeFileSync(backing, '{"ok":true}', { mode: 0o600 });
|
||||
chmodSync(backing, 0o600);
|
||||
linkSync(backing, path.join(home, AUTH_JSON_FILE_NAME));
|
||||
const { status, stdout } = runScript(home);
|
||||
expect(status).toBe(0);
|
||||
expect(Buffer.from(stdout.trim(), "base64").toString("utf8")).toBe('{"ok":true}');
|
||||
});
|
||||
});
|
||||
|
||||
describeLinux("runDescriptorBoundAuthRead", () => {
|
||||
let root: string;
|
||||
|
||||
beforeAll(() => {
|
||||
root = mkdtempSync(path.join(tmpdir(), "codex-auth-wrap-"));
|
||||
});
|
||||
afterAll(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// A fake environment runtime that runs the exact command and arguments the
|
||||
// wrapper composes. It exercises the real helper against a local home, so the
|
||||
// wrapper and the script are tested together. The session home shape must match
|
||||
// the validated pattern, so the fake maps the validated home onto a local
|
||||
// directory before it runs the helper.
|
||||
function createLocalRuntime(localHome: string) {
|
||||
return {
|
||||
async execute(input: { command: string; args?: string[]; bypassSession?: boolean }) {
|
||||
expect(input.command).toBe("node");
|
||||
expect(input.bypassSession).toBe(true);
|
||||
const args = [...(input.args ?? [])];
|
||||
// Replace the validated session-home argument with the local directory. The
|
||||
// wrapper composes `["-e", <script>, <sessionHome>, <maxBytes>]`, so the
|
||||
// session home is the third argument.
|
||||
args[2] = localHome;
|
||||
const result = spawnSync("node", args, { encoding: "utf8" });
|
||||
return { exitCode: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// A syntactically valid session home. The wrapper revalidates the shape.
|
||||
const VALID_HOME = "/tmp/paperclip-adapter-login/11111111-1111-1111-1111-111111111111";
|
||||
|
||||
it("returns the credential bytes on a valid read", async () => {
|
||||
const localHome = mkdtempSync(path.join(root, "valid-"));
|
||||
const payload = '{"tokens":{"refresh_token":"r"}}';
|
||||
const file = path.join(localHome, AUTH_JSON_FILE_NAME);
|
||||
writeFileSync(file, payload, { mode: 0o600 });
|
||||
chmodSync(file, 0o600);
|
||||
const bytes = await runDescriptorBoundAuthRead({
|
||||
environmentRuntime: createLocalRuntime(localHome) as never,
|
||||
environment: { id: "env", driver: "sandbox" } as never,
|
||||
lease: { id: "lease" } as never,
|
||||
sessionHome: VALID_HOME,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect(bytes.toString("utf8")).toBe(payload);
|
||||
});
|
||||
|
||||
it("returns the fixed error when the helper exits non-zero", async () => {
|
||||
const localHome = mkdtempSync(path.join(root, "missing-"));
|
||||
await expect(
|
||||
runDescriptorBoundAuthRead({
|
||||
environmentRuntime: createLocalRuntime(localHome) as never,
|
||||
environment: { id: "env", driver: "sandbox" } as never,
|
||||
lease: { id: "lease" } as never,
|
||||
sessionHome: VALID_HOME,
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
).rejects.toThrow(DEVICE_LOGIN_AUTH_READ_ERROR);
|
||||
});
|
||||
|
||||
it("rejects an invalid session-home shape before it runs the helper", async () => {
|
||||
let executeCalls = 0;
|
||||
const runtime = {
|
||||
async execute() {
|
||||
executeCalls += 1;
|
||||
return { exitCode: 0, stdout: "", stderr: "" };
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
runDescriptorBoundAuthRead({
|
||||
environmentRuntime: runtime as never,
|
||||
environment: { id: "env", driver: "sandbox" } as never,
|
||||
lease: { id: "lease" } as never,
|
||||
sessionHome: "/etc/passwd",
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
expect(executeCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -115,6 +115,8 @@ const execHang: ExecBehavior = ({ onStdout }) => {
|
|||
interface FakeRuntimeOptions {
|
||||
exec: ExecBehavior;
|
||||
authBytes?: Buffer;
|
||||
/** When set, the descriptor-bound read rejects with this error on success. */
|
||||
readError?: Error;
|
||||
delete?: () => Promise<SandboxDeleteResult>;
|
||||
}
|
||||
|
||||
|
|
@ -130,8 +132,11 @@ function createFakeRuntime(opts: FakeRuntimeOptions) {
|
|||
providerLeaseId: `lease-${input.sessionId}`,
|
||||
authPath: sessionCredentialPath(input.sessionId),
|
||||
driver: {
|
||||
execStreaming: (_command, onStdout) => opts.exec({ onStdout, input }),
|
||||
readFile: async () => opts.authBytes ?? Buffer.from("{}"),
|
||||
start: (_command, onData) => opts.exec({ onStdout: onData, input }),
|
||||
readFile: async () => {
|
||||
if (opts.readError) throw opts.readError;
|
||||
return opts.authBytes ?? Buffer.from("{}");
|
||||
},
|
||||
dispose: async () => {},
|
||||
},
|
||||
deleteSandbox: async () => {
|
||||
|
|
@ -241,7 +246,7 @@ describe("codex device login service", () => {
|
|||
providerLeaseId: `lease-${input.sessionId}`,
|
||||
authPath: sessionCredentialPath(input.sessionId),
|
||||
driver: {
|
||||
execStreaming: (_command, onStdout) => execSuccess({ onStdout, input }),
|
||||
start: (_command, onData) => execSuccess({ onStdout: onData, input }),
|
||||
readFile: async () => Buffer.from("{}"),
|
||||
dispose: async () => {},
|
||||
},
|
||||
|
|
@ -416,6 +421,73 @@ describe("codex device login service", () => {
|
|||
expect(deleteCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("fails closed and deletes the sandbox when the descriptor-bound read fails", async () => {
|
||||
const store = createMemoryStore();
|
||||
let promoteCalls = 0;
|
||||
const { runtime, deleteCalls } = createFakeRuntime({
|
||||
// The login command exits 0, but the descriptor-bound read rejects. The
|
||||
// runner converts the read error to a fixed driver error, so the run fails.
|
||||
exec: execSuccess,
|
||||
readError: new Error("device login failed: the sandbox credential read errored."),
|
||||
});
|
||||
const service = makeService({
|
||||
store,
|
||||
runtime,
|
||||
promotion: {
|
||||
promote: () => {
|
||||
promoteCalls += 1;
|
||||
},
|
||||
},
|
||||
});
|
||||
const companyId = randomUUID();
|
||||
const { session, completed } = await service.start({
|
||||
companyId,
|
||||
environmentId: randomUUID(),
|
||||
adapterType: ADAPTER_TYPE,
|
||||
startedByUserId: OWNER_A,
|
||||
});
|
||||
const outcome = await completed;
|
||||
// A failed read never promotes a credential and still deletes the sandbox.
|
||||
expect(outcome.status).toBe("failed");
|
||||
expect(promoteCalls).toBe(0);
|
||||
expect(deleteCalls).toHaveLength(1);
|
||||
const row = await store.getByPublicId(session.sessionId, companyId);
|
||||
expect(row?.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("fails closed and deletes the sandbox when the credential schema is malformed", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { runtime, deleteCalls } = createFakeRuntime({
|
||||
// The read returns a well-formed file, but the promotion rejects the shape.
|
||||
// A malformed schema fails the same way as a failed check: no authenticate,
|
||||
// and a sandbox delete.
|
||||
exec: execSuccess,
|
||||
authBytes: Buffer.from('{"not":"a subscription auth"}', "utf8"),
|
||||
});
|
||||
const service = makeService({
|
||||
store,
|
||||
runtime,
|
||||
promotion: {
|
||||
promote: () => {
|
||||
throw new Error("malformed credential schema");
|
||||
},
|
||||
},
|
||||
});
|
||||
const companyId = randomUUID();
|
||||
const { session, completed } = await service.start({
|
||||
companyId,
|
||||
environmentId: randomUUID(),
|
||||
adapterType: ADAPTER_TYPE,
|
||||
startedByUserId: OWNER_A,
|
||||
});
|
||||
const outcome = await completed;
|
||||
expect(outcome.status).toBe("failed");
|
||||
expect(deleteCalls).toHaveLength(1);
|
||||
const failed = await service.readOwnerSession(session.sessionId, companyId, OWNER_A);
|
||||
expect(failed?.status).toBe("failed");
|
||||
expect(failed?.failure?.reason).toBe("promotion_failed");
|
||||
});
|
||||
|
||||
it("deletes the sandbox and records a cancelled terminal on a cancellation", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { runtime, deleteCalls } = createFakeRuntime({ exec: execHang });
|
||||
|
|
@ -789,45 +861,53 @@ describe("codex device login service", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("builds a production driver that sets an empty session home and reads the fixed credential path", async () => {
|
||||
// Record the program and the argument vector for each call. The provider
|
||||
// quotes each element as one shell token, so the driver must pass a program
|
||||
// plus its arguments, never one compound string. A one-shot exec streams no
|
||||
// output, so the login command must set `forceSession` to open the session.
|
||||
const calls: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
forceSession?: boolean;
|
||||
bypassSession?: boolean;
|
||||
}[] = [];
|
||||
it("runs the login over the shared pseudo-terminal and reads the credential with the descriptor-bound read", async () => {
|
||||
const sessionId = randomUUID();
|
||||
const sessionHome = sessionCodexHomePath(sessionId);
|
||||
const authPath = sessionCredentialPath(sessionId);
|
||||
|
||||
// The fake pseudo-terminal session streams the prompt and exits with code
|
||||
// zero. It records the command the transport opened.
|
||||
let openedCommand: string | null = null;
|
||||
let closed = false;
|
||||
const openPtySession = async (command: string) => {
|
||||
openedCommand = command;
|
||||
return {
|
||||
onData(listener: (chunk: string) => void) {
|
||||
listener(PROMPT_OUTPUT);
|
||||
},
|
||||
write() {},
|
||||
async wait() {
|
||||
return { exitCode: 0 };
|
||||
},
|
||||
kill() {},
|
||||
async close() {
|
||||
closed = true;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// The fake runtime runs only the descriptor-bound read. It records the fixed,
|
||||
// no-follow helper shape and returns the base64 of the credential bytes.
|
||||
const execCalls: { command: string; args?: string[]; bypassSession?: boolean }[] = [];
|
||||
const credential = '{"tokens":{"refresh_token":"r"}}';
|
||||
const environmentRuntime = {
|
||||
execute: async (input: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
forceSession?: boolean;
|
||||
bypassSession?: boolean;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => void | Promise<void>;
|
||||
}) => {
|
||||
calls.push({
|
||||
execute: async (input: { command: string; args?: string[]; bypassSession?: boolean }) => {
|
||||
execCalls.push({
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
forceSession: input.forceSession,
|
||||
bypassSession: input.bypassSession,
|
||||
});
|
||||
const script = input.args?.join(" ") ?? "";
|
||||
if (script.includes("codex login")) {
|
||||
await input.onLog?.("stdout", PROMPT_OUTPUT);
|
||||
return { exitCode: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
// The credential read.
|
||||
return { exitCode: 0, stdout: '{"token":"secret"}', stderr: "" };
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: Buffer.from(credential, "utf8").toString("base64"),
|
||||
stderr: "",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const driver = buildSandboxLoginDriver({
|
||||
// The helper only calls `execute`; a partial runtime is enough here.
|
||||
openPtySession,
|
||||
environmentRuntime: environmentRuntime as never,
|
||||
environment: { id: "env", driver: "sandbox" } as never,
|
||||
lease: { id: "lease" } as never,
|
||||
|
|
@ -836,25 +916,28 @@ describe("codex device login service", () => {
|
|||
});
|
||||
|
||||
const chunks: string[] = [];
|
||||
const result = await driver.execStreaming("codex login --device-auth", (chunk) => chunks.push(chunk));
|
||||
const result = await driver.start("codex login --device-auth", (chunk) => chunks.push(chunk));
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(openedCommand).toBe("codex login --device-auth");
|
||||
expect(chunks.join("")).toContain(DEVICE_LOGIN_URL);
|
||||
// The login command runs through `sh -c`, so the runtime runs the whole
|
||||
// compound command in one shell. It sets an empty session-specific Codex
|
||||
// home before the login. It opens the session, so the prompt streams.
|
||||
expect(calls[0].command).toBe("sh");
|
||||
expect(calls[0].args).toEqual([
|
||||
"-c",
|
||||
`rm -rf ${sessionHome} && mkdir -p ${sessionHome} && CODEX_HOME=${sessionHome} codex login --device-auth`,
|
||||
]);
|
||||
expect(calls[0].forceSession).toBe(true);
|
||||
// The login runs over the pseudo-terminal, so the descriptor read did not run
|
||||
// during the login.
|
||||
expect(execCalls.length).toBe(0);
|
||||
|
||||
// The runner path passes the credential path, but the descriptor-bound read
|
||||
// ignores it and reads the fixed file under the verified session home.
|
||||
const authBytes = await driver.readFile(authPath);
|
||||
expect(authBytes.toString("utf8")).toBe('{"token":"secret"}');
|
||||
// The credential read runs `cat` with the path as one argument, one-shot.
|
||||
expect(calls[1].command).toBe("cat");
|
||||
expect(calls[1].args).toEqual([authPath]);
|
||||
expect(calls[1].bypassSession).toBe(true);
|
||||
expect(authBytes.toString("utf8")).toBe(credential);
|
||||
expect(execCalls.length).toBe(1);
|
||||
// The read runs the fixed no-follow helper as a one-shot, non-session command.
|
||||
expect(execCalls[0].command).toBe("node");
|
||||
expect(execCalls[0].bypassSession).toBe(true);
|
||||
expect(execCalls[0].args?.[0]).toBe("-e");
|
||||
// The third argument is the verified session home the helper opens no-follow.
|
||||
expect(execCalls[0].args?.[2]).toBe(sessionHome);
|
||||
|
||||
await driver.dispose();
|
||||
expect(closed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AgentAdapterType } from "@paperclipai/shared";
|
||||
|
||||
// The production runtime resolves the environment through `environmentService`.
|
||||
// These tests replace it with a fake, so the runtime runs with no database. The
|
||||
// fake returns a fixed environment and records the lease-metadata tag write.
|
||||
const mockEnvironmentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
updateLeaseMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/environments.js", () => ({
|
||||
environmentService: () => mockEnvironmentService,
|
||||
}));
|
||||
|
||||
import {
|
||||
createCodexWorkerBoundLoginPtyOpener,
|
||||
createProductionLoginSessionRuntime,
|
||||
sessionCodexHomePath,
|
||||
CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED,
|
||||
type LoginPtySessionBinding,
|
||||
} from "../services/codex-device-login-service.js";
|
||||
|
||||
// A fake worker pseudo-terminal session. It satisfies the shared transport
|
||||
// contract, so the opener can return it.
|
||||
function fakeWorkerSession() {
|
||||
return {
|
||||
onData() {},
|
||||
write() {},
|
||||
async wait() {
|
||||
return { exitCode: 0 };
|
||||
},
|
||||
kill() {},
|
||||
async close() {},
|
||||
};
|
||||
}
|
||||
|
||||
// A verified binding for one login session. The runtime builds it after it
|
||||
// acquires the lease. The tests set the server-controlled session home.
|
||||
function makeBinding(overrides: Partial<LoginPtySessionBinding> = {}): LoginPtySessionBinding {
|
||||
return {
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
adapterType: "codex_local" as AgentAdapterType,
|
||||
providerLeaseId: "provider-lease-9",
|
||||
sessionHome: sessionCodexHomePath(randomUUID()),
|
||||
environment: { id: "env-1", driver: "sandbox" } as never,
|
||||
lease: {
|
||||
id: "lease-1",
|
||||
metadata: { pluginId: "paperclip.daytona", provider: "daytona" },
|
||||
} as never,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("createCodexWorkerBoundLoginPtyOpener", () => {
|
||||
it("passes the binding's server-controlled session home to the worker route", async () => {
|
||||
const sessionHome = sessionCodexHomePath(randomUUID());
|
||||
const session = fakeWorkerSession();
|
||||
const openLoginPtySession = vi.fn(
|
||||
async (_pluginId: string, _input: Record<string, unknown>) => session,
|
||||
);
|
||||
const openLivePtySession = createCodexWorkerBoundLoginPtyOpener({
|
||||
workerManager: { openLoginPtySession },
|
||||
});
|
||||
|
||||
const opener = await openLivePtySession(makeBinding({ sessionHome }));
|
||||
// The opener ignores the incoming command argument. It resolves the closed
|
||||
// command key from the trusted adapter type.
|
||||
const opened = await opener("caller-supplied-ignored");
|
||||
|
||||
expect(openLoginPtySession).toHaveBeenCalledTimes(1);
|
||||
const [pluginId, input] = openLoginPtySession.mock.calls[0];
|
||||
expect(pluginId).toBe("paperclip.daytona");
|
||||
expect(input).toMatchObject({
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: "provider-lease-9",
|
||||
loginCommandKey: "codex",
|
||||
// The opener passes the binding's server-controlled home, not a fresh UUID
|
||||
// home. This home is the one the descriptor-bound credential read opens, so
|
||||
// the sandbox `CODEX_HOME` and the read target match.
|
||||
sessionHome,
|
||||
});
|
||||
expect(input).not.toHaveProperty("command");
|
||||
expect(opened).toBe(session);
|
||||
});
|
||||
|
||||
it("fails closed when the lease carries no sandbox worker binding", async () => {
|
||||
const openLoginPtySession = vi.fn();
|
||||
const openLivePtySession = createCodexWorkerBoundLoginPtyOpener({
|
||||
workerManager: { openLoginPtySession },
|
||||
});
|
||||
|
||||
await expect(
|
||||
openLivePtySession(makeBinding({ lease: { id: "lease-1", metadata: {} } as never })),
|
||||
).rejects.toThrow();
|
||||
expect(openLoginPtySession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when the adapter type has no login command key", async () => {
|
||||
const openLoginPtySession = vi.fn();
|
||||
const openLivePtySession = createCodexWorkerBoundLoginPtyOpener({
|
||||
workerManager: { openLoginPtySession },
|
||||
});
|
||||
|
||||
await expect(
|
||||
openLivePtySession(makeBinding({ adapterType: "gemini_local" as AgentAdapterType })),
|
||||
).rejects.toThrow();
|
||||
expect(openLoginPtySession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createProductionLoginSessionRuntime lease-acquisition gate", () => {
|
||||
const acquireInput = {
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
adapterType: "codex_local" as AgentAdapterType,
|
||||
startedByUserId: "user-a",
|
||||
};
|
||||
|
||||
it("re-checks the provider capability and fails before the provider lease when it is unsupported", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "env-1",
|
||||
driver: "sandbox",
|
||||
config: { provider: "daytona" },
|
||||
});
|
||||
const acquireRunLease = vi.fn();
|
||||
// The capability flipped to unsupported after the route gate. The lease gate
|
||||
// reads the current capability and fails closed.
|
||||
const assertProviderSupportsLoginPty = vi.fn(async () => {
|
||||
throw new Error(CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED);
|
||||
});
|
||||
const runtime = createProductionLoginSessionRuntime({
|
||||
db: {} as never,
|
||||
environmentRuntime: { acquireRunLease, getDriver: vi.fn() } as never,
|
||||
assertProviderSupportsLoginPty,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runtime.acquireLoginLease({ ...acquireInput, sessionId: randomUUID() }),
|
||||
).rejects.toThrow(CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED);
|
||||
// The runtime re-checked the current capability from the environment id.
|
||||
expect(assertProviderSupportsLoginPty).toHaveBeenCalledWith("env-1");
|
||||
// The gate failed before the provider lease, so no lease was acquired.
|
||||
expect(acquireRunLease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("acquires the provider lease after the capability check passes", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "env-1",
|
||||
driver: "sandbox",
|
||||
config: { provider: "daytona" },
|
||||
});
|
||||
mockEnvironmentService.updateLeaseMetadata.mockResolvedValue(undefined);
|
||||
const lease = { id: "lease-1", providerLeaseId: "provider-lease-1", metadata: {} };
|
||||
const acquireRunLease = vi.fn(async () => ({
|
||||
lease,
|
||||
environment: { id: "env-1", driver: "sandbox" },
|
||||
}));
|
||||
const assertProviderSupportsLoginPty = vi.fn(async () => {});
|
||||
const runtime = createProductionLoginSessionRuntime({
|
||||
db: {} as never,
|
||||
environmentRuntime: { acquireRunLease, getDriver: vi.fn() } as never,
|
||||
assertProviderSupportsLoginPty,
|
||||
});
|
||||
|
||||
const acquired = await runtime.acquireLoginLease({ ...acquireInput, sessionId: randomUUID() });
|
||||
|
||||
expect(assertProviderSupportsLoginPty).toHaveBeenCalledWith("env-1");
|
||||
// The gate passed, so the runtime acquired the provider lease after it.
|
||||
expect(acquireRunLease).toHaveBeenCalledTimes(1);
|
||||
expect(acquired.providerLeaseId).toBe("provider-lease-1");
|
||||
});
|
||||
});
|
||||
|
|
@ -50,17 +50,17 @@ rl.on("line", (line) => {
|
|||
result: {
|
||||
ok: true,
|
||||
supportedMethods: [
|
||||
"setupTokenPtyOpen",
|
||||
"setupTokenPtyInput",
|
||||
"setupTokenPtyStop",
|
||||
"setupTokenPtyClose",
|
||||
"loginPtyOpen",
|
||||
"loginPtyInput",
|
||||
"loginPtyStop",
|
||||
"loginPtyClose",
|
||||
],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "setupTokenPtyOpen") {
|
||||
if (method === "loginPtyOpen") {
|
||||
const directive = parseDirective(params.providerLeaseId);
|
||||
const mode = directive.mode ?? "normal";
|
||||
const workerSessionId = directive.workerSessionId ?? "ws-1";
|
||||
|
|
@ -92,7 +92,7 @@ rl.on("line", (line) => {
|
|||
for (const entry of outputs) {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "setupTokenPty.output",
|
||||
method: "loginPty.output",
|
||||
params: {
|
||||
workerSessionId: entry.sid ?? workerSessionId,
|
||||
chunk: entry.chunk,
|
||||
|
|
@ -102,7 +102,7 @@ rl.on("line", (line) => {
|
|||
if (typeof directive.exitCode === "number") {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "setupTokenPty.exit",
|
||||
method: "loginPty.exit",
|
||||
params: { workerSessionId, exitCode: directive.exitCode },
|
||||
});
|
||||
}
|
||||
|
|
@ -110,14 +110,14 @@ rl.on("line", (line) => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (method === "setupTokenPtyInput") {
|
||||
if (method === "loginPtyInput") {
|
||||
// Echo the input back as one output notification for the bound session, so a
|
||||
// test proves the input reaches the worker and the output routes back.
|
||||
for (const entry of routes.values()) {
|
||||
if (entry.workerSessionId === params.workerSessionId) {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "setupTokenPty.output",
|
||||
method: "loginPty.output",
|
||||
params: { workerSessionId: entry.workerSessionId, chunk: `echo:${params.data}` },
|
||||
});
|
||||
}
|
||||
|
|
@ -126,12 +126,12 @@ rl.on("line", (line) => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (method === "setupTokenPtyStop") {
|
||||
if (method === "loginPtyStop") {
|
||||
send({ jsonrpc: "2.0", id: message.id, result: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "setupTokenPtyClose") {
|
||||
if (method === "loginPtyClose") {
|
||||
const entry = routes.get(params.hostRouteId);
|
||||
routes.delete(params.hostRouteId);
|
||||
const closeMode = entry ? entry.closeMode : "ack";
|
||||
|
|
@ -9,7 +9,6 @@ import {
|
|||
type HostServices,
|
||||
type HostToWorkerMethods,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import { CLAUDE_SETUP_TOKEN_COMMAND } from "@paperclipai/adapter-claude-local/server";
|
||||
import {
|
||||
appendStderrExcerpt,
|
||||
createPluginWorkerHandle,
|
||||
|
|
@ -1060,14 +1059,14 @@ describe("plugin worker manager execute.log route", () => {
|
|||
// Host-owned setup-token login pseudo-terminal route gate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SETUP_TOKEN_PTY_WORKER_ENTRYPOINT = path.join(
|
||||
const LOGIN_PTY_WORKER_ENTRYPOINT = path.join(
|
||||
FIXTURES_DIR,
|
||||
"plugin-worker-setup-token-pty.cjs",
|
||||
"plugin-worker-login-pty.cjs",
|
||||
);
|
||||
|
||||
function makeSetupTokenPtyHandle(extra?: Record<string, unknown>) {
|
||||
function makeLoginPtyHandle(extra?: Record<string, unknown>) {
|
||||
return createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: SETUP_TOKEN_PTY_WORKER_ENTRYPOINT,
|
||||
entrypointPath: LOGIN_PTY_WORKER_ENTRYPOINT,
|
||||
manifest: TEST_MANIFEST,
|
||||
config: {},
|
||||
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
|
||||
|
|
@ -1077,40 +1076,72 @@ function makeSetupTokenPtyHandle(extra?: Record<string, unknown>) {
|
|||
});
|
||||
}
|
||||
|
||||
// One valid session home. The shape is the fixed root, one slash, and one UUID.
|
||||
const PTY_SESSION_HOME = "/tmp/paperclip-adapter-login/11111111-2222-4333-8444-555555555555";
|
||||
|
||||
function ptyOpenInput(directive: unknown) {
|
||||
return {
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
// The test directive rides in `providerLeaseId`, an opaque field the manager
|
||||
// forwards to the worker unchanged. The manager allowlists `command`, so the
|
||||
// command stays the fixed `CLAUDE_SETUP_TOKEN_COMMAND` for every route-gate
|
||||
// case.
|
||||
// forwards to the worker unchanged. The manager carries the closed command
|
||||
// key and the validated session home; it carries no command string.
|
||||
providerLeaseId: JSON.stringify(directive),
|
||||
command: CLAUDE_SETUP_TOKEN_COMMAND,
|
||||
loginCommandKey: "claude" as const,
|
||||
sessionHome: PTY_SESSION_HOME,
|
||||
};
|
||||
}
|
||||
|
||||
describe("plugin worker manager setup-token pty route gate", () => {
|
||||
it("rejects a command that is not the allowlisted setup-token command before the worker call", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
it("rejects a command key that is not in the closed set before the worker call", async () => {
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
// A caller passes a command other than the fixed `CLAUDE_SETUP_TOKEN_COMMAND`.
|
||||
// The manager rejects it with one fixed non-secret error before the worker
|
||||
// call, so no arbitrary process spawns in the sandbox pseudo-terminal.
|
||||
// The provider driver key routes the worker; it confers no command
|
||||
// authority. A key outside the closed set rejects with one fixed non-secret
|
||||
// error before the worker call, so a caller cannot select an arbitrary
|
||||
// command in the sandbox pseudo-terminal.
|
||||
await expect(
|
||||
handle.openSetupTokenPtySession({
|
||||
handle.openLoginPtySession({
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: JSON.stringify({ mode: "normal" }),
|
||||
command: "rm -rf /",
|
||||
loginCommandKey: "gemini" as unknown as "claude",
|
||||
sessionHome: PTY_SESSION_HOME,
|
||||
}),
|
||||
).rejects.toThrow("SETUP_TOKEN_PTY_COMMAND_NOT_ALLOWED");
|
||||
// The rejected open never consumed the single route, so a later open with
|
||||
// the allowlisted command still succeeds.
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
).rejects.toThrow("LOGIN_PTY_COMMAND_NOT_ALLOWED");
|
||||
// The rejected open never consumed the single route, so a later open with a
|
||||
// closed key still succeeds.
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
);
|
||||
expect(session).toBeDefined();
|
||||
await session.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a malformed session home before the worker call", async () => {
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
// The host revalidates the server-controlled session home shape before the
|
||||
// worker RPC. A traversal candidate fails closed at the last host gate.
|
||||
await expect(
|
||||
handle.openLoginPtySession({
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: JSON.stringify({ mode: "normal" }),
|
||||
loginCommandKey: "claude" as const,
|
||||
sessionHome: "/tmp/paperclip-adapter-login/../etc",
|
||||
}),
|
||||
).rejects.toThrow("LOGIN_PTY_INVALID_SESSION_HOME");
|
||||
// The rejected open never consumed the single route.
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
);
|
||||
expect(session).toBeDefined();
|
||||
|
|
@ -1121,21 +1152,21 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
|||
});
|
||||
|
||||
it("permits one active credential pseudo-terminal per worker", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const first = await handle.openSetupTokenPtySession(
|
||||
const first = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
);
|
||||
// A second open while the first route is not closed rejects with one fixed
|
||||
// non-secret error before it reaches the worker.
|
||||
await expect(
|
||||
handle.openSetupTokenPtySession(ptyOpenInput({ mode: "normal" })),
|
||||
).rejects.toThrow("SETUP_TOKEN_PTY_ROUTE_BUSY");
|
||||
handle.openLoginPtySession(ptyOpenInput({ mode: "normal" })),
|
||||
).rejects.toThrow("LOGIN_PTY_ROUTE_BUSY");
|
||||
await first.close();
|
||||
// After the first route closes and the worker acknowledges the close, a new
|
||||
// open is admitted.
|
||||
const second = await handle.openSetupTokenPtySession(
|
||||
const second = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
);
|
||||
await second.close();
|
||||
|
|
@ -1145,10 +1176,10 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
|||
});
|
||||
|
||||
it("delivers output only for the exact bound worker session id and drops a mismatch", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({
|
||||
workerSessionId: "ws-A",
|
||||
outputs: [
|
||||
|
|
@ -1172,10 +1203,10 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
|||
});
|
||||
|
||||
it("routes delayed input to the worker and back to the listener", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-A" }),
|
||||
);
|
||||
const chunks: string[] = [];
|
||||
|
|
@ -1191,12 +1222,12 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
|||
});
|
||||
|
||||
it("terminalizes the route when the cumulative output passes the per-route bound", async () => {
|
||||
const handle = makeSetupTokenPtyHandle({
|
||||
setupTokenPtyLimits: { maxTotalChars: 10 },
|
||||
const handle = makeLoginPtyHandle({
|
||||
loginPtyLimits: { maxTotalChars: 10 },
|
||||
});
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({
|
||||
outputs: [
|
||||
{ chunk: "aaaaa" }, // total 5 → delivered
|
||||
|
|
@ -1217,15 +1248,15 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
|||
});
|
||||
|
||||
it("terminalizes and fails closed on a malformed open reply, then admits a later open", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
await expect(
|
||||
handle.openSetupTokenPtySession(ptyOpenInput({ mode: "malformed-open" })),
|
||||
).rejects.toThrow("SETUP_TOKEN_PTY_OPEN_FAILED");
|
||||
handle.openLoginPtySession(ptyOpenInput({ mode: "malformed-open" })),
|
||||
).rejects.toThrow("LOGIN_PTY_OPEN_FAILED");
|
||||
// The terminalize closed the route by the host route id and the worker
|
||||
// acknowledged the close, so a later open is admitted.
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
);
|
||||
expect(session).toBeDefined();
|
||||
|
|
@ -1236,13 +1267,13 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
|||
});
|
||||
|
||||
it("terminalizes the route on an open timeout", async () => {
|
||||
const handle = makeSetupTokenPtyHandle({
|
||||
setupTokenPtyLimits: { openTimeoutMs: 200 },
|
||||
const handle = makeLoginPtyHandle({
|
||||
loginPtyLimits: { openTimeoutMs: 200 },
|
||||
});
|
||||
try {
|
||||
await handle.start();
|
||||
await expect(
|
||||
handle.openSetupTokenPtySession(ptyOpenInput({ mode: "no-open-reply" })),
|
||||
handle.openLoginPtySession(ptyOpenInput({ mode: "no-open-reply" })),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
|
|
@ -1250,10 +1281,10 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
|||
});
|
||||
|
||||
it("binds the worker session id one time and ignores a duplicate open reply", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({
|
||||
mode: "duplicate-open-reply",
|
||||
workerSessionId: "ws-A",
|
||||
|
|
@ -1274,10 +1305,10 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
|||
});
|
||||
|
||||
it("closes the route with a fixed exit when the worker exits", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
);
|
||||
const waitResult = session.wait();
|
||||
|
|
@ -1291,15 +1322,15 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
|||
});
|
||||
|
||||
it("retires the worker on an unconfirmed close acknowledgement", async () => {
|
||||
const handle = makeSetupTokenPtyHandle({
|
||||
setupTokenPtyLimits: { closeTimeoutMs: 200 },
|
||||
const handle = makeLoginPtyHandle({
|
||||
loginPtyLimits: { closeTimeoutMs: 200 },
|
||||
});
|
||||
try {
|
||||
await handle.start();
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
handle.on("exit", () => resolve());
|
||||
});
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ mode: "normal", closeMode: "bad-ack" }),
|
||||
);
|
||||
await session.close();
|
||||
|
|
@ -1307,7 +1338,7 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
|||
// fails closed and retires the worker before any reuse.
|
||||
await exited;
|
||||
await expect(
|
||||
handle.openSetupTokenPtySession(ptyOpenInput({ mode: "normal" })),
|
||||
handle.openLoginPtySession(ptyOpenInput({ mode: "normal" })),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ function makeModule(loginCapability?: unknown) {
|
|||
|
||||
const validLoginCapability: AdapterLoginCapability = {
|
||||
panelMode: "displayed_code",
|
||||
sandboxTransport: "streamed_exec",
|
||||
timeoutPolicy: "caller_bounded",
|
||||
getCommand: () => "vendor login",
|
||||
parsePrompt: () => null,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ describe("built-in adapter login capabilities", () => {
|
|||
expect(capability).toBeDefined();
|
||||
if (!capability) return;
|
||||
expect(capability.panelMode).toBe("displayed_code");
|
||||
expect(capability.sandboxTransport).toBe("streamed_exec");
|
||||
expect(capability.timeoutPolicy).toBe("caller_bounded");
|
||||
expect(capability.completionClaim).toBeUndefined();
|
||||
expect(typeof capability.getCommand).toBe("function");
|
||||
|
|
@ -26,7 +25,6 @@ describe("built-in adapter login capabilities", () => {
|
|||
expect(capability).toBeDefined();
|
||||
if (!capability) return;
|
||||
expect(capability.panelMode).toBe("submitted_browser_code");
|
||||
expect(capability.sandboxTransport).toBe("pseudo_terminal");
|
||||
expect(capability.timeoutPolicy).toBe("fixed");
|
||||
expect(capability.completionClaim).toBe("storedSessionId");
|
||||
expect(typeof capability.getCommand).toBe("function");
|
||||
|
|
|
|||
|
|
@ -205,7 +205,6 @@ Paperclip keeps this tombstone registered so stale acpx_local rows fail clearly
|
|||
// values only.
|
||||
const claudeLoginCapability: AdapterLoginCapability = {
|
||||
panelMode: "submitted_browser_code",
|
||||
sandboxTransport: "pseudo_terminal",
|
||||
timeoutPolicy: "fixed",
|
||||
getCommand: () => CLAUDE_SETUP_TOKEN_COMMAND,
|
||||
parsePrompt: (output) => {
|
||||
|
|
@ -220,13 +219,13 @@ const claudeLoginCapability: AdapterLoginCapability = {
|
|||
};
|
||||
|
||||
// The Codex interactive login capability. Codex runs `codex login --device-auth`
|
||||
// over the streamed exec channel. The flow shows a one-time code that the user
|
||||
// enters in the browser. The caller sets the host-side timeout. The device-login
|
||||
// flow writes its credential inside the sandbox, so the capability declares no
|
||||
// terminal credential capture and no completion claim.
|
||||
// on a real pseudo-terminal, because a pipe emits no login prompt. The flow shows
|
||||
// a one-time code that the user enters in the browser. The caller sets the
|
||||
// host-side timeout. The device-login flow writes its credential inside the
|
||||
// sandbox, so the capability declares no terminal credential capture and no
|
||||
// completion claim.
|
||||
const codexLoginCapability: AdapterLoginCapability = {
|
||||
panelMode: "displayed_code",
|
||||
sandboxTransport: "streamed_exec",
|
||||
timeoutPolicy: "caller_bounded",
|
||||
getCommand: () => CODEX_DEVICE_LOGIN_COMMAND,
|
||||
parsePrompt: (output) => {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ import {
|
|||
createProductionSetupTokenSandboxProvider,
|
||||
createProductionSetupTokenCleanupStore,
|
||||
createSetupTokenSecretWriter,
|
||||
createWorkerBoundSetupTokenPtyOpener,
|
||||
createWorkerBoundLoginPtyOpener,
|
||||
} from "./services/setup-token-transport-binding.js";
|
||||
import { environmentService } from "./services/environments.js";
|
||||
import { environmentRuntimeService } from "./services/environment-runtime.js";
|
||||
|
|
@ -464,7 +464,7 @@ export async function createApp(
|
|||
sandbox: createProductionSetupTokenSandboxProvider({
|
||||
environments: environmentService(db),
|
||||
environmentRuntime: environmentRuntimeService(db, { pluginWorkerManager: workerManager }),
|
||||
openLivePtySession: createWorkerBoundSetupTokenPtyOpener({
|
||||
openLivePtySession: createWorkerBoundLoginPtyOpener({
|
||||
workerManager,
|
||||
environments: environmentService(db),
|
||||
log: (line) => logger.info(line),
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ function makeAdapter(overrides: Partial<ServerAdapterModule> = {}): ServerAdapte
|
|||
|
||||
const displayedCodeLogin: AdapterLoginCapability = {
|
||||
panelMode: "displayed_code",
|
||||
sandboxTransport: "streamed_exec",
|
||||
timeoutPolicy: "caller_bounded",
|
||||
getCommand: () => "vendor login",
|
||||
parsePrompt: () => null,
|
||||
|
|
@ -33,7 +32,6 @@ describe("buildAdapterCapabilities login projection", () => {
|
|||
const caps = buildAdapterCapabilities(makeAdapter({ loginCapability: displayedCodeLogin }));
|
||||
expect(caps.login).toEqual({
|
||||
panelMode: "displayed_code",
|
||||
sandboxTransport: "streamed_exec",
|
||||
timeoutPolicy: "caller_bounded",
|
||||
});
|
||||
});
|
||||
|
|
@ -48,7 +46,6 @@ describe("buildAdapterCapabilities login projection", () => {
|
|||
makeAdapter({
|
||||
loginCapability: {
|
||||
panelMode: "submitted_browser_code",
|
||||
sandboxTransport: "pseudo_terminal",
|
||||
timeoutPolicy: "fixed",
|
||||
getCommand: () => "vendor setup-token",
|
||||
parsePrompt: () => null,
|
||||
|
|
@ -59,7 +56,6 @@ describe("buildAdapterCapabilities login projection", () => {
|
|||
);
|
||||
expect(caps.login).toEqual({
|
||||
panelMode: "submitted_browser_code",
|
||||
sandboxTransport: "pseudo_terminal",
|
||||
timeoutPolicy: "fixed",
|
||||
});
|
||||
expect(caps.login).not.toHaveProperty("getCommand");
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js";
|
|||
import type { ServerAdapterModule, AdapterConfigSchema } from "../adapters/types.js";
|
||||
import type {
|
||||
AdapterLoginPanelMode,
|
||||
AdapterLoginSandboxTransport,
|
||||
AdapterLoginTimeoutPolicy,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js";
|
||||
|
|
@ -101,13 +100,13 @@ interface AdapterInstallRequest {
|
|||
|
||||
/**
|
||||
* The safe scalar login fields the adapter listing projects to the client. It
|
||||
* carries only the panel mode, the sandbox transport, and the timeout policy.
|
||||
* It carries no function member and no secret. The user interface reads it to
|
||||
* pick the login flow and the login panel.
|
||||
* carries only the panel mode and the timeout policy. It carries no function
|
||||
* member and no secret. The user interface reads it to pick the login flow and
|
||||
* the login panel. Every login runs on a real pseudo-terminal, so the projection
|
||||
* carries no transport field.
|
||||
*/
|
||||
interface AdapterLoginProjection {
|
||||
panelMode: AdapterLoginPanelMode;
|
||||
sandboxTransport: AdapterLoginSandboxTransport;
|
||||
timeoutPolicy: AdapterLoginTimeoutPolicy;
|
||||
}
|
||||
|
||||
|
|
@ -189,7 +188,6 @@ export function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterC
|
|||
? {
|
||||
login: {
|
||||
panelMode: login.panelMode,
|
||||
sandboxTransport: login.sandboxTransport,
|
||||
timeoutPolicy: login.timeoutPolicy,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } f
|
|||
import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js";
|
||||
import { assertAuthenticated, assertBoard, assertCompanyAccess, assertInstanceAdmin, buildActorSecretContext, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js";
|
||||
import { runAdapterLoginStartSpine } from "./adapter-login-route-spine.js";
|
||||
import { isLoginCommandSupportedAdapterType } from "../services/login-command.js";
|
||||
import {
|
||||
assertNoAgentHostWorkspaceCommandMutation,
|
||||
collectAgentAdapterWorkspaceCommandPaths,
|
||||
|
|
@ -143,8 +144,11 @@ import {
|
|||
import {
|
||||
AdapterAuthSessionConflictError,
|
||||
createCodexDeviceLoginService,
|
||||
createCodexWorkerBoundLoginPtyOpener,
|
||||
createDbAdapterAuthSessionStore,
|
||||
createProductionLoginSessionRuntime,
|
||||
CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED,
|
||||
CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE,
|
||||
} from "../services/codex-device-login-service.js";
|
||||
import type { AdapterAuthSessionOwnerResponse } from "@paperclipai/shared";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
|
|
@ -490,7 +494,28 @@ export function agentRoutes(
|
|||
const adapterLoginStore = createDbAdapterAuthSessionStore(db);
|
||||
const adapterLoginService = createCodexDeviceLoginService({
|
||||
store: adapterLoginStore,
|
||||
runtime: createProductionLoginSessionRuntime({ db, environmentRuntime }),
|
||||
runtime: createProductionLoginSessionRuntime({
|
||||
db,
|
||||
environmentRuntime,
|
||||
// Re-check the provider login pseudo-terminal capability from current
|
||||
// runtime state immediately before the provider lease. The route gate ran
|
||||
// earlier, so a managed reconciliation can rebind the environment to an
|
||||
// unsupported provider between the gate and the acquire; this fails closed
|
||||
// before the lease and the pseudo-terminal.
|
||||
assertProviderSupportsLoginPty: (environmentId) =>
|
||||
assertCodexLoginProviderCapability(environmentId),
|
||||
// Wire the live Codex pseudo-terminal opener through the plugin worker
|
||||
// manager route. The opener sets the sandbox `CODEX_HOME` to the same
|
||||
// server-controlled session home the descriptor-bound credential read
|
||||
// opens. When no worker manager is bound, the runtime keeps its fail-closed
|
||||
// opener and the login fails closed.
|
||||
openLivePtySession: options.pluginWorkerManager
|
||||
? createCodexWorkerBoundLoginPtyOpener({
|
||||
workerManager: options.pluginWorkerManager,
|
||||
log: (line) => logger.info(line),
|
||||
})
|
||||
: undefined,
|
||||
}),
|
||||
// The mandatory credential promotion. A successful login authenticates only
|
||||
// after this promotion validates the exact staged credential, runs an
|
||||
// independent readiness check, confirms the session still holds the sole
|
||||
|
|
@ -1215,13 +1240,25 @@ export function agentRoutes(
|
|||
return findActiveServerAdapter(type)?.loginCapability ?? null;
|
||||
}
|
||||
|
||||
// The device-login route drives a login over the streamed exec channel. It
|
||||
// serves any adapter whose registry login capability declares that transport.
|
||||
// The guard reads the capability, not the adapter name, so a new adapter with
|
||||
// the same transport passes with no code change. It rejects an adapter with no
|
||||
// matching capability with a fixed 400.
|
||||
function assertStreamedExecLoginAdapter(type: string): void {
|
||||
if (getRegistryLoginCapability(type)?.sandboxTransport !== "streamed_exec") {
|
||||
// The device-login route drives a login that shows a one-time code on a real
|
||||
// pseudo-terminal. It serves an adapter whose registry login capability
|
||||
// declares the displayed-code panel mode and whose trusted adapter type maps
|
||||
// to a login command key. The guard reads the panel mode and the command map,
|
||||
// not the adapter name, so a new adapter that satisfies both passes with no
|
||||
// guard code change. It rejects an adapter with no matching capability, and an
|
||||
// adapter with no mapped command key, with the same fixed 400.
|
||||
//
|
||||
// The command-map check keeps admission consistent with the closed command
|
||||
// map. The login opener resolves the command key from the same map. An adapter
|
||||
// that declares the displayed-code capability but has no mapped key would pass
|
||||
// the panel-mode check, then fail at command resolution after the route
|
||||
// creates session state. The guard rejects it before any session or lease side
|
||||
// effect.
|
||||
function assertDeviceLoginAdapter(type: string): void {
|
||||
if (getRegistryLoginCapability(type)?.panelMode !== "displayed_code") {
|
||||
throw badRequest(`Adapter "${type}" does not support a device login.`);
|
||||
}
|
||||
if (!isLoginCommandSupportedAdapterType(type)) {
|
||||
throw badRequest(`Adapter "${type}" does not support a device login.`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1271,14 +1308,15 @@ export function agentRoutes(
|
|||
}
|
||||
|
||||
/**
|
||||
* Fails closed when the environment provider does not advertise the Claude
|
||||
* setup-token login capability. It resolves the effective provider from the
|
||||
* environment config, then reads the static capability from the provider
|
||||
* plugin manifest. It never checks the provider by name. A missing plugin, a
|
||||
* non-plugin provider, and a provider without the flag all fail closed with
|
||||
* the fixed, typed error, so no session row, lease, or pseudo-terminal starts.
|
||||
* Reports whether the environment provider advertises the login pseudo-terminal
|
||||
* capability. It resolves the effective provider from the current environment
|
||||
* config, then reads the static capability from the provider plugin manifest. It
|
||||
* never checks the provider by name. A missing provider, a missing plugin, a
|
||||
* non-plugin provider, and a provider without the flag all return false. Both
|
||||
* login flows share this resolver, so both gates read the same current
|
||||
* capability.
|
||||
*/
|
||||
async function assertSetupTokenLoginProviderCapability(environmentId: string): Promise<void> {
|
||||
async function resolveProviderSupportsLoginPty(environmentId: string): Promise<boolean> {
|
||||
const environment = await environmentsSvc.getById(environmentId);
|
||||
const config =
|
||||
environment?.config && typeof environment.config === "object"
|
||||
|
|
@ -1288,13 +1326,39 @@ export function agentRoutes(
|
|||
const resolved = provider
|
||||
? await resolvePluginSandboxProviderDriverByKey({ db, driverKey: provider })
|
||||
: null;
|
||||
if (!resolved?.driver.supportsLoginPty) {
|
||||
return resolved?.driver.supportsLoginPty === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails closed when the environment provider does not advertise the login
|
||||
* pseudo-terminal capability that the Claude setup-token login needs. It reads
|
||||
* the current provider capability. It fails closed with the fixed, typed error,
|
||||
* so no session row, lease, or pseudo-terminal starts.
|
||||
*/
|
||||
async function assertSetupTokenLoginProviderCapability(environmentId: string): Promise<void> {
|
||||
if (!(await resolveProviderSupportsLoginPty(environmentId))) {
|
||||
throw unprocessable(SETUP_TOKEN_PROVIDER_UNSUPPORTED, {
|
||||
code: SETUP_TOKEN_PROVIDER_UNSUPPORTED_CODE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails closed when the environment provider does not advertise the login
|
||||
* pseudo-terminal capability that the Codex device login needs. It reads the
|
||||
* current provider capability. The Codex route runs it before any session or
|
||||
* lease state, and the lease-acquisition path runs it again from current runtime
|
||||
* state before the provider lease. It fails closed with the fixed, typed error,
|
||||
* so no session row, lease, or pseudo-terminal starts.
|
||||
*/
|
||||
async function assertCodexLoginProviderCapability(environmentId: string): Promise<void> {
|
||||
if (!(await resolveProviderSupportsLoginPty(environmentId))) {
|
||||
throw unprocessable(CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED, {
|
||||
code: CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Read a login session for its owner. The durable row is the authority for the
|
||||
// company and the owner. This returns null when the row is absent, when it
|
||||
// belongs to another company or adapter, or when the requesting user is not the
|
||||
|
|
@ -2597,11 +2661,19 @@ export function agentRoutes(
|
|||
req,
|
||||
res,
|
||||
deriveOwner: () => assertCanManageAdapterLogin(req, companyId),
|
||||
guardBeforeValidate: () => assertStreamedExecLoginAdapter(type),
|
||||
guardBeforeValidate: () => assertDeviceLoginAdapter(type),
|
||||
requestSchema: startAdapterAuthSessionRequestSchema,
|
||||
invalidRequestError: "The device login start request is invalid.",
|
||||
requestOverrides: { adapterType: type },
|
||||
assertSandbox: (data) => assertSandboxLoginEnvironment(companyId, data.environmentId),
|
||||
assertSandbox: async (data) => {
|
||||
// The device login runs on a real pseudo-terminal, so it needs a
|
||||
// provider that advertises the login pseudo-terminal capability. Gate
|
||||
// the route on the current provider capability before any session or
|
||||
// lease state. The lease-acquisition path re-checks it from current
|
||||
// runtime state before the provider lease.
|
||||
await assertSandboxLoginEnvironment(companyId, data.environmentId);
|
||||
await assertCodexLoginProviderCapability(data.environmentId);
|
||||
},
|
||||
});
|
||||
if (!resolved) return;
|
||||
const { ownerUserId: startedByUserId, data } = resolved;
|
||||
|
|
@ -2650,7 +2722,7 @@ export function agentRoutes(
|
|||
const type = req.params.type as string;
|
||||
const sessionId = req.params.sessionId as string;
|
||||
const ownerUserId = await assertCanManageAdapterLogin(req, companyId);
|
||||
assertStreamedExecLoginAdapter(type);
|
||||
assertDeviceLoginAdapter(type);
|
||||
|
||||
const owner = await readOwnerLoginSession(companyId, type, sessionId, ownerUserId);
|
||||
if (!owner) {
|
||||
|
|
@ -2670,7 +2742,7 @@ export function agentRoutes(
|
|||
const type = req.params.type as string;
|
||||
const sessionId = req.params.sessionId as string;
|
||||
const ownerUserId = await assertCanManageAdapterLogin(req, companyId);
|
||||
assertStreamedExecLoginAdapter(type);
|
||||
assertDeviceLoginAdapter(type);
|
||||
|
||||
// Scope the cancel to this company, adapter, and owner. A non-owner and a
|
||||
// cross-company caller both receive a 404 and cannot cancel a session.
|
||||
|
|
@ -4754,15 +4826,12 @@ export function agentRoutes(
|
|||
guardAfterValidate: (data) => {
|
||||
// The setup-token route drives a login on a pseudo-terminal and records a
|
||||
// stored session identifier on success. It serves any adapter whose
|
||||
// registry login capability declares that transport and that claim. The
|
||||
// guard reads the capability, not the adapter name, so a new adapter with
|
||||
// the same capability passes with no code change. It rejects an adapter
|
||||
// with no matching capability with a fixed 400.
|
||||
// registry login capability records that completion claim. The guard reads
|
||||
// the capability, not the adapter name, so a new adapter with the same
|
||||
// claim passes with no code change. It rejects an adapter with no matching
|
||||
// capability with a fixed 400.
|
||||
const capability = getRegistryLoginCapability(data.adapterType);
|
||||
if (
|
||||
capability?.sandboxTransport !== "pseudo_terminal" ||
|
||||
capability.completionClaim !== "storedSessionId"
|
||||
) {
|
||||
if (capability?.completionClaim !== "storedSessionId") {
|
||||
res.status(400).json({ error: "This adapter does not support a setup-token login." });
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,11 +111,9 @@ const mockSecretService = vi.hoisted(() => ({
|
|||
const mockFindActiveServerAdapter = vi.hoisted(() => vi.fn());
|
||||
|
||||
// The Claude setup-token login capability the registry declares for the built-in
|
||||
// adapter. The guard requires the pseudo-terminal transport and the stored-session
|
||||
// completion claim.
|
||||
// adapter. The guard requires the stored-session completion claim.
|
||||
const CLAUDE_LOGIN_CAPABILITY = {
|
||||
panelMode: "submitted_browser_code",
|
||||
sandboxTransport: "pseudo_terminal",
|
||||
timeoutPolicy: "fixed",
|
||||
completionClaim: "storedSessionId",
|
||||
getCommand: () => "",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
// The descriptor-bound credential read for the Codex device login.
|
||||
//
|
||||
// The login pseudo-terminal (PTY) runs `codex login --device-auth` and writes
|
||||
// `auth.json` into the server-controlled session home. The service reads that
|
||||
// file after the command exits with code zero. The read must close the
|
||||
// time-of-check-to-time-of-use (TOCTOU) window: a pathname check and a later
|
||||
// `cat` on the same pathname let an attacker swap the file between the two steps.
|
||||
//
|
||||
// This module runs one fixed, server-controlled operation inside the sandbox
|
||||
// instead. The operation opens the filesystem root, then walks each path
|
||||
// component of the session home with no symlink follow. `O_NOFOLLOW` protects
|
||||
// only the final component of one open, so the operation opens every intermediate
|
||||
// component with a separate no-follow open. A symlink at any ancestor fails the
|
||||
// walk. The operation then opens `auth.json` relative to the final directory
|
||||
// descriptor without a symlink follow, runs `fstat` on the opened descriptor, and
|
||||
// reads only from that same descriptor. The `fstat` check requires a regular
|
||||
// file, login-user ownership, exact mode `0600`, and a bounded size. A missing
|
||||
// file, a symlink at any component, a non-regular file, a wrong owner, a wrong
|
||||
// mode, or an oversize file returns one fixed, non-secret error.
|
||||
//
|
||||
// The provider exposes no descriptor application programming interface (API), so
|
||||
// this module runs a fixed helper program in the sandbox through the environment
|
||||
// runtime `execute` seam. The helper is node, because the sandbox already runs
|
||||
// node for the Paperclip bridge. The helper never re-opens the pathname after the
|
||||
// check: the `fstat` and the read bind to the one opened descriptor.
|
||||
//
|
||||
// Security: the helper prints only the base64 of the credential bytes on the
|
||||
// fully validated success path. It prints one fixed error token on every failed
|
||||
// path. It never prints the pathname, the raw bytes, or any other detail. This
|
||||
// module converts any failure to one fixed, non-secret error and reads no bytes.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { Environment, EnvironmentLease } from "@paperclipai/shared";
|
||||
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
|
||||
import { validateLoginSessionHome } from "./login-command.js";
|
||||
|
||||
/**
|
||||
* The bounded size for the credential payload. A real subscription `auth.json` is
|
||||
* a few kilobytes. The read rejects a larger payload before it returns any bytes.
|
||||
* The value matches the adapter export bound.
|
||||
*/
|
||||
export const MAX_AUTH_JSON_BYTES = 64 * 1024;
|
||||
|
||||
/** The fixed credential file name inside the session home. No caller controls it. */
|
||||
export const AUTH_JSON_FILE_NAME = "auth.json";
|
||||
|
||||
/**
|
||||
* The fixed, non-secret error the read returns on every failed path. The message
|
||||
* carries no pathname, no byte, and no other secret detail.
|
||||
*/
|
||||
export const DEVICE_LOGIN_AUTH_READ_ERROR =
|
||||
"device login failed: the sandbox credential read errored.";
|
||||
|
||||
/**
|
||||
* The fixed helper program. The server reads the helper from its own script file
|
||||
* and runs it in the sandbox as `node -e <script> <sessionHome> <maxBytes>
|
||||
* [<expectedUid>]`. The sandbox already runs node for the Paperclip bridge, so the
|
||||
* helper needs no extra runtime. The helper source lives in
|
||||
* `scripts/codex-auth-read.cjs`, so no large script stays as a string literal in
|
||||
* this module.
|
||||
*
|
||||
* The helper opens the filesystem root, then opens each session-home path
|
||||
* component in turn with a no-follow, directory-only open, relative to the
|
||||
* previous directory descriptor, so a symlink at any component fails. It opens
|
||||
* `auth.json` relative to the final directory descriptor with `O_NOFOLLOW`, runs
|
||||
* `fstat` on the opened descriptor, and requires a regular file, the expected
|
||||
* owner, exact mode `0600`, and a size at or below the bound. It reads only from
|
||||
* that same descriptor. The `expectedUid` argument is optional; the server omits
|
||||
* it, so the helper uses the login user's own id.
|
||||
*/
|
||||
export const DEVICE_LOGIN_AUTH_READ_SCRIPT = readFileSync(
|
||||
fileURLToPath(new URL("./scripts/codex-auth-read.cjs", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
/** A strict base64 token: standard alphabet, correct padding, length a multiple
|
||||
* of four. The read rejects any other stdout, so malformed helper output never
|
||||
* decodes to partial bytes. */
|
||||
const STRICT_BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||
|
||||
/**
|
||||
* Decodes the helper stdout to the credential bytes. It accepts only strict
|
||||
* base64 with correct padding, so a truncated or corrupt line returns the fixed
|
||||
* error instead of partial bytes. It rejects a decoded payload over the bound.
|
||||
*/
|
||||
export function decodeAuthReadOutput(stdout: string): Buffer {
|
||||
const encoded = stdout.trim();
|
||||
if (encoded.length % 4 !== 0 || !STRICT_BASE64_RE.test(encoded)) {
|
||||
throw new Error(DEVICE_LOGIN_AUTH_READ_ERROR);
|
||||
}
|
||||
const bytes = Buffer.from(encoded, "base64");
|
||||
if (bytes.length > MAX_AUTH_JSON_BYTES) {
|
||||
throw new Error(DEVICE_LOGIN_AUTH_READ_ERROR);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** The environment-runtime surface the descriptor-bound read needs. */
|
||||
export interface DescriptorBoundAuthReadDeps {
|
||||
environmentRuntime: Pick<EnvironmentRuntimeService, "execute">;
|
||||
environment: Environment;
|
||||
lease: EnvironmentLease;
|
||||
/** The verified, server-controlled session home. */
|
||||
sessionHome: string;
|
||||
/** The host timeout for the read command. */
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the descriptor-bound credential read once and returns the bytes. It
|
||||
* revalidates the session-home shape, then runs the fixed helper as a one-shot,
|
||||
* non-session command. It converts a non-zero exit, an empty or malformed stdout,
|
||||
* or an oversize payload to the one fixed, non-secret error. It reads no bytes on
|
||||
* any failed path.
|
||||
*/
|
||||
export async function runDescriptorBoundAuthRead(
|
||||
deps: DescriptorBoundAuthReadDeps,
|
||||
): Promise<Buffer> {
|
||||
// Revalidate the home shape before the sandbox command. The server derived and
|
||||
// validated it earlier; this second check keeps the helper argument fixed.
|
||||
validateLoginSessionHome(deps.sessionHome);
|
||||
let result: { exitCode: number | null; stdout?: string };
|
||||
try {
|
||||
result = await deps.environmentRuntime.execute({
|
||||
environment: deps.environment,
|
||||
lease: deps.lease,
|
||||
command: "node",
|
||||
args: ["-e", DEVICE_LOGIN_AUTH_READ_SCRIPT, deps.sessionHome, String(MAX_AUTH_JSON_BYTES)],
|
||||
timeoutMs: deps.timeoutMs,
|
||||
// The read is one fixed, non-session operation. It must not open or reuse
|
||||
// the login session.
|
||||
bypassSession: true,
|
||||
});
|
||||
} catch {
|
||||
// A driver error may embed sandbox text. Convert it to the fixed error.
|
||||
throw new Error(DEVICE_LOGIN_AUTH_READ_ERROR);
|
||||
}
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(DEVICE_LOGIN_AUTH_READ_ERROR);
|
||||
}
|
||||
return decodeAuthReadOutput(result.stdout ?? "");
|
||||
}
|
||||
|
|
@ -20,9 +20,20 @@ import {
|
|||
type DeviceLoginPrompt,
|
||||
type SandboxLoginDriver,
|
||||
} from "@paperclipai/adapter-codex-local/server";
|
||||
import {
|
||||
createLoginPtyTransport,
|
||||
type LoginPtySessionOpener,
|
||||
} from "@paperclipai/adapter-utils/login-pty-transport";
|
||||
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
|
||||
import { buildLoginLeaseAcquireArgs } from "./adapter-login-lease.js";
|
||||
import { environmentService } from "./environments.js";
|
||||
import { runDescriptorBoundAuthRead } from "./codex-device-login-credential-read.js";
|
||||
import {
|
||||
resolveLoginCommandKey,
|
||||
validateLoginSessionHome,
|
||||
type LoginCommandKey,
|
||||
} from "./login-command.js";
|
||||
import type { LoginPtyWorkerManagerLike } from "./setup-token-transport-binding.js";
|
||||
|
||||
// The login-session service. It creates a login session, acquires a fresh
|
||||
// sandbox lease, runs `codex login --device-auth` through the runner, and owns
|
||||
|
|
@ -38,6 +49,17 @@ import { environmentService } from "./environments.js";
|
|||
/** The host timeout for the sandbox login command. It is exactly five minutes. */
|
||||
export const CODEX_DEVICE_LOGIN_TIMEOUT_MS = 300_000;
|
||||
|
||||
// The fixed error for a sandbox provider that does not advertise the login
|
||||
// pseudo-terminal capability. The Codex device login runs the login command on a
|
||||
// real pseudo-terminal, so only a provider that advertises the capability can
|
||||
// host the login. The route returns this specific, typed error and starts no
|
||||
// session, so an unsupported provider never reaches a session row, a lease, or a
|
||||
// pseudo-terminal.
|
||||
export const CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED =
|
||||
"The sandbox provider does not support the Codex device login.";
|
||||
export const CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE =
|
||||
"codex_device_login_provider_unsupported";
|
||||
|
||||
/**
|
||||
* The lease-metadata key that tags a login sandbox lease with its session
|
||||
* identifier. The runtime stamps it at acquisition. The reaper reads it to find
|
||||
|
|
@ -1151,80 +1173,197 @@ export function sessionCredentialPath(sessionId: string): string {
|
|||
|
||||
/**
|
||||
* Build the sandbox login driver. This is the one helper the production runtime
|
||||
* and the tests share. It binds streamed execution and a credential read to the
|
||||
* environment runtime.
|
||||
* and the tests share. It runs the login command over the shared login
|
||||
* pseudo-terminal (PTY) transport and reads the credential with one
|
||||
* descriptor-bound read.
|
||||
*
|
||||
* - `execStreaming` sets an empty session-specific Codex home before the fixed
|
||||
* login command runs, exports `CODEX_HOME`, and streams standard output to the
|
||||
* runner. It passes the command through `sh -c`, so the runtime runs the whole
|
||||
* compound command in one shell. It sets `forceSession`, so the command opens
|
||||
* the persistent session and streams each standard-output chunk while the
|
||||
* login command waits for the user. A one-shot exec returns the output only at
|
||||
* the end, so the login prompt would never reach the user in time.
|
||||
* - `readFile` reads the credential from the fixed session-specific path with a
|
||||
* one-shot `cat` command. No caller controls the path.
|
||||
* - `dispose` is a no-op. The service owns the sandbox delete through a separate
|
||||
* seam, so the runner's swallowed dispose must not delete the sandbox.
|
||||
* - `start` runs the fixed login command on a real pseudo-terminal through the
|
||||
* shared {@link createLoginPtyTransport} and streams each terminal output chunk
|
||||
* to the runner while the login command waits for the user. The login command
|
||||
* needs a pseudo-terminal: pipe stdio emits no login prompt. The pseudo-terminal
|
||||
* opener sets the session-specific `CODEX_HOME` to the same verified session
|
||||
* home this driver reads.
|
||||
* - `readFile` runs one descriptor-bound read. It opens the verified session home
|
||||
* and the fixed credential file with no symlink follow, checks the opened
|
||||
* descriptor, and reads only from that same descriptor. It ignores the path the
|
||||
* runner passes, so no caller controls the read target. It reads the fixed
|
||||
* `auth.json` under the server-controlled session home.
|
||||
* - `dispose` releases the pseudo-terminal transport, so the host frees the login
|
||||
* pseudo-terminal slot. The service owns the sandbox delete through a separate
|
||||
* seam, so this dispose never deletes the sandbox.
|
||||
*
|
||||
* The runtime passes `command` and `args` to the provider as a program and its
|
||||
* argument vector. The provider quotes each element as one shell token. So a
|
||||
* compound command must run through a shell (`sh -c "<script>"`); a bare
|
||||
* compound string in `command` becomes one token and never runs.
|
||||
* The session home is server-controlled and shared: the pseudo-terminal opener
|
||||
* sets `CODEX_HOME` to it, and the descriptor-bound read opens it. So the login
|
||||
* command writes `auth.json` into the exact directory the read opens.
|
||||
*/
|
||||
export function buildSandboxLoginDriver(deps: {
|
||||
openPtySession: LoginPtySessionOpener;
|
||||
environmentRuntime: Pick<EnvironmentRuntimeService, "execute">;
|
||||
environment: Environment;
|
||||
lease: EnvironmentLease;
|
||||
sessionHome: string;
|
||||
timeoutMs: number;
|
||||
}): SandboxLoginDriver {
|
||||
const { environmentRuntime, environment, lease, sessionHome, timeoutMs } = deps;
|
||||
const { openPtySession, environmentRuntime, environment, lease, sessionHome, timeoutMs } = deps;
|
||||
const transport = createLoginPtyTransport(openPtySession);
|
||||
return {
|
||||
async execStreaming(command, onStdout) {
|
||||
const script = `rm -rf ${sessionHome} && mkdir -p ${sessionHome} && CODEX_HOME=${sessionHome} ${command}`;
|
||||
const result = await environmentRuntime.execute({
|
||||
environment,
|
||||
lease,
|
||||
command: "sh",
|
||||
args: ["-c", script],
|
||||
timeoutMs,
|
||||
// Open the persistent session, so the runtime streams each output chunk
|
||||
// to the runner while the login command waits. A one-shot exec returns
|
||||
// the output only at the end, so the prompt would arrive too late.
|
||||
forceSession: true,
|
||||
onLog: (stream, chunk) => {
|
||||
if (stream === "stdout") onStdout(chunk);
|
||||
},
|
||||
});
|
||||
return { exitCode: result.exitCode };
|
||||
async start(command, onData) {
|
||||
return transport.start(command, onData);
|
||||
},
|
||||
async readFile(path) {
|
||||
const result = await environmentRuntime.execute({
|
||||
async readFile(_path) {
|
||||
// The read ignores the runner path. It runs one fixed, server-controlled,
|
||||
// descriptor-bound operation against the verified session home. So a swap of
|
||||
// the credential file between a check and the read cannot steer the read.
|
||||
return runDescriptorBoundAuthRead({
|
||||
environmentRuntime,
|
||||
environment,
|
||||
lease,
|
||||
command: "cat",
|
||||
args: [path],
|
||||
sessionHome,
|
||||
timeoutMs,
|
||||
bypassSession: true,
|
||||
});
|
||||
return Buffer.from(result.stdout ?? "", "utf8");
|
||||
},
|
||||
async dispose() {
|
||||
// The service owns the sandbox delete. This dispose is a no-op.
|
||||
// Free the host pseudo-terminal slot before the service deletes the sandbox.
|
||||
// The service owns the sandbox delete through a separate seam.
|
||||
await transport.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The verified binding one login session hands to the live pseudo-terminal
|
||||
* opener. The opener sets the sandbox `CODEX_HOME` to `sessionHome`, so the login
|
||||
* command writes `auth.json` into the exact directory the descriptor-bound read
|
||||
* opens.
|
||||
*/
|
||||
export interface LoginPtySessionBinding {
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
adapterType: AgentAdapterType;
|
||||
/** The provider lease that binds the sandbox worker. */
|
||||
providerLeaseId: string;
|
||||
/** The server-controlled session home. The opener sets `CODEX_HOME` to it. */
|
||||
sessionHome: string;
|
||||
/** The resolved environment for the acquired lease. */
|
||||
environment: Environment;
|
||||
/** The acquired environment lease. */
|
||||
lease: EnvironmentLease;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the live pseudo-terminal for one login session and returns the shared
|
||||
* transport opener. The Daytona provider runs as a plugin worker, so the real
|
||||
* opener binds inside the worker through the plugin worker manager route. A test
|
||||
* injects a fake opener to drive the full session path.
|
||||
*/
|
||||
export type OpenLoginPtySession = (
|
||||
binding: LoginPtySessionBinding,
|
||||
) => Promise<LoginPtySessionOpener>;
|
||||
|
||||
/** The fixed, non-secret error the Codex live opener throws when it cannot bind
|
||||
* the sandbox worker route. It carries no lease detail and no secret. */
|
||||
const CODEX_LOGIN_PTY_BIND_FAILED =
|
||||
"device login failed: the sandbox pseudo-terminal transport is not bound.";
|
||||
|
||||
/** The dependencies the worker-bound Codex live pseudo-terminal opener needs. */
|
||||
export interface CodexWorkerBoundLoginPtyOpenerDeps {
|
||||
/** The plugin worker manager that owns the host route gate. */
|
||||
workerManager: LoginPtyWorkerManagerLike;
|
||||
/** A non-leaking status sink. It receives only fixed status lines. */
|
||||
log?: (line: string) => void;
|
||||
}
|
||||
|
||||
function readLeaseMetaString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the production Codex `openLivePtySession`. It drives the sandbox worker
|
||||
* through the manager route gate. The manager mints the host route identifier and
|
||||
* owns the route lifecycle.
|
||||
*
|
||||
* The opener passes the binding's server-controlled `sessionHome` to the worker
|
||||
* route. That home is the exact directory the descriptor-bound credential read
|
||||
* opens, so the sandbox `CODEX_HOME` and the read target match. The opener never
|
||||
* derives a fresh home: a fresh home would set `CODEX_HOME` to a directory the
|
||||
* read never opens, and every Codex login would fail closed.
|
||||
*
|
||||
* The opener resolves the closed login command key from the trusted adapter type,
|
||||
* never from the caller. It validates the session home shape before the worker
|
||||
* RPC. It fails closed when the lease carries no sandbox worker binding.
|
||||
*/
|
||||
export function createCodexWorkerBoundLoginPtyOpener(
|
||||
deps: CodexWorkerBoundLoginPtyOpenerDeps,
|
||||
): OpenLoginPtySession {
|
||||
const log = deps.log ?? (() => {});
|
||||
return async (binding) => {
|
||||
const metadata =
|
||||
binding.lease.metadata && typeof binding.lease.metadata === "object"
|
||||
? (binding.lease.metadata as Record<string, unknown>)
|
||||
: {};
|
||||
const pluginId = readLeaseMetaString(metadata.pluginId);
|
||||
const driverKey =
|
||||
readLeaseMetaString(metadata.provider) ?? readLeaseMetaString(metadata.driver);
|
||||
if (!binding.providerLeaseId || !pluginId || !driverKey) {
|
||||
log("[paperclip] Device login: the lease carries no sandbox worker binding.");
|
||||
throw new Error(CODEX_LOGIN_PTY_BIND_FAILED);
|
||||
}
|
||||
// Resolve the closed command key from the trusted adapter type. An unmapped
|
||||
// adapter fails closed before the worker RPC.
|
||||
let loginCommandKey: LoginCommandKey;
|
||||
try {
|
||||
loginCommandKey = resolveLoginCommandKey(binding.adapterType);
|
||||
} catch {
|
||||
log("[paperclip] Device login: the adapter type has no login command key.");
|
||||
throw new Error(CODEX_LOGIN_PTY_BIND_FAILED);
|
||||
}
|
||||
// Validate the server-controlled session home shape before the worker RPC.
|
||||
// The runtime derived it from the session id; the manager revalidates it too.
|
||||
validateLoginSessionHome(binding.sessionHome);
|
||||
// The opener argument is the runner's fixed command string. It confers no
|
||||
// command authority, so the opener ignores it and returns a host-bound opener.
|
||||
return (_command: string) =>
|
||||
deps.workerManager.openLoginPtySession(pluginId, {
|
||||
driverKey,
|
||||
companyId: binding.companyId,
|
||||
environmentId: binding.environmentId,
|
||||
providerLeaseId: binding.providerLeaseId,
|
||||
loginCommandKey,
|
||||
sessionHome: binding.sessionHome,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProductionLoginSessionRuntimeDeps {
|
||||
db: Db;
|
||||
environmentRuntime: EnvironmentRuntimeService;
|
||||
/**
|
||||
* Re-checks the provider login pseudo-terminal capability from current runtime
|
||||
* state, immediately before the provider lease. The route gate already ran, so
|
||||
* a managed reconciliation can rebind the environment to an unsupported
|
||||
* provider between the route gate and this acquire. The check reads the current
|
||||
* provider capability, not the stale route decision. It throws the fixed
|
||||
* unsupported-provider error, so the runtime creates no lease and opens no
|
||||
* pseudo-terminal for an unsupported provider.
|
||||
*/
|
||||
assertProviderSupportsLoginPty: (environmentId: string) => Promise<void>;
|
||||
/**
|
||||
* Opens the live pseudo-terminal for the acquired lease. When a caller omits
|
||||
* it, the runtime binds a fail-closed opener: the login run then fails and the
|
||||
* service deletes the sandbox. The live opener binds the sandbox worker route.
|
||||
*/
|
||||
openLivePtySession?: OpenLoginPtySession;
|
||||
}
|
||||
|
||||
/** The fixed, non-secret error the fail-closed opener throws when no live
|
||||
* pseudo-terminal opener is bound. */
|
||||
const LOGIN_PTY_OPENER_UNBOUND = "device login failed: the sandbox pseudo-terminal transport is not bound.";
|
||||
|
||||
/**
|
||||
* Build the production login-session runtime. It acquires a fresh lease with
|
||||
* reuse disabled (no heartbeat run, no execution workspace) and the active
|
||||
* custom-image template applied, binds the sandbox login driver, and owns the
|
||||
* delete and release seams.
|
||||
* custom-image template applied, binds the sandbox login driver over the shared
|
||||
* pseudo-terminal transport, and owns the delete and release seams.
|
||||
*/
|
||||
export function createProductionLoginSessionRuntime(
|
||||
deps: ProductionLoginSessionRuntimeDeps,
|
||||
|
|
@ -1236,6 +1375,14 @@ export function createProductionLoginSessionRuntime(
|
|||
if (!environment) {
|
||||
throw new Error(`Environment "${input.environmentId}" is not found.`);
|
||||
}
|
||||
// Re-check the provider login pseudo-terminal capability from current
|
||||
// runtime state, immediately before the provider lease. A managed
|
||||
// reconciliation can rebind the environment to an unsupported provider
|
||||
// between the route gate and this acquire, so the check reads the current
|
||||
// capability, not the stale route decision. It throws the fixed
|
||||
// unsupported-provider error, so the runtime acquires no lease and opens no
|
||||
// pseudo-terminal for an unsupported provider.
|
||||
await deps.assertProviderSupportsLoginPty(input.environmentId);
|
||||
const record = await deps.environmentRuntime.acquireRunLease(
|
||||
buildLoginLeaseAcquireArgs({
|
||||
metadata: {
|
||||
|
|
@ -1254,7 +1401,23 @@ export function createProductionLoginSessionRuntime(
|
|||
const sessionHome = sessionCodexHomePath(input.sessionId);
|
||||
const authPath = sessionCredentialPath(input.sessionId);
|
||||
const providerLeaseId = record.lease.providerLeaseId ?? record.lease.id;
|
||||
// Resolve the live pseudo-terminal opener for this lease. When no live
|
||||
// opener is bound, use a fail-closed opener: the login run then fails and
|
||||
// the service deletes the sandbox. The opener sets `CODEX_HOME` to the same
|
||||
// `sessionHome` the descriptor-bound read opens.
|
||||
const openPtySession: LoginPtySessionOpener = deps.openLivePtySession
|
||||
? await deps.openLivePtySession({
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environmentId,
|
||||
adapterType: input.adapterType,
|
||||
providerLeaseId,
|
||||
sessionHome,
|
||||
environment: record.environment,
|
||||
lease: record.lease,
|
||||
})
|
||||
: () => Promise.reject(new Error(LOGIN_PTY_OPENER_UNBOUND));
|
||||
const driver = buildSandboxLoginDriver({
|
||||
openPtySession,
|
||||
environmentRuntime: deps.environmentRuntime,
|
||||
environment: record.environment,
|
||||
lease: record.lease,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveLoginSessionHome,
|
||||
isLoginCommandKey,
|
||||
isLoginCommandSupportedAdapterType,
|
||||
LOGIN_SESSION_HOME_ROOT,
|
||||
resolveLoginCommandKey,
|
||||
validateLoginSessionHome,
|
||||
} from "./login-command.js";
|
||||
|
||||
const UUID = "11111111-2222-4333-8444-555555555555";
|
||||
|
||||
describe("resolveLoginCommandKey", () => {
|
||||
it("resolves the trusted adapter type to the closed key", () => {
|
||||
expect(resolveLoginCommandKey("claude_local")).toBe("claude");
|
||||
expect(resolveLoginCommandKey("codex_local")).toBe("codex");
|
||||
});
|
||||
|
||||
it("fails closed for an unmapped adapter type", () => {
|
||||
// The provider driver key and an unknown adapter confer no command authority.
|
||||
expect(() => resolveLoginCommandKey("daytona")).toThrow("LOGIN_PTY_UNSUPPORTED_ADAPTER");
|
||||
expect(() => resolveLoginCommandKey("gemini_local")).toThrow("LOGIN_PTY_UNSUPPORTED_ADAPTER");
|
||||
expect(() => resolveLoginCommandKey("")).toThrow("LOGIN_PTY_UNSUPPORTED_ADAPTER");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLoginCommandKey", () => {
|
||||
it("accepts only the closed key set", () => {
|
||||
expect(isLoginCommandKey("claude")).toBe(true);
|
||||
expect(isLoginCommandKey("codex")).toBe(true);
|
||||
expect(isLoginCommandKey("gemini")).toBe(false);
|
||||
expect(isLoginCommandKey("rm -rf /")).toBe(false);
|
||||
expect(isLoginCommandKey(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLoginCommandSupportedAdapterType", () => {
|
||||
it("accepts only the adapter types the closed command map holds", () => {
|
||||
// The admission guard reads this predicate, so it must match the map that
|
||||
// the opener resolves. The mapped types pass; an unmapped type fails closed.
|
||||
expect(isLoginCommandSupportedAdapterType("claude_local")).toBe(true);
|
||||
expect(isLoginCommandSupportedAdapterType("codex_local")).toBe(true);
|
||||
expect(isLoginCommandSupportedAdapterType("gemini_local")).toBe(false);
|
||||
expect(isLoginCommandSupportedAdapterType("daytona")).toBe(false);
|
||||
expect(isLoginCommandSupportedAdapterType("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveLoginSessionHome", () => {
|
||||
it("derives the exact UUID-form absolute path", () => {
|
||||
expect(deriveLoginSessionHome(UUID)).toBe(`${LOGIN_SESSION_HOME_ROOT}/${UUID}`);
|
||||
});
|
||||
|
||||
it("rejects a non-UUID id", () => {
|
||||
expect(() => deriveLoginSessionHome("not-a-uuid")).toThrow("LOGIN_PTY_INVALID_SESSION_HOME");
|
||||
expect(() => deriveLoginSessionHome("")).toThrow("LOGIN_PTY_INVALID_SESSION_HOME");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateLoginSessionHome", () => {
|
||||
it("accepts the exact derived path shape", () => {
|
||||
expect(() => validateLoginSessionHome(`${LOGIN_SESSION_HOME_ROOT}/${UUID}`)).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects an empty, relative, traversal, whitespace, control, or metacharacter candidate", () => {
|
||||
const candidates = [
|
||||
"",
|
||||
"paperclip-adapter-login/" + UUID,
|
||||
`${LOGIN_SESSION_HOME_ROOT}/../${UUID}`,
|
||||
`${LOGIN_SESSION_HOME_ROOT}/${UUID}/..`,
|
||||
`${LOGIN_SESSION_HOME_ROOT}/ ${UUID}`,
|
||||
`${LOGIN_SESSION_HOME_ROOT}/${UUID} `,
|
||||
`${LOGIN_SESSION_HOME_ROOT}/${UUID}\n`,
|
||||
`${LOGIN_SESSION_HOME_ROOT}/${UUID}\t`,
|
||||
`${LOGIN_SESSION_HOME_ROOT}/${UUID}\0`,
|
||||
`${LOGIN_SESSION_HOME_ROOT}/${UUID};rm -rf /`,
|
||||
`${LOGIN_SESSION_HOME_ROOT}/$(id)`,
|
||||
`${LOGIN_SESSION_HOME_ROOT}/${UUID}&&whoami`,
|
||||
`/tmp/other/${UUID}`,
|
||||
`${LOGIN_SESSION_HOME_ROOT}/NOTHEX11-2222-4333-8444-555555555555`,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
expect(() => validateLoginSessionHome(candidate), candidate).toThrow(
|
||||
"LOGIN_PTY_INVALID_SESSION_HOME",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
// The host-owned login command contract for the shared login pseudo-terminal
|
||||
// (PTY). The shared login PTY runs one fixed command. The host selects that
|
||||
// command from a closed internal key, never from a request, a configuration, a
|
||||
// plugin manifest, or the worker routing key. This module holds the closed key
|
||||
// union, the exhaustive resolver from the trusted adapter type, and the
|
||||
// server-controlled session home helpers.
|
||||
//
|
||||
// Security boundary: the key is the only command authority. The provider driver
|
||||
// key routes the worker; it confers no command authority. A caller cannot pass a
|
||||
// command, so the sandbox pseudo-terminal never runs an arbitrary process.
|
||||
|
||||
/**
|
||||
* The closed set of login command identities. The host resolves the key from the
|
||||
* trusted adapter type. The worker maps the key to a compile-time command. The
|
||||
* union is exhaustive: a value outside it fails closed before the worker RPC.
|
||||
*/
|
||||
export type LoginCommandKey = "claude" | "codex";
|
||||
|
||||
/**
|
||||
* The exhaustive map from the trusted adapter type to the login command key. The
|
||||
* host reads only this map. It never reads the key from a request, a
|
||||
* configuration, a plugin manifest, or the provider driver key.
|
||||
*/
|
||||
const ADAPTER_TYPE_TO_LOGIN_COMMAND_KEY: Readonly<Record<string, LoginCommandKey>> = {
|
||||
claude_local: "claude",
|
||||
codex_local: "codex",
|
||||
};
|
||||
|
||||
/** The fixed non-secret error an unsupported adapter type returns. */
|
||||
export const LOGIN_COMMAND_UNSUPPORTED_ADAPTER = "LOGIN_PTY_UNSUPPORTED_ADAPTER";
|
||||
/** The fixed non-secret error an invalid session home returns. */
|
||||
export const LOGIN_SESSION_HOME_INVALID = "LOGIN_PTY_INVALID_SESSION_HOME";
|
||||
|
||||
/**
|
||||
* Resolves the login command key from the trusted adapter type. It reads only the
|
||||
* exhaustive first-party map. It throws the fixed non-secret error for an adapter
|
||||
* type that has no mapped key, so an unknown adapter fails closed before the
|
||||
* worker RPC.
|
||||
*/
|
||||
export function resolveLoginCommandKey(adapterType: string): LoginCommandKey {
|
||||
const key = ADAPTER_TYPE_TO_LOGIN_COMMAND_KEY[adapterType];
|
||||
if (!key) {
|
||||
throw new Error(LOGIN_COMMAND_UNSUPPORTED_ADAPTER);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/** 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";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether the trusted adapter type maps to a login command key. The
|
||||
* admission guard reads this predicate, so an adapter type with no mapped key
|
||||
* fails closed at admission. The device login opener resolves the same closed
|
||||
* map later, so admission and command resolution stay consistent.
|
||||
*/
|
||||
export function isLoginCommandSupportedAdapterType(adapterType: string): boolean {
|
||||
return ADAPTER_TYPE_TO_LOGIN_COMMAND_KEY[adapterType] !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fixed root for a login session home. The server derives one per-session
|
||||
* home under this root. The path shape is exact, so the provider revalidates it
|
||||
* before it touches the filesystem.
|
||||
*/
|
||||
export const LOGIN_SESSION_HOME_ROOT = "/tmp/paperclip-adapter-login";
|
||||
|
||||
/** Matches one lowercase-hyphenated UUID (version 4 layout not enforced). */
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
/**
|
||||
* 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 path
|
||||
* traversal, whitespace, a control character, and a shell metacharacter, because
|
||||
* none of those match the UUID or the fixed root.
|
||||
*/
|
||||
const SESSION_HOME_PATTERN = new RegExp(
|
||||
`^${LOGIN_SESSION_HOME_ROOT.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Derives the session home from a session UUID. It builds the exact
|
||||
* `/tmp/paperclip-adapter-login/<uuid>` path. It throws the fixed non-secret
|
||||
* error when the id is not a UUID, so a malformed id never becomes a home.
|
||||
*/
|
||||
export function deriveLoginSessionHome(sessionUuid: string): string {
|
||||
if (!UUID_PATTERN.test(sessionUuid)) {
|
||||
throw new Error(LOGIN_SESSION_HOME_INVALID);
|
||||
}
|
||||
return `${LOGIN_SESSION_HOME_ROOT}/${sessionUuid}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the exact session home path shape. It throws the fixed non-secret
|
||||
* error for an empty, a relative, a traversal, a whitespace, a control-character,
|
||||
* or a shell-metacharacter candidate. The server validates before the worker RPC;
|
||||
* the provider revalidates before the filesystem operation.
|
||||
*/
|
||||
export function validateLoginSessionHome(sessionHome: string): void {
|
||||
if (!SESSION_HOME_PATTERN.test(sessionHome)) {
|
||||
throw new Error(LOGIN_SESSION_HOME_INVALID);
|
||||
}
|
||||
}
|
||||
|
|
@ -37,8 +37,8 @@ import {
|
|||
isJsonRpcSuccessResponse,
|
||||
JsonRpcParseError,
|
||||
JsonRpcCallError,
|
||||
SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION,
|
||||
SETUP_TOKEN_PTY_EXIT_NOTIFICATION,
|
||||
LOGIN_PTY_OUTPUT_NOTIFICATION,
|
||||
LOGIN_PTY_EXIT_NOTIFICATION,
|
||||
DUPLEX_CHANNEL_DATA_NOTIFICATION,
|
||||
DUPLEX_CHANNEL_EXIT_NOTIFICATION,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
|
|
@ -63,7 +63,11 @@ import {
|
|||
type DuplexAggregateTokenOwner,
|
||||
type ReservationToken,
|
||||
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
|
||||
import { CLAUDE_SETUP_TOKEN_COMMAND } from "@paperclipai/adapter-claude-local/server";
|
||||
import {
|
||||
isLoginCommandKey,
|
||||
validateLoginSessionHome,
|
||||
type LoginCommandKey,
|
||||
} from "./login-command.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { traceparentFromContextToken } from "../instrumentation.js";
|
||||
|
||||
|
|
@ -160,24 +164,24 @@ const MAX_WORKER_MESSAGE_CHARS = 128 * 1024 * 1024;
|
|||
const MAX_EXECUTE_LOG_TOTAL_CHARS = 128 * 1024 * 1024;
|
||||
|
||||
/** Maximum characters for one live login pseudo-terminal output notification. */
|
||||
const MAX_SETUP_TOKEN_PTY_CHUNK_CHARS = 1_000_000;
|
||||
const MAX_LOGIN_PTY_CHUNK_CHARS = 1_000_000;
|
||||
/** Maximum cumulative output characters for one login pseudo-terminal route. */
|
||||
const MAX_SETUP_TOKEN_PTY_TOTAL_CHARS = 8 * 1024 * 1024;
|
||||
const MAX_LOGIN_PTY_TOTAL_CHARS = 8 * 1024 * 1024;
|
||||
/** The default open timeout for one login pseudo-terminal route, in milliseconds. */
|
||||
const SETUP_TOKEN_PTY_OPEN_TIMEOUT_MS = 30_000;
|
||||
const LOGIN_PTY_OPEN_TIMEOUT_MS = 30_000;
|
||||
/** The default close timeout for one login pseudo-terminal route, in milliseconds. */
|
||||
const SETUP_TOKEN_PTY_CLOSE_TIMEOUT_MS = 10_000;
|
||||
const LOGIN_PTY_CLOSE_TIMEOUT_MS = 10_000;
|
||||
/**
|
||||
* The fixed non-secret error a disallowed login command returns. The manager
|
||||
* forwards only the compile-time `CLAUDE_SETUP_TOKEN_COMMAND` to the worker
|
||||
* pseudo-terminal. It rejects any other command before the worker call, so a
|
||||
* future caller cannot spawn an arbitrary process in the sandbox.
|
||||
* The fixed non-secret error a disallowed login command key returns. The manager
|
||||
* forwards only a closed login command key to the worker pseudo-terminal. It
|
||||
* rejects a key outside the closed set before the worker call, so a caller cannot
|
||||
* select an arbitrary command in the sandbox.
|
||||
*/
|
||||
const SETUP_TOKEN_PTY_COMMAND_NOT_ALLOWED = "SETUP_TOKEN_PTY_COMMAND_NOT_ALLOWED";
|
||||
const LOGIN_PTY_COMMAND_NOT_ALLOWED = "LOGIN_PTY_COMMAND_NOT_ALLOWED";
|
||||
/** The fixed non-secret error a rejected second credential open returns. */
|
||||
const SETUP_TOKEN_PTY_ROUTE_BUSY = "SETUP_TOKEN_PTY_ROUTE_BUSY";
|
||||
const LOGIN_PTY_ROUTE_BUSY = "LOGIN_PTY_ROUTE_BUSY";
|
||||
/** The fixed non-secret error a failed open returns. */
|
||||
const SETUP_TOKEN_PTY_OPEN_FAILED = "SETUP_TOKEN_PTY_OPEN_FAILED";
|
||||
const LOGIN_PTY_OPEN_FAILED = "LOGIN_PTY_OPEN_FAILED";
|
||||
|
||||
// Bounds and timeouts for the generic duplex channel route. The route mirrors the
|
||||
// login pseudo-terminal route, but it carries no command allowlist and adds seven
|
||||
|
|
@ -461,7 +465,7 @@ export interface WorkerStartOptions {
|
|||
* the open and the close timeouts. A test overrides them to exercise the
|
||||
* terminalize paths without huge inputs or long waits.
|
||||
*/
|
||||
setupTokenPtyLimits?: {
|
||||
loginPtyLimits?: {
|
||||
/** Max characters for one login pseudo-terminal output notification. */
|
||||
maxChunkChars?: number;
|
||||
/** Max cumulative output characters for one login pseudo-terminal route. */
|
||||
|
|
@ -542,24 +546,33 @@ export type ExecuteLogSink = (
|
|||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* The input the manager needs to open one live login pseudo-terminal route
|
||||
* The manager mints the host route identifier; the caller supplies
|
||||
* only the sandbox scope, the provider lease id, and the fixed command.
|
||||
* The input the manager needs to open one live login pseudo-terminal route. The
|
||||
* manager mints the host route identifier; the caller supplies the sandbox scope,
|
||||
* the provider lease id, and the host-resolved launch descriptor. The launch
|
||||
* descriptor carries a closed command key and a validated session home. It
|
||||
* carries no command string, so a caller cannot select the command.
|
||||
*/
|
||||
export interface SetupTokenPtyOpenInput {
|
||||
export interface LoginPtyOpenInput {
|
||||
/** The environment driver key. It routes the worker; it confers no command authority. */
|
||||
driverKey: string;
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
providerLeaseId: string;
|
||||
command: string;
|
||||
/** The host-resolved fixed command identity. The worker maps it to a compile-time command. */
|
||||
loginCommandKey: LoginCommandKey;
|
||||
/**
|
||||
* The server-controlled, validated session home. The shape is exact:
|
||||
* `/tmp/paperclip-adapter-login/<uuid>`.
|
||||
*/
|
||||
sessionHome: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One live login pseudo-terminal session the manager hands to the login
|
||||
* transport. The shape matches the sandbox provider setup-token
|
||||
* transport. The shape matches the sandbox provider login
|
||||
* pseudo-terminal session, so the transport consumes it with no adapter.
|
||||
*/
|
||||
export interface SetupTokenPtyHostSession {
|
||||
export interface LoginPtyHostSession {
|
||||
/** 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. */
|
||||
|
|
@ -701,9 +714,9 @@ export interface PluginWorkerHandle {
|
|||
* binds the worker session identifier one time, and returns a session the login
|
||||
* transport drives. It permits one active credential pseudo-terminal per worker.
|
||||
*/
|
||||
openSetupTokenPtySession(
|
||||
input: SetupTokenPtyOpenInput,
|
||||
): Promise<SetupTokenPtyHostSession>;
|
||||
openLoginPtySession(
|
||||
input: LoginPtyOpenInput,
|
||||
): Promise<LoginPtyHostSession>;
|
||||
|
||||
/**
|
||||
* Open one generic duplex channel on this worker. The manager mints the host
|
||||
|
|
@ -822,14 +835,14 @@ export interface PluginWorkerManager {
|
|||
|
||||
/**
|
||||
* Open one live login pseudo-terminal route on a specific plugin worker
|
||||
* See {@link PluginWorkerHandle.openSetupTokenPtySession}.
|
||||
* See {@link PluginWorkerHandle.openLoginPtySession}.
|
||||
*
|
||||
* @throws if the worker is not registered.
|
||||
*/
|
||||
openSetupTokenPtySession(
|
||||
openLoginPtySession(
|
||||
pluginId: string,
|
||||
input: SetupTokenPtyOpenInput,
|
||||
): Promise<SetupTokenPtyHostSession>;
|
||||
input: LoginPtyOpenInput,
|
||||
): Promise<LoginPtyHostSession>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -890,14 +903,14 @@ export function createPluginWorkerHandle(
|
|||
|
||||
// Bounds and timeouts for the login pseudo-terminal route. A caller
|
||||
// (a test) can lower them to exercise the terminalize paths.
|
||||
const maxSetupTokenPtyChunkChars =
|
||||
options.setupTokenPtyLimits?.maxChunkChars ?? MAX_SETUP_TOKEN_PTY_CHUNK_CHARS;
|
||||
const maxSetupTokenPtyTotalChars =
|
||||
options.setupTokenPtyLimits?.maxTotalChars ?? MAX_SETUP_TOKEN_PTY_TOTAL_CHARS;
|
||||
const setupTokenPtyOpenTimeoutMs =
|
||||
options.setupTokenPtyLimits?.openTimeoutMs ?? SETUP_TOKEN_PTY_OPEN_TIMEOUT_MS;
|
||||
const setupTokenPtyCloseTimeoutMs =
|
||||
options.setupTokenPtyLimits?.closeTimeoutMs ?? SETUP_TOKEN_PTY_CLOSE_TIMEOUT_MS;
|
||||
const maxLoginPtyChunkChars =
|
||||
options.loginPtyLimits?.maxChunkChars ?? MAX_LOGIN_PTY_CHUNK_CHARS;
|
||||
const maxLoginPtyTotalChars =
|
||||
options.loginPtyLimits?.maxTotalChars ?? MAX_LOGIN_PTY_TOTAL_CHARS;
|
||||
const loginPtyOpenTimeoutMs =
|
||||
options.loginPtyLimits?.openTimeoutMs ?? LOGIN_PTY_OPEN_TIMEOUT_MS;
|
||||
const loginPtyCloseTimeoutMs =
|
||||
options.loginPtyLimits?.closeTimeoutMs ?? LOGIN_PTY_CLOSE_TIMEOUT_MS;
|
||||
|
||||
// Bounds and timeouts for the generic duplex channel route. A caller (a test)
|
||||
// can lower them to exercise each bound and the terminalize paths.
|
||||
|
|
@ -1320,7 +1333,7 @@ export function createPluginWorkerHandle(
|
|||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Host-owned setup-token login pseudo-terminal route gate
|
||||
// Host-owned login pseudo-terminal route gate
|
||||
// -----------------------------------------------------------------------
|
||||
// The manager owns one live login pseudo-terminal route per worker. It mints a
|
||||
// host-owned opaque route identifier, carries it in the open call, and keys the
|
||||
|
|
@ -1370,10 +1383,10 @@ export function createPluginWorkerHandle(
|
|||
return workerSessionId;
|
||||
}
|
||||
|
||||
type SetupTokenPtyRouteState = RouteState;
|
||||
interface SetupTokenPtyRoute {
|
||||
type LoginPtyRouteState = RouteState;
|
||||
interface LoginPtyRoute {
|
||||
hostRouteId: string;
|
||||
state: SetupTokenPtyRouteState;
|
||||
state: LoginPtyRouteState;
|
||||
workerSessionId: string | null;
|
||||
listener: ((chunk: string) => void) | null;
|
||||
buffered: string[];
|
||||
|
|
@ -1383,19 +1396,19 @@ export function createPluginWorkerHandle(
|
|||
}
|
||||
// At most one active credential pseudo-terminal per worker. A non-null route
|
||||
// blocks a second open until the manager confirms the first route's close.
|
||||
let setupTokenPtyRoute: SetupTokenPtyRoute | null = null;
|
||||
let loginPtyRoute: LoginPtyRoute | null = null;
|
||||
|
||||
// Close the worker terminal by the host route identifier and verify the bound
|
||||
// acknowledgement. Return true only when the worker returns an acknowledgement
|
||||
// that carries the exact host route identifier. An absent, malformed,
|
||||
// mismatched, or timed-out acknowledgement returns false, so the caller fails
|
||||
// closed.
|
||||
async function closeSetupTokenPtyTerminal(hostRouteId: string): Promise<boolean> {
|
||||
async function closeLoginPtyTerminal(hostRouteId: string): Promise<boolean> {
|
||||
try {
|
||||
const ack = await callInternal(
|
||||
"setupTokenPtyClose",
|
||||
"loginPtyClose",
|
||||
{ hostRouteId },
|
||||
setupTokenPtyCloseTimeoutMs,
|
||||
loginPtyCloseTimeoutMs,
|
||||
);
|
||||
return isRecord(ack) && readNonEmptyString(ack.hostRouteId) === hostRouteId;
|
||||
} catch {
|
||||
|
|
@ -1406,7 +1419,7 @@ export function createPluginWorkerHandle(
|
|||
// Terminalize the route exactly once. Resolve the login wait, close the worker
|
||||
// terminal by the host route identifier, and free the per-worker slot only
|
||||
// after the close resolves. Retire the worker when the close is unconfirmed.
|
||||
async function terminalizeSetupTokenPtyRoute(route: SetupTokenPtyRoute): Promise<void> {
|
||||
async function terminalizeLoginPtyRoute(route: LoginPtyRoute): Promise<void> {
|
||||
if (route.terminalized) return;
|
||||
route.terminalized = true;
|
||||
route.state = "closed";
|
||||
|
|
@ -1415,14 +1428,14 @@ export function createPluginWorkerHandle(
|
|||
// A terminalized route reports a null exit code, which the runner treats as a
|
||||
// failure.
|
||||
settleRouteWait(route, { exitCode: null });
|
||||
const confirmed = await closeSetupTokenPtyTerminal(route.hostRouteId);
|
||||
if (setupTokenPtyRoute === route) setupTokenPtyRoute = null;
|
||||
const confirmed = await closeLoginPtyTerminal(route.hostRouteId);
|
||||
if (loginPtyRoute === route) loginPtyRoute = null;
|
||||
if (!confirmed) {
|
||||
// The worker did not acknowledge the close, so the host cannot prove the
|
||||
// terminal is gone. Fail closed: retire the worker before any reuse.
|
||||
log.error(
|
||||
{ pluginId },
|
||||
"setup-token login pseudo-terminal close not acknowledged; retiring worker",
|
||||
"login pseudo-terminal close not acknowledged; retiring worker",
|
||||
);
|
||||
void killProcess();
|
||||
}
|
||||
|
|
@ -1432,8 +1445,8 @@ export function createPluginWorkerHandle(
|
|||
// listener. Deliver only while the route is `open` and the notification carries
|
||||
// the exact bound worker session identifier and valid bounded bytes. Drop an
|
||||
// unknown, late, malformed, or mismatched notification. Never log the raw bytes.
|
||||
function routeSetupTokenPtyOutput(notification: JsonRpcNotification): void {
|
||||
const route = setupTokenPtyRoute;
|
||||
function routeLoginPtyOutput(notification: JsonRpcNotification): void {
|
||||
const route = loginPtyRoute;
|
||||
if (!route || route.state !== "open") return;
|
||||
const params = isRecord(notification.params) ? notification.params : {};
|
||||
const workerSessionId = readNonEmptyString(params.workerSessionId);
|
||||
|
|
@ -1442,13 +1455,13 @@ export function createPluginWorkerHandle(
|
|||
if (
|
||||
typeof chunk !== "string" ||
|
||||
chunk.length === 0 ||
|
||||
chunk.length > maxSetupTokenPtyChunkChars
|
||||
chunk.length > maxLoginPtyChunkChars
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (route.deliveredChars + chunk.length > maxSetupTokenPtyTotalChars) {
|
||||
if (route.deliveredChars + chunk.length > maxLoginPtyTotalChars) {
|
||||
// The cumulative output passed the per-route bound. Terminalize the route.
|
||||
void terminalizeSetupTokenPtyRoute(route);
|
||||
void terminalizeLoginPtyRoute(route);
|
||||
return;
|
||||
}
|
||||
route.deliveredChars += chunk.length;
|
||||
|
|
@ -1459,8 +1472,8 @@ export function createPluginWorkerHandle(
|
|||
// Route one login pseudo-terminal exit notification to the login wait. Resolve
|
||||
// only while the route is `open` and the notification carries the exact bound
|
||||
// worker session identifier.
|
||||
function routeSetupTokenPtyExit(notification: JsonRpcNotification): void {
|
||||
const route = setupTokenPtyRoute;
|
||||
function routeLoginPtyExit(notification: JsonRpcNotification): void {
|
||||
const route = loginPtyRoute;
|
||||
if (!route || route.state !== "open") return;
|
||||
const params = isRecord(notification.params) ? notification.params : {};
|
||||
const workerSessionId = readNonEmptyString(params.workerSessionId);
|
||||
|
|
@ -1472,10 +1485,10 @@ export function createPluginWorkerHandle(
|
|||
// Close the one route on a worker exit. The worker is gone, so the manager
|
||||
// resolves the login wait with the fixed non-secret exit and clears the route
|
||||
// one time. The pending pseudo-terminal calls reject through `rejectAllPending`.
|
||||
function closeSetupTokenPtyRouteOnWorkerExit(): void {
|
||||
const route = setupTokenPtyRoute;
|
||||
function closeLoginPtyRouteOnWorkerExit(): void {
|
||||
const route = loginPtyRoute;
|
||||
if (!route) return;
|
||||
setupTokenPtyRoute = null;
|
||||
loginPtyRoute = null;
|
||||
route.terminalized = true;
|
||||
route.state = "closed";
|
||||
route.listener = null;
|
||||
|
|
@ -1487,27 +1500,31 @@ export function createPluginWorkerHandle(
|
|||
// before the open call, bind the worker session identifier one time on the
|
||||
// first successful open reply, and return a session the login transport drives.
|
||||
// Terminalize the route on every open failure path.
|
||||
async function openSetupTokenPtySession(
|
||||
input: SetupTokenPtyOpenInput,
|
||||
): Promise<SetupTokenPtyHostSession> {
|
||||
if (input.command !== CLAUDE_SETUP_TOKEN_COMMAND) {
|
||||
// Allowlist the login command. Only the fixed `CLAUDE_SETUP_TOKEN_COMMAND`
|
||||
// may run in the sandbox pseudo-terminal. Reject any other command with one
|
||||
// fixed non-secret error before the worker call, so a caller cannot spawn
|
||||
// an arbitrary process in the sandbox pseudo-terminal.
|
||||
throw new Error(SETUP_TOKEN_PTY_COMMAND_NOT_ALLOWED);
|
||||
async function openLoginPtySession(
|
||||
input: LoginPtyOpenInput,
|
||||
): Promise<LoginPtyHostSession> {
|
||||
if (!isLoginCommandKey(input.loginCommandKey)) {
|
||||
// Allowlist the login command key. Only a key in the closed set may open a
|
||||
// sandbox pseudo-terminal. Reject a key outside the set with one fixed
|
||||
// non-secret error before the worker call, so a caller cannot select an
|
||||
// arbitrary command in the sandbox pseudo-terminal.
|
||||
throw new Error(LOGIN_PTY_COMMAND_NOT_ALLOWED);
|
||||
}
|
||||
if (setupTokenPtyRoute) {
|
||||
// Revalidate the server-controlled session home shape before the worker RPC.
|
||||
// The binding validated it at the service boundary; this is the last host gate
|
||||
// before the worker call, so a malformed home fails closed here too.
|
||||
validateLoginSessionHome(input.sessionHome);
|
||||
if (loginPtyRoute) {
|
||||
// A route for this worker is not yet closed and confirmed. Reject the
|
||||
// second open with one fixed non-secret error before it reaches the worker.
|
||||
throw new Error(SETUP_TOKEN_PTY_ROUTE_BUSY);
|
||||
throw new Error(LOGIN_PTY_ROUTE_BUSY);
|
||||
}
|
||||
const hostRouteId = randomUUID();
|
||||
let settleWait: (value: { exitCode: number | null }) => void = () => {};
|
||||
const waitPromise = new Promise<{ exitCode: number | null }>((resolve) => {
|
||||
settleWait = resolve;
|
||||
});
|
||||
const route: SetupTokenPtyRoute = {
|
||||
const route: LoginPtyRoute = {
|
||||
hostRouteId,
|
||||
state: "reserved",
|
||||
workerSessionId: null,
|
||||
|
|
@ -1517,36 +1534,37 @@ export function createPluginWorkerHandle(
|
|||
terminalized: false,
|
||||
settleWait,
|
||||
};
|
||||
setupTokenPtyRoute = route;
|
||||
loginPtyRoute = route;
|
||||
|
||||
route.state = "opening";
|
||||
let openResult: HostToWorkerMethods["setupTokenPtyOpen"][1];
|
||||
let openResult: HostToWorkerMethods["loginPtyOpen"][1];
|
||||
try {
|
||||
openResult = await callInternal(
|
||||
"setupTokenPtyOpen",
|
||||
"loginPtyOpen",
|
||||
{
|
||||
hostRouteId,
|
||||
driverKey: input.driverKey,
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environmentId,
|
||||
providerLeaseId: input.providerLeaseId,
|
||||
command: input.command,
|
||||
loginCommandKey: input.loginCommandKey,
|
||||
sessionHome: input.sessionHome,
|
||||
},
|
||||
setupTokenPtyOpenTimeoutMs,
|
||||
loginPtyOpenTimeoutMs,
|
||||
);
|
||||
} catch (err) {
|
||||
// A send failure, an RPC rejection, or an open timeout. Terminalize the
|
||||
// route exactly once and fail closed.
|
||||
await terminalizeSetupTokenPtyRoute(route);
|
||||
throw err instanceof Error ? err : new Error(SETUP_TOKEN_PTY_OPEN_FAILED);
|
||||
await terminalizeLoginPtyRoute(route);
|
||||
throw err instanceof Error ? err : new Error(LOGIN_PTY_OPEN_FAILED);
|
||||
}
|
||||
|
||||
const workerSessionId = readBindableWorkerSessionId(route, openResult);
|
||||
if (!workerSessionId) {
|
||||
// A malformed reply, or a route that already left `opening`. A late or a
|
||||
// duplicate reply never binds, revives, or reopens a route.
|
||||
await terminalizeSetupTokenPtyRoute(route);
|
||||
throw new Error(SETUP_TOKEN_PTY_OPEN_FAILED);
|
||||
await terminalizeLoginPtyRoute(route);
|
||||
throw new Error(LOGIN_PTY_OPEN_FAILED);
|
||||
}
|
||||
// Bind the worker session identifier one time and move the route to `open`.
|
||||
route.workerSessionId = workerSessionId;
|
||||
|
|
@ -1565,9 +1583,9 @@ export function createPluginWorkerHandle(
|
|||
const sid = route.workerSessionId;
|
||||
if (route.state !== "open" || !sid) return;
|
||||
void callInternal(
|
||||
"setupTokenPtyInput",
|
||||
"loginPtyInput",
|
||||
{ workerSessionId: sid, data },
|
||||
setupTokenPtyOpenTimeoutMs,
|
||||
loginPtyOpenTimeoutMs,
|
||||
).catch(() => {});
|
||||
},
|
||||
wait(): Promise<{ exitCode: number | null }> {
|
||||
|
|
@ -1577,13 +1595,13 @@ export function createPluginWorkerHandle(
|
|||
const sid = route.workerSessionId;
|
||||
if (!sid) return;
|
||||
void callInternal(
|
||||
"setupTokenPtyStop",
|
||||
"loginPtyStop",
|
||||
{ workerSessionId: sid },
|
||||
setupTokenPtyOpenTimeoutMs,
|
||||
loginPtyOpenTimeoutMs,
|
||||
).catch(() => {});
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
await terminalizeSetupTokenPtyRoute(route);
|
||||
await terminalizeLoginPtyRoute(route);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -2660,15 +2678,15 @@ export function createPluginWorkerHandle(
|
|||
return;
|
||||
}
|
||||
|
||||
// Setup-token login pseudo-terminal notifications: deliver output
|
||||
// Login pseudo-terminal notifications: deliver output
|
||||
// and the exit to the one host-owned login route, bound by the worker session
|
||||
// identifier while the route is open.
|
||||
if (notification.method === SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION) {
|
||||
routeSetupTokenPtyOutput(notification);
|
||||
if (notification.method === LOGIN_PTY_OUTPUT_NOTIFICATION) {
|
||||
routeLoginPtyOutput(notification);
|
||||
return;
|
||||
}
|
||||
if (notification.method === SETUP_TOKEN_PTY_EXIT_NOTIFICATION) {
|
||||
routeSetupTokenPtyExit(notification);
|
||||
if (notification.method === LOGIN_PTY_EXIT_NOTIFICATION) {
|
||||
routeLoginPtyExit(notification);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2850,7 +2868,7 @@ export function createPluginWorkerHandle(
|
|||
// Close the one login pseudo-terminal route with a fixed non-secret exit and
|
||||
// clear the route one time. The pending pseudo-terminal calls
|
||||
// already rejected through `rejectAllPending`.
|
||||
closeSetupTokenPtyRouteOnWorkerExit();
|
||||
closeLoginPtyRouteOnWorkerExit();
|
||||
|
||||
// Close the one duplex channel route the same way. The pending channel calls
|
||||
// already rejected through `rejectAllPending`.
|
||||
|
|
@ -3326,7 +3344,7 @@ export function createPluginWorkerHandle(
|
|||
return callInternal(method, params, timeoutMs, executeLogSink);
|
||||
},
|
||||
|
||||
openSetupTokenPtySession(input: SetupTokenPtyOpenInput) {
|
||||
openLoginPtySession(input: LoginPtyOpenInput) {
|
||||
if (status !== "running" && status !== "starting") {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
|
|
@ -3334,7 +3352,7 @@ export function createPluginWorkerHandle(
|
|||
),
|
||||
);
|
||||
}
|
||||
return openSetupTokenPtySession(input);
|
||||
return openLoginPtySession(input);
|
||||
},
|
||||
|
||||
openDuplexChannel(input: DuplexChannelOpenInput) {
|
||||
|
|
@ -3653,14 +3671,14 @@ export function createPluginWorkerManager(
|
|||
return handle.call(method, params, timeoutMs, executeLogSink);
|
||||
},
|
||||
|
||||
openSetupTokenPtySession(pluginId: string, input: SetupTokenPtyOpenInput) {
|
||||
openLoginPtySession(pluginId: string, input: LoginPtyOpenInput) {
|
||||
const handle = workers.get(pluginId);
|
||||
if (!handle) {
|
||||
return Promise.reject(
|
||||
new Error(`No worker registered for plugin "${pluginId}"`),
|
||||
);
|
||||
}
|
||||
return handle.openSetupTokenPtySession(input);
|
||||
return handle.openLoginPtySession(input);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
// The descriptor-bound credential read helper. The server runs it in the sandbox
|
||||
// as `node -e <this file> <sessionHome> <maxBytes> [<expectedUid>]`. The sandbox
|
||||
// already runs node for the Paperclip bridge, so the helper needs no extra
|
||||
// runtime.
|
||||
//
|
||||
// The helper closes the time-of-check-to-time-of-use (TOCTOU) window. It opens
|
||||
// the filesystem root, then opens each session-home path component in turn with a
|
||||
// no-follow, directory-only open, relative to the previous directory descriptor. A
|
||||
// symlink at any component fails the walk. Node has no `openat`, so the helper
|
||||
// opens each next component through `/proc/self/fd/<dfd>/<component>`: the kernel
|
||||
// resolves the magic descriptor link, then applies `O_NOFOLLOW` to the final
|
||||
// component. This binds each open to the descriptor inode, not to a re-resolved
|
||||
// pathname, so it matches an `openat` walk.
|
||||
//
|
||||
// The helper opens `auth.json` relative to the final directory descriptor with
|
||||
// `O_NOFOLLOW`, runs `fstat` on that one descriptor, and reads only from it. The
|
||||
// `fstat` check requires a regular file, the expected owner, exact mode `0600`,
|
||||
// and a size at or below the bound. The helper never re-opens the pathname after
|
||||
// the check.
|
||||
//
|
||||
// Security: the helper prints only the base64 of the credential bytes on the
|
||||
// fully validated success path. It prints one fixed error token on stderr on every
|
||||
// failed path. It never prints the pathname, the raw bytes, or any other detail.
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
|
||||
const NAME = "auth.json";
|
||||
const home = process.argv[1];
|
||||
const max = Number.parseInt(process.argv[2], 10);
|
||||
const expectedUid =
|
||||
process.argv[3] !== undefined ? Number.parseInt(process.argv[3], 10) : process.getuid();
|
||||
|
||||
function fail() {
|
||||
process.stderr.write("AUTH_READ_FAILED\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(max) || max <= 0) fail();
|
||||
if (typeof home !== "string" || !home.startsWith("/")) fail();
|
||||
const parts = home.split("/").filter((component) => component.length > 0);
|
||||
if (parts.length === 0) fail();
|
||||
|
||||
// Open each component with a no-follow, directory-only open relative to the
|
||||
// previous descriptor. A symlink or a non-directory at any component throws, so
|
||||
// the read fails closed and never leaves the trusted tree.
|
||||
let dfd;
|
||||
try {
|
||||
dfd = fs.openSync("/", fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
|
||||
} catch {
|
||||
fail();
|
||||
}
|
||||
for (const part of parts) {
|
||||
try {
|
||||
dfd = fs.openSync(
|
||||
`/proc/self/fd/${dfd}/${part}`,
|
||||
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
} catch {
|
||||
fail();
|
||||
}
|
||||
}
|
||||
|
||||
// Open the credential relative to the final directory descriptor with a no-follow
|
||||
// open, so a symlink credential fails and a missing credential fails.
|
||||
let ffd;
|
||||
try {
|
||||
ffd = fs.openSync(`/proc/self/fd/${dfd}/${NAME}`, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
||||
} catch {
|
||||
fail();
|
||||
}
|
||||
|
||||
// Validate the opened descriptor, then read only from it.
|
||||
const st = fs.fstatSync(ffd);
|
||||
if (!st.isFile()) fail();
|
||||
if (st.uid !== expectedUid) fail();
|
||||
if ((st.mode & 0o7777) !== 0o600) fail();
|
||||
if (st.size > max) fail();
|
||||
|
||||
const buffer = Buffer.allocUnsafe(65536);
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const read = fs.readSync(ffd, buffer, 0, buffer.length, null);
|
||||
if (read === 0) break;
|
||||
chunks.push(Buffer.from(buffer.subarray(0, read)));
|
||||
total += read;
|
||||
if (total > max) fail();
|
||||
}
|
||||
|
||||
process.stdout.write(Buffer.concat(chunks).toString("base64"));
|
||||
process.exit(0);
|
||||
|
|
@ -5,7 +5,7 @@ import {
|
|||
buildSetupTokenLoginTransport,
|
||||
createProductionSetupTokenSandboxProvider,
|
||||
createSetupTokenSecretWriter,
|
||||
createWorkerBoundSetupTokenPtyOpener,
|
||||
createWorkerBoundLoginPtyOpener,
|
||||
type SetupTokenSandboxProvider,
|
||||
} from "./setup-token-transport-binding.js";
|
||||
import {
|
||||
|
|
@ -18,7 +18,7 @@ import {
|
|||
type SetupTokenSessionScope,
|
||||
type SetupTokenSessionState,
|
||||
} from "./setup-token-session.js";
|
||||
import type { SetupTokenPtySessionOpener } from "@paperclipai/adapter-utils/setup-token-transport";
|
||||
import type { LoginPtySessionOpener } from "@paperclipai/adapter-utils/login-pty-transport";
|
||||
import {
|
||||
CLAUDE_SETUP_TOKEN_COMMAND,
|
||||
SETUP_TOKEN_AFTER_ANCHOR,
|
||||
|
|
@ -54,7 +54,7 @@ function createFakeSandbox() {
|
|||
resolveExit = resolve;
|
||||
});
|
||||
|
||||
const openPtySession: SetupTokenPtySessionOpener = async (command) => {
|
||||
const openPtySession: LoginPtySessionOpener = async (command) => {
|
||||
openedCommands.push(command);
|
||||
return {
|
||||
onData(): void {},
|
||||
|
|
@ -410,7 +410,7 @@ describe("production sandbox provider", () => {
|
|||
});
|
||||
|
||||
it("acquires a lease and binds the live opener when it is present", async () => {
|
||||
const openPtySession: SetupTokenPtySessionOpener = async () => ({
|
||||
const openPtySession: LoginPtySessionOpener = async () => ({
|
||||
onData(): void {},
|
||||
write(): void {},
|
||||
wait: async () => ({ exitCode: 0 }),
|
||||
|
|
@ -446,7 +446,7 @@ describe("production sandbox provider", () => {
|
|||
});
|
||||
|
||||
it("forwards the session deadline and records a lease expiry at or before it", async () => {
|
||||
const openPtySession: SetupTokenPtySessionOpener = async () => ({
|
||||
const openPtySession: LoginPtySessionOpener = async () => ({
|
||||
onData(): void {},
|
||||
write(): void {},
|
||||
wait: async () => ({ exitCode: 0 }),
|
||||
|
|
@ -569,14 +569,16 @@ describe("worker-bound live pseudo-terminal opener", () => {
|
|||
kill: () => {},
|
||||
close: async () => {},
|
||||
};
|
||||
const openSetupTokenPtySession = vi.fn(async () => session);
|
||||
const openLoginPtySession = vi.fn(
|
||||
async (_pluginId: string, _input: Record<string, unknown>) => session,
|
||||
);
|
||||
const getLeaseById = vi.fn(async () => ({
|
||||
providerLeaseId: "provider-lease-9",
|
||||
metadata: { pluginId: "paperclip.daytona", provider: "daytona" },
|
||||
}));
|
||||
|
||||
const openLivePtySession = createWorkerBoundSetupTokenPtyOpener({
|
||||
workerManager: { openSetupTokenPtySession },
|
||||
const openLivePtySession = createWorkerBoundLoginPtyOpener({
|
||||
workerManager: { openLoginPtySession },
|
||||
environments: { getLeaseById },
|
||||
});
|
||||
|
||||
|
|
@ -588,24 +590,64 @@ describe("worker-bound live pseudo-terminal opener", () => {
|
|||
// The opener resolved the server lease id to the provider lease id.
|
||||
expect(getLeaseById).toHaveBeenCalledWith("lease-42");
|
||||
|
||||
const opened = await opener("claude setup-token");
|
||||
// The opener drove the manager route gate with the resolved worker target and
|
||||
// the fixed command. The manager mints the host route id, so the opener passes
|
||||
// none.
|
||||
expect(openSetupTokenPtySession).toHaveBeenCalledWith("paperclip.daytona", {
|
||||
// The opener ignores the incoming command argument. It resolves the closed
|
||||
// command key from the trusted adapter type and derives the session home.
|
||||
const opened = await opener("caller-supplied-ignored");
|
||||
// The opener drove the manager route gate with the resolved worker target, the
|
||||
// closed command key, and a validated session home. The manager mints the host
|
||||
// route id, so the opener passes none. The open carries no command string.
|
||||
expect(openLoginPtySession).toHaveBeenCalledTimes(1);
|
||||
const [pluginId, openInput] = openLoginPtySession.mock.calls[0];
|
||||
expect(pluginId).toBe("paperclip.daytona");
|
||||
expect(openInput).toMatchObject({
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: "provider-lease-9",
|
||||
command: "claude setup-token",
|
||||
loginCommandKey: "claude",
|
||||
});
|
||||
expect(openInput).not.toHaveProperty("command");
|
||||
// The session home is the fixed root, one slash, and one UUID.
|
||||
expect(openInput.sessionHome).toMatch(
|
||||
/^\/tmp\/paperclip-adapter-login\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(opened).toBe(session);
|
||||
});
|
||||
|
||||
it("fails closed when the adapter type has no login command key", async () => {
|
||||
const openLoginPtySession = vi.fn(async () => ({
|
||||
onData: () => {},
|
||||
write: () => {},
|
||||
wait: async () => ({ exitCode: 0 }),
|
||||
kill: () => {},
|
||||
close: async () => {},
|
||||
}));
|
||||
const openLivePtySession = createWorkerBoundLoginPtyOpener({
|
||||
workerManager: { openLoginPtySession },
|
||||
environments: {
|
||||
getLeaseById: async () => ({
|
||||
providerLeaseId: "provider-lease-9",
|
||||
metadata: { pluginId: "paperclip.daytona", provider: "daytona" },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// An unmapped adapter type confers no command authority. The opener fails
|
||||
// closed before it reaches the worker manager.
|
||||
await expect(
|
||||
openLivePtySession({
|
||||
scope: { ...SCOPE, adapterType: "gemini_local" },
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-42",
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 503 });
|
||||
expect(openLoginPtySession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when the lease carries no sandbox worker binding", async () => {
|
||||
const openSetupTokenPtySession = vi.fn();
|
||||
const openLivePtySession = createWorkerBoundSetupTokenPtyOpener({
|
||||
workerManager: { openSetupTokenPtySession },
|
||||
const openLoginPtySession = vi.fn();
|
||||
const openLivePtySession = createWorkerBoundLoginPtyOpener({
|
||||
workerManager: { openLoginPtySession },
|
||||
// The lease resolves but carries no provider lease id or plugin id.
|
||||
environments: {
|
||||
getLeaseById: async () => ({ providerLeaseId: null, metadata: {} }),
|
||||
|
|
@ -616,12 +658,12 @@ describe("worker-bound live pseudo-terminal opener", () => {
|
|||
openLivePtySession({ scope: SCOPE, environmentId: "env-1", leaseId: "lease-42" }),
|
||||
).rejects.toMatchObject({ status: 503 });
|
||||
// The opener never reached the worker manager.
|
||||
expect(openSetupTokenPtySession).not.toHaveBeenCalled();
|
||||
expect(openLoginPtySession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when the lease is missing", async () => {
|
||||
const openLivePtySession = createWorkerBoundSetupTokenPtyOpener({
|
||||
workerManager: { openSetupTokenPtySession: vi.fn() },
|
||||
const openLivePtySession = createWorkerBoundLoginPtyOpener({
|
||||
workerManager: { openLoginPtySession: vi.fn() },
|
||||
environments: { getLeaseById: async () => null },
|
||||
});
|
||||
|
||||
|
|
@ -706,7 +748,7 @@ function createStreamingSandbox() {
|
|||
});
|
||||
const writes: string[] = [];
|
||||
|
||||
const openPtySession: SetupTokenPtySessionOpener = async () => ({
|
||||
const openPtySession: LoginPtySessionOpener = async () => ({
|
||||
onData(next): void {
|
||||
listener = next;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -39,14 +39,21 @@ import type { environmentRuntimeService } from "./environment-runtime.js";
|
|||
import { buildLoginLeaseAcquireArgs } from "./adapter-login-lease.js";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
createSetupTokenPtyTransport,
|
||||
type SetupTokenPtySession,
|
||||
type SetupTokenPtySessionOpener,
|
||||
} from "@paperclipai/adapter-utils/setup-token-transport";
|
||||
createLoginPtyTransport,
|
||||
type LoginPtySession,
|
||||
type LoginPtySessionOpener,
|
||||
} from "@paperclipai/adapter-utils/login-pty-transport";
|
||||
import {
|
||||
runSetupTokenLogin,
|
||||
CLAUDE_SETUP_TOKEN_COMMAND,
|
||||
} from "@paperclipai/adapter-claude-local/server";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
deriveLoginSessionHome,
|
||||
resolveLoginCommandKey,
|
||||
validateLoginSessionHome,
|
||||
type LoginCommandKey,
|
||||
} from "./login-command.js";
|
||||
|
||||
/**
|
||||
* The sandbox provider for one login session. It acquires a fresh sandbox lease
|
||||
|
|
@ -64,7 +71,7 @@ export interface SetupTokenSandboxProvider {
|
|||
acquire(input: {
|
||||
scope: SetupTokenSessionScope;
|
||||
deadline: number;
|
||||
}): Promise<{ leaseId: string; openPtySession: SetupTokenPtySessionOpener }>;
|
||||
}): Promise<{ leaseId: string; openPtySession: LoginPtySessionOpener }>;
|
||||
/** Releases the lease by id. The service and the startup reaper call it. */
|
||||
release(leaseId: string): Promise<void>;
|
||||
}
|
||||
|
|
@ -246,7 +253,7 @@ export function buildSetupTokenLoginTransport(
|
|||
// The service calls `leases.acquire` and then `factory` in sequence for one
|
||||
// session, so a per-scope handoff correlates them. The per-owner session cap is
|
||||
// one, so one scope holds at most one live acquire.
|
||||
const pendingOpeners = new Map<string, { leaseId: string; openPtySession: SetupTokenPtySessionOpener }>();
|
||||
const pendingOpeners = new Map<string, { leaseId: string; openPtySession: LoginPtySessionOpener }>();
|
||||
// The reverse map releases a handoff by lease id when the factory never
|
||||
// consumes it (a factory error path).
|
||||
const leaseScopeKeys = new Map<string, string>();
|
||||
|
|
@ -288,7 +295,7 @@ export function buildSetupTokenLoginTransport(
|
|||
pendingOpeners.delete(key);
|
||||
leaseScopeKeys.delete(pending.leaseId);
|
||||
|
||||
const transport = createSetupTokenPtyTransport(pending.openPtySession);
|
||||
const transport = createLoginPtyTransport(pending.openPtySession);
|
||||
|
||||
// Bridge the browser code. The service calls `submitCode` one time after the
|
||||
// single `awaiting_code` transition wins. The runner calls `provideCode` one
|
||||
|
|
@ -358,7 +365,7 @@ export interface ProductionSetupTokenSandboxProviderDeps {
|
|||
scope: SetupTokenSessionScope;
|
||||
environmentId: string;
|
||||
leaseId: string;
|
||||
}) => Promise<SetupTokenPtySessionOpener>;
|
||||
}) => Promise<LoginPtySessionOpener>;
|
||||
/** A non-leaking status sink. It receives only fixed status lines. */
|
||||
log?: (line: string) => void;
|
||||
}
|
||||
|
|
@ -535,21 +542,22 @@ export function createProductionSetupTokenCleanupStore(db: Db): SetupTokenCleanu
|
|||
* reserves one route per worker, drives the open, binds the worker session
|
||||
* identifier one time, routes output, and terminalizes the route.
|
||||
*/
|
||||
export interface SetupTokenPtyWorkerManagerLike {
|
||||
openSetupTokenPtySession(
|
||||
export interface LoginPtyWorkerManagerLike {
|
||||
openLoginPtySession(
|
||||
pluginId: string,
|
||||
input: {
|
||||
driverKey: string;
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
providerLeaseId: string;
|
||||
command: string;
|
||||
loginCommandKey: LoginCommandKey;
|
||||
sessionHome: string;
|
||||
},
|
||||
): Promise<SetupTokenPtySession>;
|
||||
): Promise<LoginPtySession>;
|
||||
}
|
||||
|
||||
/** The narrow lease-lookup surface the live opener needs. */
|
||||
export interface SetupTokenPtyLeaseLookup {
|
||||
export interface LoginPtyLeaseLookup {
|
||||
getLeaseById(leaseId: string): Promise<
|
||||
| {
|
||||
providerLeaseId: string | null;
|
||||
|
|
@ -560,11 +568,11 @@ export interface SetupTokenPtyLeaseLookup {
|
|||
}
|
||||
|
||||
/** The dependencies the worker-bound live pseudo-terminal opener needs. */
|
||||
export interface WorkerBoundSetupTokenPtyOpenerDeps {
|
||||
export interface WorkerBoundLoginPtyOpenerDeps {
|
||||
/** The plugin worker manager that owns the host route gate. */
|
||||
workerManager: SetupTokenPtyWorkerManagerLike;
|
||||
workerManager: LoginPtyWorkerManagerLike;
|
||||
/** The environment lease lookup that resolves the worker target for a lease. */
|
||||
environments: SetupTokenPtyLeaseLookup;
|
||||
environments: LoginPtyLeaseLookup;
|
||||
/** A non-leaking status sink. It receives only fixed status lines. */
|
||||
log?: (line: string) => void;
|
||||
}
|
||||
|
|
@ -579,9 +587,15 @@ function readLeaseMetaString(value: unknown): string | null {
|
|||
* drives the worker through the manager route gate. The manager mints the host
|
||||
* route identifier and owns the route lifecycle. The opener fails closed when the
|
||||
* lease carries no sandbox worker binding.
|
||||
*
|
||||
* The opener resolves the host launch descriptor. It reads the closed login
|
||||
* command key from the trusted adapter type, and it derives the server-controlled
|
||||
* session home from a fresh UUID. It validates the home shape before the worker
|
||||
* RPC. It never reads a command from the caller: the incoming opener argument is
|
||||
* unused, so the transport cannot influence the sandbox command.
|
||||
*/
|
||||
export function createWorkerBoundSetupTokenPtyOpener(
|
||||
deps: WorkerBoundSetupTokenPtyOpenerDeps,
|
||||
export function createWorkerBoundLoginPtyOpener(
|
||||
deps: WorkerBoundLoginPtyOpenerDeps,
|
||||
): NonNullable<ProductionSetupTokenSandboxProviderDeps["openLivePtySession"]> {
|
||||
const log = deps.log ?? (() => {});
|
||||
return async ({ scope, environmentId, leaseId }) => {
|
||||
|
|
@ -601,13 +615,30 @@ export function createWorkerBoundSetupTokenPtyOpener(
|
|||
log("[paperclip] Setup-token login: the lease carries no sandbox worker binding.");
|
||||
throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED);
|
||||
}
|
||||
return (command: string) =>
|
||||
deps.workerManager.openSetupTokenPtySession(pluginId, {
|
||||
// Resolve the closed command key from the trusted adapter type. An unmapped
|
||||
// adapter fails closed before the worker RPC.
|
||||
let loginCommandKey: LoginCommandKey;
|
||||
try {
|
||||
loginCommandKey = resolveLoginCommandKey(scope.adapterType);
|
||||
} catch {
|
||||
log("[paperclip] Setup-token login: the adapter type has no login command key.");
|
||||
throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED);
|
||||
}
|
||||
// The opener argument is the runner's fixed command string. It confers no
|
||||
// command authority, so the opener ignores it and returns a host-bound opener.
|
||||
return (_command: string) => {
|
||||
// Generate the session UUID, then the home path, then validate the exact
|
||||
// shape before the host sends the worker RPC.
|
||||
const sessionHome = deriveLoginSessionHome(randomUUID());
|
||||
validateLoginSessionHome(sessionHome);
|
||||
return deps.workerManager.openLoginPtySession(pluginId, {
|
||||
driverKey,
|
||||
companyId: scope.companyId,
|
||||
environmentId,
|
||||
providerLeaseId,
|
||||
command,
|
||||
loginCommandKey,
|
||||
sessionHome,
|
||||
});
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { api } from "./client";
|
|||
*/
|
||||
export interface AdapterLoginProjection {
|
||||
panelMode: "displayed_code" | "submitted_browser_code";
|
||||
sandboxTransport: "streamed_exec" | "pseudo_terminal";
|
||||
timeoutPolicy: "caller_bounded" | "fixed";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -108,14 +108,15 @@ vi.mock("../adapters", () => ({
|
|||
// third adapter with a projected login capability.
|
||||
const mockLoginProjections = vi.hoisted(
|
||||
() =>
|
||||
new Map<string, { panelMode: string; sandboxTransport: string; timeoutPolicy: string }>([
|
||||
["codex_local", { panelMode: "displayed_code", sandboxTransport: "streamed_exec", timeoutPolicy: "caller_bounded" }],
|
||||
["claude_local", { panelMode: "submitted_browser_code", sandboxTransport: "pseudo_terminal", timeoutPolicy: "fixed" }],
|
||||
new Map<string, { panelMode: string; timeoutPolicy: string }>([
|
||||
["codex_local", { panelMode: "displayed_code", timeoutPolicy: "caller_bounded" }],
|
||||
["claude_local", { panelMode: "submitted_browser_code", timeoutPolicy: "fixed" }],
|
||||
// A third adapter, not a built-in, with a projected displayed-code login.
|
||||
["vendor_local", { panelMode: "displayed_code", sandboxTransport: "streamed_exec", timeoutPolicy: "caller_bounded" }],
|
||||
// A non-built-in adapter with a pseudo-terminal login transport. The gate
|
||||
// requires the provider pty capability from the transport, not the name.
|
||||
["pty_vendor_local", { panelMode: "submitted_browser_code", sandboxTransport: "pseudo_terminal", timeoutPolicy: "fixed" }],
|
||||
["vendor_local", { panelMode: "displayed_code", timeoutPolicy: "caller_bounded" }],
|
||||
// A non-built-in adapter with a submitted-browser-code login. Every login
|
||||
// runs on a real pseudo-terminal, so the gate requires the provider pty
|
||||
// capability from the login capability, not the adapter name.
|
||||
["pty_vendor_local", { panelMode: "submitted_browser_code", timeoutPolicy: "fixed" }],
|
||||
]),
|
||||
);
|
||||
|
||||
|
|
@ -408,9 +409,9 @@ async function renderCodexSandbox(agentOverrides: Partial<Agent> = {}) {
|
|||
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
||||
makeEnvironment({
|
||||
id: "sandbox-1",
|
||||
name: "E2B",
|
||||
name: "Daytona",
|
||||
driver: "sandbox",
|
||||
config: { provider: "e2b" },
|
||||
config: { provider: "daytona" },
|
||||
}),
|
||||
],
|
||||
{ defaultEnvironmentId: "sandbox-1", ...agentOverrides },
|
||||
|
|
@ -419,16 +420,17 @@ async function renderCodexSandbox(agentOverrides: Partial<Agent> = {}) {
|
|||
}
|
||||
|
||||
// A third adapter, not a built-in, in a sandbox environment. Its projected login
|
||||
// capability drives the login affordance and the displayed-code panel.
|
||||
// capability drives the login affordance and the displayed-code panel. The
|
||||
// provider advertises the login pseudo-terminal capability the login needs.
|
||||
async function renderVendorSandbox(agentOverrides: Partial<Agent> = {}) {
|
||||
return renderForm(
|
||||
[
|
||||
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
||||
makeEnvironment({
|
||||
id: "sandbox-1",
|
||||
name: "E2B",
|
||||
name: "Daytona",
|
||||
driver: "sandbox",
|
||||
config: { provider: "e2b" },
|
||||
config: { provider: "daytona" },
|
||||
}),
|
||||
],
|
||||
{ adapterType: "vendor_local", defaultEnvironmentId: "sandbox-1", ...agentOverrides },
|
||||
|
|
@ -1042,6 +1044,31 @@ describe("AgentConfigForm environment selector", () => {
|
|||
expect(findButton(result.container, "Log in")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the Codex login for a provider without the login pseudo-terminal capability", async () => {
|
||||
// The Codex device login runs on a real pseudo-terminal, so it needs a
|
||||
// provider that advertises the login pseudo-terminal capability. E2B reports
|
||||
// no capability, so the panel stays hidden even after the auth-missing check.
|
||||
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
|
||||
const result = await renderForm(
|
||||
[
|
||||
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
||||
makeEnvironment({
|
||||
id: "sandbox-1",
|
||||
name: "E2B",
|
||||
driver: "sandbox",
|
||||
config: { provider: "e2b" },
|
||||
}),
|
||||
],
|
||||
{ adapterType: "codex_local", defaultEnvironmentId: "sandbox-1" },
|
||||
{ showAdapterTestEnvironmentButton: true },
|
||||
);
|
||||
roots.push(result.root);
|
||||
|
||||
await runTest(result.container);
|
||||
|
||||
expect(findButton(result.container, "Log in")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("shows the login affordance and the displayed-code panel for a third adapter with a projected login capability", async () => {
|
||||
// The adapter is not a built-in. Its projected login capability drives the
|
||||
// login affordance and the panel, so the form reads the capability, not the
|
||||
|
|
@ -1199,9 +1226,9 @@ describe("AgentConfigForm environment selector", () => {
|
|||
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
||||
makeEnvironment({
|
||||
id: "sandbox-1",
|
||||
name: "E2B",
|
||||
name: "Daytona",
|
||||
driver: "sandbox",
|
||||
config: { provider: "e2b" },
|
||||
config: { provider: "daytona" },
|
||||
}),
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -630,17 +630,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
// Load the sandbox provider capabilities. A login that runs on a real
|
||||
// pseudo-terminal needs a provider that advertises the login pseudo-terminal
|
||||
// capability. The login panel gate reads this to hide the panel for a provider
|
||||
// without the capability. Enable the query from the adapter login transport,
|
||||
// not the adapter name: only a pseudo-terminal login consults this data. The
|
||||
// gate is advisory; the server resolves the capability again and fails closed.
|
||||
// without the capability. Enable the query when the adapter declares a login
|
||||
// capability: every login runs on a real pseudo-terminal, so every login
|
||||
// consults this data. The gate is advisory; the server resolves the capability
|
||||
// again and fails closed.
|
||||
const { data: environmentCapabilities } = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.environments.capabilities(selectedCompanyId)
|
||||
: ["environment-capabilities", "none"],
|
||||
queryFn: () => environmentsApi.capabilities(selectedCompanyId!),
|
||||
enabled:
|
||||
Boolean(selectedCompanyId) &&
|
||||
adapterCaps.login?.sandboxTransport === "pseudo_terminal",
|
||||
enabled: Boolean(selectedCompanyId) && adapterCaps.login != null,
|
||||
});
|
||||
|
||||
// When the instance forces Kubernetes execution, new agents must default to the
|
||||
|
|
@ -1032,11 +1031,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
testResult && testResultSupportsSandboxLogin
|
||||
? testResult.checks.find((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE) ?? null
|
||||
: null;
|
||||
// A login that runs on a real pseudo-terminal needs a provider that advertises
|
||||
// the login pseudo-terminal capability. Read the capability for the effective
|
||||
// environment provider. The form reads the adapter login transport, not the
|
||||
// adapter name: a login with a streamed-exec transport does not gate on this
|
||||
// capability, so the requirement applies to a pseudo-terminal login only.
|
||||
// A login runs on a real pseudo-terminal, so it needs a provider that
|
||||
// advertises the login pseudo-terminal capability. Read the capability for the
|
||||
// effective environment provider. The form reads the adapter login capability,
|
||||
// not the adapter name: every adapter login gates on this provider capability.
|
||||
const effectiveLoginProvider =
|
||||
typeof effectiveLoginEnvironment?.config?.provider === "string"
|
||||
? effectiveLoginEnvironment.config.provider
|
||||
|
|
@ -1044,7 +1042,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
const providerSupportsLoginPty =
|
||||
effectiveLoginProvider != null &&
|
||||
environmentCapabilities?.sandboxProviders?.[effectiveLoginProvider]?.supportsLoginPty === true;
|
||||
const loginNeedsPty = adapterCaps.login?.sandboxTransport === "pseudo_terminal";
|
||||
const loginNeedsPty = adapterCaps.login != null;
|
||||
const showAdapterLogin =
|
||||
adapterSupportsSandboxLogin &&
|
||||
effectiveLoginEnvironment?.driver === "sandbox" &&
|
||||
|
|
|
|||
|
|
@ -82,9 +82,9 @@ vi.mock("../adapters/use-disabled-adapters", () => ({
|
|||
// provider pty capability. Provide the projection so the login panel renders.
|
||||
const mockLoginProjections = vi.hoisted(
|
||||
() =>
|
||||
new Map<string, { panelMode: string; sandboxTransport: string; timeoutPolicy: string }>([
|
||||
["claude_local", { panelMode: "submitted_browser_code", sandboxTransport: "pseudo_terminal", timeoutPolicy: "fixed" }],
|
||||
["codex_local", { panelMode: "displayed_code", sandboxTransport: "streamed_exec", timeoutPolicy: "caller_bounded" }],
|
||||
new Map<string, { panelMode: string; timeoutPolicy: string }>([
|
||||
["claude_local", { panelMode: "submitted_browser_code", timeoutPolicy: "fixed" }],
|
||||
["codex_local", { panelMode: "displayed_code", timeoutPolicy: "caller_bounded" }],
|
||||
]),
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue