Merge master into Runner D delivery

Resolve the App delivery conflicts while preserving native session recovery and current login/duplex behavior.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-23 20:38:03 +00:00
commit 2c5c2f0fce
93 files changed
+11860 -1628

No files matched your search

@@ -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,
@@ -6,6 +6,7 @@ import {
type AdapterExecutionTarget,
} from "@paperclipai/adapter-utils/execution-target";
import type { DuplexTelemetryRecorder } from "@paperclipai/adapter-utils/duplex-telemetry";
import type { DuplexAggregateByteLedger } from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
import {
clampSpanLabel,
getActiveStepContext,
@@ -206,6 +207,10 @@ export async function resolveEnvironmentExecutionTarget(input: {
// enters the sandbox environment. Absent keeps the safe no-op default in the
// bridge, so the surface stays inert until the host injects a real recorder.
duplexTelemetryRecorder?: DuplexTelemetryRecorder | null;
// The process-owned aggregate byte ledger. The seam stamps it onto the sandbox
// target next to the runner, so the live object stays on the host and never
// enters the sandbox environment. Absent keeps the bridge inert for this seam.
duplexAggregateByteLedger?: DuplexAggregateByteLedger | null;
}): Promise<AdapterExecutionTarget | null> {
if (input.environment.driver === "local") {
return {
@@ -323,6 +328,10 @@ export async function resolveEnvironmentExecutionTarget(input: {
// binds it to the fixed observability surface. Absent keeps the no-op
// default, so the surface stays inert on a run with no injected recorder.
duplexTelemetryRecorder: input.duplexTelemetryRecorder ?? null,
// Attach the process-owned aggregate byte ledger next to the runner. The
// bridge passes it to the broker, the decoder, and the response-body reader.
// Absent keeps the bridge inert for this seam.
duplexAggregateByteLedger: input.duplexAggregateByteLedger ?? null,
...(effectiveCapabilities ? { effectiveCapabilities: Object.freeze({ ...effectiveCapabilities }) } : {}),
environmentId: input.environment.id ?? null,
leaseId: input.leaseId ?? null,
@@ -43,6 +43,7 @@ import {
type AdapterWorkspaceRealization,
} from "@paperclipai/adapter-utils/execution-target";
import type { DuplexTelemetryRecorder } from "@paperclipai/adapter-utils/duplex-telemetry";
import type { DuplexAggregateByteLedger } from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
import { buildWorkspaceRealizationRequest } from "./workspace-realization.js";
import { executionWorkspaceService } from "./execution-workspaces.js";
import { logActivity } from "./activity-log.js";
@@ -154,6 +155,14 @@ export function environmentRunOrchestrator(
options: {
pluginWorkerManager?: PluginWorkerManager;
environmentRuntime?: EnvironmentRuntimeService;
/**
* The process-owned aggregate byte ledger for the sandbox duplex channel.
* The server root creates one ledger per host process and injects the same
* object here. The orchestrator stamps it onto the sandbox execution target,
* so one shared gauge bounds the aggregate retained bytes across all live
* duplex routes. Absent keeps the bridge inert for this seam.
*/
duplexAggregateByteLedger?: DuplexAggregateByteLedger | null;
} = {},
) {
const environmentsSvc = environmentService(db);
@@ -523,6 +532,7 @@ export function environmentRunOrchestrator(
lease,
environmentRuntime,
duplexTelemetryRecorder: input.duplexTelemetryRecorder ?? null,
duplexAggregateByteLedger: options.duplexAggregateByteLedger ?? null,
});
const realizationMode = workspaceRealization.mode === "in_place" ? "in_place" : "copy";
const authoritativeRoot =
+3 -2
View File
@@ -1080,9 +1080,10 @@ function adaptDuplexChannelHostSession(
onData(listener: (chunk: string) => void): void {
session.onData(listener);
},
onExit(listener: (exit: { exitCode: number | null }) => void): void {
onExit(listener: (exit: { exitCode: number | null; transportClosed?: boolean }) => void): void {
// `wait()` resolves one time with the exit and never rejects, so a single
// `then` bridges it to the one-time exit listener.
// `then` bridges it to the one-time exit listener. The exit carries
// `transportClosed`, so the broker tells a real exit from a transport close.
void session.wait().then((exit) => {
listener(exit);
});
+10
View File
@@ -90,6 +90,7 @@ import {
import { conflict, HttpError, notFound } from "../errors.js";
import { getStartupTraceContext, getStartupTracer } from "../instrumentation.js";
import { createHostDuplexTelemetryRecorder } from "./duplex-telemetry-recorder.js";
import type { DuplexAggregateByteLedger } from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
import { incrementToolRuntimeMetricCounter } from "./tool-runtime-metrics.js";
import { logger } from "../middleware/logger.js";
import {
@@ -7741,6 +7742,14 @@ export interface HeartbeatServiceOptions {
nativeSessionBackendFactory?: (
execution: NativeExecutionInput,
) => NativeSessionBackend;
/**
* The process-owned aggregate byte ledger for the sandbox duplex channel. The
* server root creates one ledger per host process and injects the same object
* here. The heartbeat threads it into the environment run orchestrator, which
* stamps it onto the sandbox execution target. Absent keeps the bridge inert
* for this seam.
*/
duplexAggregateByteLedger?: DuplexAggregateByteLedger | null;
}
type WorkspaceReadyCommentWriter = {
@@ -7887,6 +7896,7 @@ export function heartbeatService(
const envOrchestrator = environmentRunOrchestrator(db, {
pluginWorkerManager: options.pluginWorkerManager,
environmentRuntime,
duplexAggregateByteLedger: options.duplexAggregateByteLedger,
});
const workspaceOperationsSvc = workspaceOperationService(db);
const liveRunExecutions = {
@@ -0,0 +1,231 @@
import { describe, expect, it } from "vitest";
import type { Db } from "@paperclipai/db";
import {
buildIssueBlockersResolvedWakeIdempotencyKey,
buildIssueBlockersResolvedWakeStateKey,
buildIssueBlockersResolvedWakeStateKeyWithoutCycle,
findExistingIssueBlockersResolvedWakeForReadyState,
} from "./issue-dependency-wakeups.js";
const dependentIssueId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const blockerIssueId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const companyId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
const firstCycle = new Date("2026-04-01T12:00:00.000Z");
const secondCycle = new Date("2026-08-01T09:30:00.000Z");
type WakeRow = {
id: string;
status: string;
idempotencyKey: string | null;
requestedAt: Date;
};
function dbWithWakes(rows: WakeRow[]): Db {
return {
select() {
return {
from() {
return {
where() {
return Promise.resolve(rows);
},
};
},
};
},
} as unknown as Db;
}
describe("buildIssueBlockersResolvedWakeStateKey", () => {
it("is identical for the same dependent, blockers, and blockedTransitionAt", () => {
const first = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: firstCycle,
});
const second = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: firstCycle.toISOString(),
});
expect(first).toBe(second);
expect(first).toContain(dependentIssueId);
});
it("changes when blockedTransitionAt changes", () => {
const first = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: firstCycle,
});
const second = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: secondCycle,
});
expect(first).not.toBe(second);
});
it("hashes a null cycle as none and differs from any timestamp", () => {
const noneKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: null,
});
const omittedKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
});
const datedKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: firstCycle,
});
expect(noneKey).toBe(omittedKey);
expect(noneKey).not.toBe(datedKey);
expect(noneKey).not.toBe(
buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
}),
);
});
});
describe("findExistingIssueBlockersResolvedWakeForReadyState", () => {
const readyState = {
companyId,
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: secondCycle,
};
it("suppresses a completed wake on the cycle-aware state key", async () => {
const cycleKey = buildIssueBlockersResolvedWakeStateKey(readyState);
const existing = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-cycle",
status: "completed",
idempotencyKey: cycleKey,
requestedAt: secondCycle,
},
]),
readyState,
);
expect(existing?.id).toBe("wake-cycle");
});
it("does not let a completed old-key wake from a previous blocked cycle suppress", async () => {
const oldKey = buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
});
const existing = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-old-previous-cycle",
status: "completed",
idempotencyKey: oldKey,
requestedAt: firstCycle,
},
]),
readyState,
);
expect(existing).toBeNull();
});
it("suppresses a completed old-key wake requested at or after blockedTransitionAt", async () => {
const oldKey = buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
});
const existing = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-old-same-cycle",
status: "completed",
idempotencyKey: oldKey,
requestedAt: secondCycle,
},
]),
readyState,
);
expect(existing?.id).toBe("wake-old-same-cycle");
});
it("suppresses a completed old-key wake when blockedTransitionAt is null", async () => {
const oldKey = buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
});
const existing = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-old-no-cycle",
status: "completed",
idempotencyKey: oldKey,
requestedAt: firstCycle,
},
]),
{
companyId,
dependentIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt: null,
},
);
expect(existing?.id).toBe("wake-old-no-cycle");
});
it("suppresses an in-flight old-key wake across a later blocked cycle", async () => {
const oldKey = buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
dependentIssueId,
blockerIssueIds: [blockerIssueId],
});
const existing = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-old-queued",
status: "queued",
idempotencyKey: oldKey,
requestedAt: firstCycle,
},
]),
readyState,
);
expect(existing?.id).toBe("wake-old-queued");
});
it("keeps legacy per-edge matching in-flight only", async () => {
const legacyKey = buildIssueBlockersResolvedWakeIdempotencyKey({
dependentIssueId,
resolvedBlockerIssueId: blockerIssueId,
});
const inFlight = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-legacy-claimed",
status: "claimed",
idempotencyKey: legacyKey,
requestedAt: firstCycle,
},
]),
readyState,
);
expect(inFlight?.id).toBe("wake-legacy-claimed");
const completed = await findExistingIssueBlockersResolvedWakeForReadyState(
dbWithWakes([
{
id: "wake-legacy-completed",
status: "completed",
idempotencyKey: legacyKey,
requestedAt: firstCycle,
},
]),
readyState,
);
expect(completed).toBeNull();
});
});
+151 -51
View File
@@ -1,4 +1,4 @@
import { and, eq, inArray, or } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import { createHash } from "node:crypto";
import type { Db } from "@paperclipai/db";
import { agentWakeupRequests } from "@paperclipai/db";
@@ -19,14 +19,62 @@ const IDEMPOTENT_DEPENDENCY_WAKE_STATUSES = [
// A wake counts as "still in flight" for these statuses. The `completed` status
// is not in this set on purpose. Dependency readiness is level-triggered, so a
// historical completed per-edge wake must never suppress a new wake for the
// current ready state. The dedup uses this set only for the legacy per-edge key,
// to avoid a duplicate while an old-format wake is still queued or claimed.
// current ready state. The dedup uses this set only for the legacy per-edge key
// and for old no-cycle state keys that are still queued after a deploy.
const IN_FLIGHT_DEPENDENCY_WAKE_STATUSES = [
"queued",
"deferred_issue_execution",
"claimed",
] as const;
const IDEMPOTENT_DEPENDENCY_WAKE_STATUS_SET = new Set<string>(IDEMPOTENT_DEPENDENCY_WAKE_STATUSES);
const IN_FLIGHT_DEPENDENCY_WAKE_STATUS_SET = new Set<string>(IN_FLIGHT_DEPENDENCY_WAKE_STATUSES);
export type IssueBlockersResolvedWakeCycleInput = Date | string | null | undefined;
export type IssueBlockersResolvedReadyStateInput = {
dependentIssueId: string;
blockerIssueIds: string[];
blockedTransitionAt?: IssueBlockersResolvedWakeCycleInput;
};
/**
* Canonical blocked-cycle stamp for the dependency-ready state key.
* `blockedTransitionAt` is UTC ISO-8601, or `none` when the dependent has no
* recorded transition into `blocked`.
*/
export function formatIssueBlockersResolvedWakeCycle(
blockedTransitionAt: IssueBlockersResolvedWakeCycleInput,
): string {
if (blockedTransitionAt == null || blockedTransitionAt === "") return "none";
const parsed = blockedTransitionAt instanceof Date
? blockedTransitionAt
: new Date(blockedTransitionAt);
if (Number.isNaN(parsed.getTime())) return "none";
return parsed.toISOString();
}
function uniqueSortedBlockerIssueIds(blockerIssueIds: string[]): string[] {
return [...new Set(blockerIssueIds.filter(Boolean))].sort();
}
function hashBlockerReadyStateDigest(sortedBlockerIssueIds: string[], cycle: string | null): string {
const payload = cycle == null
? sortedBlockerIssueIds.join(",")
: `${sortedBlockerIssueIds.join(",")}\n${cycle}`;
return createHash("sha256").update(payload).digest("hex").slice(0, 32);
}
function buildStateKey(dependentIssueId: string, digest: string, blockerCount: number): string {
return [
ISSUE_BLOCKERS_RESOLVED_WAKE_REASON,
"state",
dependentIssueId,
String(blockerCount),
digest,
].join(":");
}
/**
* Legacy per-edge idempotency key. One key encodes a single resolved blocker
* edge `issue_blockers_resolved:{dependentIssueId}:{resolvedBlockerIssueId}`.
@@ -45,42 +93,96 @@ export function buildIssueBlockersResolvedWakeIdempotencyKey(input: {
}
/**
* Level-triggered idempotency key. One key encodes the full set of blockers that
* defines the current dependency-ready state. Two wakes for the same ready state
* share the key. A wake for an earlier partial state has a different blocker set,
* so it produces a different key and never suppresses the current wake. All three
* emit paths (route-time, finalize-time, periodic backstop) use this key so they
* share one idempotency rule.
* Pre-cycle level-triggered key. Rows written before the ready state included
* `blockedTransitionAt` hashed only the sorted blocker ids. Lookup still reads
* this format so an in-flight deploy-overlap wake can suppress a duplicate.
*/
export function buildIssueBlockersResolvedWakeStateKey(input: {
export function buildIssueBlockersResolvedWakeStateKeyWithoutCycle(input: {
dependentIssueId: string;
blockerIssueIds: string[];
}) {
const sortedBlockerIssueIds = [...new Set(input.blockerIssueIds.filter(Boolean))].sort();
const digest = createHash("sha256")
.update(sortedBlockerIssueIds.join(","))
.digest("hex")
.slice(0, 32);
return [
ISSUE_BLOCKERS_RESOLVED_WAKE_REASON,
"state",
const sortedBlockerIssueIds = uniqueSortedBlockerIssueIds(input.blockerIssueIds);
return buildStateKey(
input.dependentIssueId,
String(sortedBlockerIssueIds.length),
digest,
].join(":");
hashBlockerReadyStateDigest(sortedBlockerIssueIds, null),
sortedBlockerIssueIds.length,
);
}
/**
* Level-triggered idempotency key. One key encodes the full set of blockers that
* defines the current dependency-ready state plus the dependent's current
* blocked cycle (`blockedTransitionAt`, or `none`). Two wakes for the same ready
* state share the key. A wake from an earlier blocked cycle has a different
* cycle stamp, so it produces a different key and never suppresses the current
* wake. All three emit paths (route-time, finalize-time, periodic backstop) use
* this key so they share one idempotency rule.
*/
export function buildIssueBlockersResolvedWakeStateKey(input: IssueBlockersResolvedReadyStateInput) {
const sortedBlockerIssueIds = uniqueSortedBlockerIssueIds(input.blockerIssueIds);
const cycle = formatIssueBlockersResolvedWakeCycle(input.blockedTransitionAt);
return buildStateKey(
input.dependentIssueId,
hashBlockerReadyStateDigest(sortedBlockerIssueIds, cycle),
sortedBlockerIssueIds.length,
);
}
function parseWakeCycleDate(blockedTransitionAt: IssueBlockersResolvedWakeCycleInput): Date | null {
if (blockedTransitionAt == null || blockedTransitionAt === "") return null;
const parsed = blockedTransitionAt instanceof Date
? blockedTransitionAt
: new Date(blockedTransitionAt);
if (Number.isNaN(parsed.getTime())) return null;
return parsed;
}
function wakeCoversIssueBlockersResolvedReadyState(
wake: {
status: string;
idempotencyKey: string | null;
requestedAt: Date;
},
keys: {
cycleKey: string;
oldStateKey: string;
legacyKeys: Set<string>;
blockedTransitionAt: Date | null;
},
): boolean {
const idempotencyKey = wake.idempotencyKey;
if (!idempotencyKey) return false;
if (idempotencyKey === keys.cycleKey) {
return IDEMPOTENT_DEPENDENCY_WAKE_STATUS_SET.has(wake.status);
}
if (idempotencyKey === keys.oldStateKey) {
if (IN_FLIGHT_DEPENDENCY_WAKE_STATUS_SET.has(wake.status)) return true;
if (wake.status !== "completed") return false;
if (!keys.blockedTransitionAt) return true;
return wake.requestedAt.getTime() >= keys.blockedTransitionAt.getTime();
}
if (keys.legacyKeys.has(idempotencyKey)) {
return IN_FLIGHT_DEPENDENCY_WAKE_STATUS_SET.has(wake.status);
}
return false;
}
/**
* Find a wake that already covers the current dependency-ready state of the
* dependent issue. The check is level-triggered:
* dependent issue. The check is level-triggered and cycle-aware:
*
* - The state key matches a wake in any idempotent status (including
* `completed`). This suppresses a duplicate wake for the SAME ready state and
* bounds reconciliation.
* - Each legacy per-edge key matches only a wake that is still in flight
* (`queued`, `deferred_issue_execution`, `claimed`). This prevents a duplicate
* wake while an old-format wake is still pending after a deploy, but it never
* lets a historical completed per-edge wake strand the issue.
* - The cycle-aware state key matches a wake in any idempotent status
* (including `completed`). This suppresses a duplicate for the SAME ready
* state, including the current blocked cycle.
* - The old no-cycle state key matches in-flight statuses (deploy overlap),
* or a `completed` wake whose `requestedAt` is at or after the current
* `blockedTransitionAt` (same cycle). A completed old-key wake from a
* previous cycle does not suppress.
* - Each legacy per-edge key matches only a wake that is still in flight.
*
* Returns the first matching wake or `null`.
*/
@@ -90,13 +192,12 @@ export async function findExistingIssueBlockersResolvedWakeForReadyState(
companyId: string;
dependentIssueId: string;
blockerIssueIds: string[];
blockedTransitionAt?: IssueBlockersResolvedWakeCycleInput;
},
) {
const stateKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: input.dependentIssueId,
blockerIssueIds: input.blockerIssueIds,
});
const legacyKeys = [
const cycleKey = buildIssueBlockersResolvedWakeStateKey(input);
const oldStateKey = buildIssueBlockersResolvedWakeStateKeyWithoutCycle(input);
const legacyKeyList = [
...new Set(
input.blockerIssueIds
.filter(Boolean)
@@ -108,32 +209,31 @@ export async function findExistingIssueBlockersResolvedWakeForReadyState(
),
),
];
const lookupKeys = [...new Set([cycleKey, oldStateKey, ...legacyKeyList])];
const blockedTransitionAt = parseWakeCycleDate(input.blockedTransitionAt);
const stateMatch = and(
eq(agentWakeupRequests.idempotencyKey, stateKey),
inArray(agentWakeupRequests.status, [...IDEMPOTENT_DEPENDENCY_WAKE_STATUSES]),
);
const legacyMatch =
legacyKeys.length > 0
? and(
inArray(agentWakeupRequests.idempotencyKey, legacyKeys),
inArray(agentWakeupRequests.status, [...IN_FLIGHT_DEPENDENCY_WAKE_STATUSES]),
)
: null;
return db
const rows = await db
.select({
id: agentWakeupRequests.id,
status: agentWakeupRequests.status,
idempotencyKey: agentWakeupRequests.idempotencyKey,
requestedAt: agentWakeupRequests.requestedAt,
})
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, input.companyId),
legacyMatch ? or(stateMatch, legacyMatch) : stateMatch,
inArray(agentWakeupRequests.idempotencyKey, lookupKeys),
),
)
.limit(1)
.then((rows) => rows[0] ?? null);
);
const covering = rows.find((row) =>
wakeCoversIssueBlockersResolvedReadyState(row, {
cycleKey,
oldStateKey,
legacyKeys: new Set(legacyKeyList),
blockedTransitionAt,
}),
);
return covering ?? null;
}
+2
View File
@@ -6608,6 +6608,7 @@ export function issueService(db: Db) {
id: issues.id,
assigneeAgentId: issues.assigneeAgentId,
status: issues.status,
blockedTransitionAt: issues.blockedTransitionAt,
})
.from(issueRelations)
.innerJoin(issues, eq(issueRelations.relatedIssueId, issues.id))
@@ -6647,6 +6648,7 @@ export function issueService(db: Db) {
id: candidate.id,
assigneeAgentId: candidate.assigneeAgentId!,
blockerIssueIds: readiness.blockerIssueIds,
blockedTransitionAt: candidate.blockedTransitionAt,
}));
},
+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);
}
}
File diff suppressed because it is too large. Load diff
+4
View File
@@ -5725,6 +5725,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
companyId: issues.companyId,
identifier: issues.identifier,
assigneeAgentId: issues.assigneeAgentId,
blockedTransitionAt: issues.blockedTransitionAt,
totalCount: sql<number>`count(*) over()::int`,
})
.from(issueRelations)
@@ -5740,6 +5741,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
companyId: issues.companyId,
identifier: issues.identifier,
assigneeAgentId: issues.assigneeAgentId,
blockedTransitionAt: issues.blockedTransitionAt,
totalCount: sql<number>`count(*) over()::int`,
})
.from(issues)
@@ -5813,11 +5815,13 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
const idempotencyKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: candidate.id,
blockerIssueIds: readiness.blockerIssueIds,
blockedTransitionAt: candidate.blockedTransitionAt,
});
const existingWake = await findExistingIssueBlockersResolvedWakeForReadyState(db, {
companyId,
dependentIssueId: candidate.id,
blockerIssueIds: readiness.blockerIssueIds,
blockedTransitionAt: candidate.blockedTransitionAt,
});
if (existingWake) {
result.existingWakeSkipped += 1;
@@ -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,
});
};
};
}