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:
Nicky LeachandPaperclip authored and GitHub committed 2026-08-23 09:44:59 -07:00
1 parent 16b59c9315
commit 63df7ad2b3
52 files changed
+3500 -1033

No files matched your search

@@ -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,
-2
View File
@@ -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");
+5 -6
View File
@@ -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) => {
+2 -2
View File
@@ -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),
-4
View File
@@ -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");
+4 -6
View File
@@ -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,
},
}
+97 -28
View File
@@ -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;
}
+1 -3
View File
@@ -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 ?? "");
}
+207 -44
View File
@@ -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,
+88
View File
@@ -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",
);
}
});
});
+105
View File
@@ -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);
}
}
+113 -95
View File
@@ -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,
});
};
};
}