Files
PaperClipAI/server/src/services/login-command.ts
T
Nicky LeachandPaperclip 63df7ad2b3 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>
2026-08-23 09:44:59 -07:00

106 lines
4.6 KiB
TypeScript

// 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);
}
}