mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 21:59:38 +01:00
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:
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 ?? "");
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
},
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user