mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
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:
1 parent
16b59c9315
commit
63df7ad2b3
52 files changed
+3500
-1033
No files matched your search
@@ -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);
|
||||
Reference in new issue
Block a user