Extend login PTY contracts for existing computers (#20410)

## Thinking Path

> - Paperclip manages agent execution across environments.
> - Environment providers own execution resources.
> - Codex and Claude already share the login PTY transport.
> - A login on an existing computer needs its own allocation and
deadline.
> - Input retries must retain their identity.
> - Codex also needs a fixed credential-file read; Claude already
returns its token through PTY output.
> - This change extends the shared contract and keeps both existing
provider parsers.

## Linked Issues or Issue Description

**Problem**

The existing login contract assumes a temporary sandbox. It cannot
describe a login allocation on an existing computer or export its fixed
credential result without generic execution.

**Proposed solution**

Add shared allocation purpose, command identity, PTY deadline, and input
identity fields for both Codex and Claude. Add one optional
credential-result hook for Codex's auth.json file.

**Alternatives considered**

A separate Claude login implementation would duplicate the current
setup-token parser and owner-bound credential writer. Claude continues
to use that existing path. The new artifact hook is Codex-specific
because Claude's setup-token result comes through the existing shared
PTY output stream.

## What Changed

- Export the login contract types from the main SDK entry point.
- Forward login purpose and command through plugin allocation and retain
them in lease metadata.
- Forward the PTY deadline and give each input write a stable identity.
- End a login route when the worker cannot confirm input delivery.
- Preserve the opening company scope for input, stop, and close
callbacks.
- Advertise and dispatch the optional fixed Codex credential-result
hook.

The allocation, deadline, input, output, and cleanup contracts serve
both providers. This PR does not enable either provider on an existing
computer. The dependent consumer PR connects admission, services,
credential completion, and UI for both providers.

## Verification

- Close-ordering regression: all 91 host worker-manager tests passed,
including concurrent close callers waiting behind a held plugin
acknowledgement after input failure. Server TypeScript check passed. All
current CI gates, ordinary review, and the selected follow-up review
pass on a39fb59bd1.

- Shared and SDK TypeScript builds passed.
- SDK worker dispatch: 31 tests passed.
- Host worker-manager: 90 tests passed, including a nested
worker-to-host scope guard.
- Environment runtime with embedded PostgreSQL: 197 tests passed.
- Added absent-hook, input-failure, and allocation-forwarding checks.
- A running instance is not required to validate this SDK-only
increment. Real provider acceptance follows after the dependent hooks
are connected.

## Risks

The hook and fields are optional. Existing sandbox providers keep their
current path. Failed input delivery now ends its route instead of
waiting for the deadline. There is no database migration.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning and code execution. The exact
runtime model identifier and context size are not exposed to this agent.

## 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-11-09 16:50:58 -08:00
1 parent 347d85fef3
commit 3a5572c9e5
11 files changed
+232 -22

No files matched your search

@@ -6288,11 +6288,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
it("forwards a requested expiry to the acquire RPC and records only a provider-attested expiry", async () => {
const pluginId = randomUUID();
let receivedRequestedExpiresAt: unknown;
let receivedPurpose: unknown, receivedCommand: unknown;
const workerManager = {
isRunning: vi.fn(() => true),
call: vi.fn(async (_pluginId: string, method: string, params: Record<string, unknown>) => {
if (method === "environmentAcquireLease") {
receivedRequestedExpiresAt = params.requestedExpiresAt;
receivedPurpose = params.purpose; receivedCommand = params.loginCommandKey;
// The provider grants no expiry.
return { providerLeaseId: "plugin-lease-no-expiry", metadata: { remoteCwd: "/workspace" } };
}
@@ -6310,10 +6312,12 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
requestedExpiresAt,
purpose: "login", loginCommandKey: "codex",
});
// The acquire RPC receives the requested expiry as an ISO 8601 string.
expect(receivedRequestedExpiresAt).toBe(requestedExpiresAt.toISOString());
expect(receivedPurpose).toBe("login"); expect(receivedCommand).toBe("codex");
// The provider gave no expiry, so the lease row records no expiry. The runtime
// never synthesizes the requested deadline as evidence of provider enforcement.
expect(acquired.lease.expiresAt ?? null).toBeNull();
@@ -146,6 +146,16 @@ function scriptedOutputLines(directive, hostRouteId, workerSessionId) {
// The registered terminals, keyed by the host route id. Each entry records the
// bound worker session id and the close directive.
const routes = new Map();
const pendingHost = new Map();
let nextHostCall = 0;
function probeCompanyScope(message, entry) {
const id = `scope-${++nextHostCall}`;
return new Promise((resolve, reject) => {
pendingHost.set(id, { resolve, reject });
send({ jsonrpc: "2.0", id, method: "companies.get", params: { companyId: entry.companyId, stage: message.method },
...(message.paperclipInvocation ? { paperclipInvocationId: message.paperclipInvocation.id } : {}) });
});
}
function parseDirective(raw) {
try {
@@ -158,12 +168,24 @@ function parseDirective(raw) {
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
rl.on("line", (line) => {
rl.on("line", async (line) => {
if (!line.trim()) return;
const message = JSON.parse(line);
if (pendingHost.has(message.id)) {
const pending = pendingHost.get(message.id); pendingHost.delete(message.id);
if (message.error) pending.reject(new Error("Host company scope denied")); else pending.resolve(message.result);
return;
}
const method = message && typeof message.method === "string" ? message.method : null;
const params = message.params ?? {};
if (["loginPtyInput", "loginPtyStop", "loginPtyClose"].includes(method)) {
const entry = method === "loginPtyClose" ? routes.get(params.hostRouteId) : [...routes.values()].find(value => value.workerSessionId === params.workerSessionId);
if (entry?.probeLifecycle) {
try { await probeCompanyScope(message, entry); }
catch { send({ jsonrpc: "2.0", id: message.id, error: { code: -32000, message: "Host company scope denied" } }); return; }
}
}
if (method === "initialize") {
send({
jsonrpc: "2.0",
@@ -188,7 +210,9 @@ rl.on("line", (line) => {
const closeMode = directive.closeMode ?? "ack";
routes.set(params.hostRouteId, {
workerSessionId,
companyId: params.companyId, probeLifecycle: directive.probeLifecycle === true,
closeMode,
rejectInput: directive.rejectInput === true,
pendingOutput: directive.emitOnInput === true
? scriptedOutputLines(directive, params.hostRouteId, workerSessionId)
: null,
@@ -253,6 +277,14 @@ rl.on("line", (line) => {
// test proves the input reaches the worker and the output routes back.
for (const [hostRouteId, entry] of routes.entries()) {
if (entry.workerSessionId === params.workerSessionId) {
if (entry.rejectInput) {
send({ jsonrpc: "2.0", id: message.id, error: { code: -32000, message: "input delivery unconfirmed" } });
return;
}
if (typeof params.inputId !== "string" || params.inputId.length === 0) {
send({ jsonrpc: "2.0", id: message.id, error: { code: -32000, message: "input identity missing" } });
return;
}
if (entry.pendingOutput !== null) {
process.stdout.write(entry.pendingOutput);
entry.pendingOutput = null;
@@ -1336,6 +1336,64 @@ describe("plugin worker manager setup-token pty route gate", () => {
}
});
it("retains the opening company for scoped host calls during input, stop, and close", async () => {
const stages: string[] = [];
const handle = makeLoginPtyHandle({ hostHandlers: { "companies.get": async (
params: { companyId: string; stage: string }, context?: { invocationScope?: { companyId?: string } },
) => {
if (context?.invocationScope?.companyId !== params.companyId || params.companyId !== "company-1") throw new Error("Host company scope denied");
stages.push(params.stage);
return { id: params.companyId };
} } });
try {
await handle.start();
const session = await handle.openLoginPtySession(ptyOpenInput({ probeLifecycle: true }));
session.write("browser-code");
await vi.waitFor(() => expect(stages).toEqual(["loginPtyInput"]));
session.kill();
await vi.waitFor(() => expect(stages).toEqual(["loginPtyInput", "loginPtyStop"]));
await session.close();
expect(stages).toEqual(["loginPtyInput", "loginPtyStop", "loginPtyClose"]);
} finally { await handle.stop().catch(() => undefined); }
});
it("fails the login when input delivery is unconfirmed", async () => {
const handle = makeLoginPtyHandle();
try {
await handle.start();
const session = await handle.openLoginPtySession(ptyOpenInput({ rejectInput: true }));
session.write("browser-code");
await expect(session.wait()).resolves.toEqual({ exitCode: null });
await session.close();
} finally { await handle.stop().catch(() => undefined); }
});
it("joins an in-flight close after input failure before releasing the login", async () => {
let releaseClose!: () => void;
const heldClose = new Promise<void>(resolve => { releaseClose = resolve; });
let closeCalls = 0;
const handle = makeLoginPtyHandle({ hostHandlers: { "companies.get": async (params: { stage: string }) => {
if (params.stage === "loginPtyClose") { closeCalls++; await heldClose; }
return { id: "company-1" };
} } });
try {
await handle.start();
const session = await handle.openLoginPtySession(ptyOpenInput({ rejectInput: true, probeLifecycle: true }));
session.write("browser-code");
await expect(session.wait()).resolves.toEqual({ exitCode: null });
await vi.waitFor(() => expect(closeCalls).toBe(1));
let completed = 0;
const first = session.close().then(() => { completed++; });
const second = session.close().then(() => { completed++; });
await new Promise(resolve => setImmediate(resolve));
expect(completed).toBe(0);
releaseClose();
await Promise.all([first, second]);
expect(completed).toBe(2);
expect(closeCalls).toBe(1);
} finally { releaseClose(); await handle.stop().catch(() => undefined); }
});
it("terminalizes the route when the cumulative output passes the per-route bound", async () => {
const handle = makeLoginPtyHandle({
loginPtyLimits: { maxTotalChars: 10 },
+10 -2
View File
@@ -454,6 +454,8 @@ function stripSecretRefValuesFromPluginLeaseMetadata(input: {
}
export interface EnvironmentDriverAcquireInput {
purpose?: "run" | "login";
loginCommandKey?: import("@paperclipai/plugin-sdk").PluginLoginCommandKey;
companyId: string;
environment: Environment;
/** Saved environment before run-only lease retention settings. */
@@ -3364,7 +3366,7 @@ function createPluginEnvironmentDriver(
driver.warmTaskVersion === 1 && !!input.issueId && !!input.agentId && !!input.heartbeatRunId &&
workerManager.getWorker(plugin.id)?.supportedMethods.includes("environmentResumeLease");
const lifecyclePolicy = warm ? { mode: "warm", idleTimeoutMs: parsed.config.runnerIdleTimeoutMs ?? 300_000 } : { mode: "per_turn", idleTimeoutMs: null };
const fingerprint = createHash("sha256").update(JSON.stringify([parsed.config, input.agentId, input.runnerTaskProjectIds ?? []])).digest("hex");
const fingerprint = createHash("sha256").update(JSON.stringify([parsed.config, input.agentId, input.runnerTaskProjectIds ?? [], ...(input.purpose ? [input.purpose, input.loginCommandKey] : [])])).digest("hex");
const candidates = warm ? await db.select().from(environmentLeases).where(and(
eq(environmentLeases.companyId, input.companyId), eq(environmentLeases.environmentId, input.environment.id),
eq(environmentLeases.status, "retained"), eq(environmentLeases.issueId, input.issueId!),
@@ -3405,6 +3407,7 @@ function createPluginEnvironmentDriver(
config: parsed.config.driverConfig,
runId: identity.runId,
creationOperationId: identity.id,
...(input.purpose ? { purpose: input.purpose, loginCommandKey: input.loginCommandKey } : {}),
workspaceMode: input.executionWorkspaceMode ?? undefined,
agentId: input.agentId ?? undefined,
executionWorkspaceId: input.executionWorkspaceId ?? undefined,
@@ -3436,8 +3439,9 @@ function createPluginEnvironmentDriver(
...(warm ? {runnerTaskProjectIds:[...input.runnerTaskProjectIds ?? []]} : {}),remoteCwd:".",
...(input.agentId ? { agentId: input.agentId } : {}),
providerMetadata: providerLease.metadata ?? {},
...(input.purpose ? { purpose: input.purpose, loginCommandKey: input.loginCommandKey } : {}),
environmentId: input.environment.id,
...(input.adapterType === "paperclip_runner" && driver.supportsTasks === true ? { runnerTaskLease: true } : {}),
...(input.purpose !== "login" && input.adapterType === "paperclip_runner" && driver.supportsTasks === true ? { runnerTaskLease: true } : {}),
driver: input.environment.driver,
executionWorkspaceMode: input.executionWorkspaceMode,
pluginId: plugin.id,
@@ -3845,6 +3849,8 @@ export function environmentRuntimeService(
},
async acquireRunLease(input: {
purpose?: "run" | "login";
loginCommandKey?: import("@paperclipai/plugin-sdk").PluginLoginCommandKey;
companyId: string;
environment: Environment;
/** Saved environment before run-only lease retention settings. */
@@ -3901,6 +3907,8 @@ export function environmentRuntimeService(
executionWorkspaceMode: leaseContext.executionWorkspaceMode,
executionWorkspaceSettings: input.executionWorkspaceSettings ?? null,
runnerTaskProjectIds: input.runnerTaskProjectIds,
purpose: input.purpose,
loginCommandKey: input.loginCommandKey,
adapterType: input.adapterType ?? null,
resolveSnapshotIdentity: input.resolveSnapshotIdentity,
applyCustomImageTemplate: input.applyCustomImageTemplate ?? false,
+29 -18
View File
@@ -590,6 +590,7 @@ export type ExecuteLogSink = (
* carries no command string, so a caller cannot select the command.
*/
export interface LoginPtyOpenInput {
expiresAt?: string;
/** The environment driver key. It routes the worker; it confers no command authority. */
driverKey: string;
companyId: string;
@@ -1564,7 +1565,9 @@ export function createPluginWorkerHandle(
type LoginPtyPreBindRecord = LoginPtyPreBindOutputRecord | LoginPtyPreBindExitRecord;
interface LoginPtyRoute {
companyId: string;
hostRouteId: string;
closePromise: Promise<void> | null;
state: LoginPtyRouteState;
workerSessionId: string | null;
listener: ((chunk: string) => void) | null;
@@ -1626,11 +1629,11 @@ export function createPluginWorkerHandle(
// that carries the exact host route identifier. An absent, malformed,
// mismatched, or timed-out acknowledgement returns false, so the caller fails
// closed.
async function closeLoginPtyTerminal(hostRouteId: string): Promise<boolean> {
async function closeLoginPtyTerminal(hostRouteId: string, companyId: string): Promise<boolean> {
try {
const ack = await callInternal(
"loginPtyClose",
{ hostRouteId },
{ hostRouteId, companyId },
loginPtyCloseTimeoutMs,
);
return isRecord(ack) && readNonEmptyString(ack.hostRouteId) === hostRouteId;
@@ -1647,6 +1650,9 @@ export function createPluginWorkerHandle(
// every other route this worker still holds settles through the worker-exit
// path that follows.
async function terminalizeLoginPtyRoute(route: LoginPtyRoute): Promise<void> {
// A notification may already have started close. Every later disposer must
// join that acknowledgement before its owner can release the allocation.
if (route.closePromise) return route.closePromise;
if (route.terminalized) return;
route.terminalized = true;
route.state = "closed";
@@ -1660,20 +1666,22 @@ export function createPluginWorkerHandle(
loginPtyRoutesByWorkerSessionId.delete(route.workerSessionId);
}
releaseLoginPtyRouteSlot(route);
// A terminalized route reports a null exit code, which the runner treats as a
// failure.
route.closePromise = (async () => {
const confirmed = await closeLoginPtyTerminal(route.hostRouteId, route.companyId);
if (!confirmed) {
// The worker did not acknowledge the close, so the host cannot prove the
// terminal is gone. Retire the worker before any reuse.
log.error(
{ pluginId },
"login pseudo-terminal close not acknowledged; retiring worker",
);
retireWorkerProcess();
}
})();
// Report failure promptly, but retain the shared close barrier so the
// runner's following dispose waits for confirmed stop (or worker retirement).
settleRouteWait(route, { exitCode: null });
const confirmed = await closeLoginPtyTerminal(route.hostRouteId);
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. This
// also settles and clears every other route the worker still holds.
log.error(
{ pluginId },
"login pseudo-terminal close not acknowledged; retiring worker",
);
retireWorkerProcess();
}
return route.closePromise;
}
// Resolve one login pseudo-terminal notification to its route. Return null
@@ -1920,7 +1928,9 @@ export function createPluginWorkerHandle(
settleWait = resolve;
});
const route: LoginPtyRoute = {
companyId: input.companyId,
hostRouteId,
closePromise: null,
state: "reserved",
workerSessionId: null,
listener: null,
@@ -1957,6 +1967,7 @@ export function createPluginWorkerHandle(
providerLeaseId: input.providerLeaseId,
loginCommandKey: input.loginCommandKey,
sessionHome: input.sessionHome,
...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
},
loginPtyOpenTimeoutMs,
);
@@ -2001,9 +2012,9 @@ export function createPluginWorkerHandle(
if (route.state !== "open" || !sid) return;
void callInternal(
"loginPtyInput",
{ workerSessionId: sid, data },
{ companyId: route.companyId, workerSessionId: sid, data, inputId: randomUUID() },
loginPtyOpenTimeoutMs,
).catch(() => {});
).catch(() => terminalizeLoginPtyRoute(route));
},
wait(): Promise<{ exitCode: number | null }> {
return waitPromise;
@@ -2013,7 +2024,7 @@ export function createPluginWorkerHandle(
if (!sid) return;
void callInternal(
"loginPtyStop",
{ workerSessionId: sid },
{ companyId: route.companyId, workerSessionId: sid },
loginPtyOpenTimeoutMs,
).catch(() => {});
},