mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +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
@@ -73,6 +73,10 @@ const mockEnvironmentRuntime = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockResolveEnvironmentExecutionTarget = vi.hoisted(() => vi.fn());
|
||||
// The provider capability resolver the device-login gate reads. A supported
|
||||
// provider advertises the login pseudo-terminal capability. A test overrides this
|
||||
// to prove an unsupported provider fails closed before any session or lease.
|
||||
const mockResolvePluginSandboxProviderDriverByKey = vi.hoisted(() => vi.fn());
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
|
||||
}));
|
||||
@@ -136,6 +140,16 @@ vi.mock("../services/environment-runtime.js", () => ({
|
||||
environmentRuntimeService: () => mockEnvironmentRuntime,
|
||||
}));
|
||||
|
||||
vi.mock("../services/plugin-environment-driver.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../services/plugin-environment-driver.js")>(
|
||||
"../services/plugin-environment-driver.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../services/environment-execution-target.js", () => ({
|
||||
resolveEnvironmentExecutionTarget: mockResolveEnvironmentExecutionTarget,
|
||||
}));
|
||||
@@ -263,8 +277,8 @@ function createFakeRuntime(): LoginSessionRuntime {
|
||||
providerLeaseId: `provider-lease-${input.sessionId}`,
|
||||
authPath: `/tmp/paperclip-adapter-login/${input.sessionId}/auth.json`,
|
||||
driver: {
|
||||
async execStreaming(_command, onStdout) {
|
||||
onStdout(PROMPT_OUTPUT);
|
||||
async start(_command, onData) {
|
||||
onData(PROMPT_OUTPUT);
|
||||
await harness.gate;
|
||||
return { exitCode: 0 };
|
||||
},
|
||||
@@ -340,6 +354,14 @@ describe("adapter device-login routes", () => {
|
||||
config: { provider: "daytona" },
|
||||
envVars: {},
|
||||
}));
|
||||
// The default provider advertises the login pseudo-terminal capability. A
|
||||
// test overrides this to prove an unsupported provider fails closed.
|
||||
mockResolvePluginSandboxProviderDriverByKey.mockImplementation(
|
||||
async ({ driverKey }: { driverKey: string }) =>
|
||||
driverKey === "daytona"
|
||||
? { plugin: { id: "plugin-daytona" }, driver: { supportsLoginPty: true } }
|
||||
: null,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -396,10 +418,10 @@ describe("adapter device-login routes", () => {
|
||||
expect(store.rows.get(res.body.sessionId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects an adapter whose login capability drives a different transport", async () => {
|
||||
// The Claude adapter declares a pseudo-terminal login, not a streamed-exec
|
||||
// device login. The guard reads the capability transport, so it rejects the
|
||||
// adapter with a fixed 400 before any lease.
|
||||
it("rejects an adapter whose login capability drives a different panel mode", async () => {
|
||||
// The Claude adapter declares a submitted-browser-code login, not a
|
||||
// displayed-code device login. The guard reads the capability panel mode, so
|
||||
// it rejects the adapter with a fixed 400 before any lease.
|
||||
const app = await createApp();
|
||||
|
||||
const res = await request(app)
|
||||
@@ -410,12 +432,14 @@ describe("adapter device-login routes", () => {
|
||||
expect(harness.acquisitions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("starts a device login for a third adapter that declares the streamed-exec capability", async () => {
|
||||
// A third adapter, not the Codex adapter, declares a streamed-exec login
|
||||
// capability. The guard reads the registry capability, not the adapter name,
|
||||
// so the adapter passes the guard and starts a session. This proves no
|
||||
// adapter-name branch remains in the guard path. The test overrides an
|
||||
// existing adapter type so the strict request schema accepts it.
|
||||
it("rejects a displayed-code adapter that has no mapped login command key", async () => {
|
||||
// A third adapter declares a displayed-code login capability, but its adapter
|
||||
// type maps to no login command key. The login opener resolves the command
|
||||
// key from the closed command map, so this adapter would fail at command
|
||||
// resolution after the route creates session state. The guard keeps admission
|
||||
// consistent with the command map, so it rejects the adapter with a fixed 400
|
||||
// before any lease. The test overrides an existing adapter type so the strict
|
||||
// request schema accepts it.
|
||||
const app = await createApp();
|
||||
const { registerServerAdapter, unregisterServerAdapter } = await import("../adapters/index.js");
|
||||
registerServerAdapter({
|
||||
@@ -428,7 +452,6 @@ describe("adapter device-login routes", () => {
|
||||
},
|
||||
loginCapability: {
|
||||
panelMode: "displayed_code",
|
||||
sandboxTransport: "streamed_exec",
|
||||
timeoutPolicy: "caller_bounded",
|
||||
getCommand: () => "vendor login",
|
||||
parsePrompt: () => null,
|
||||
@@ -439,15 +462,74 @@ describe("adapter device-login routes", () => {
|
||||
.post(loginPath(COMPANY_1, "gemini_local"))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(res.body).toMatchObject({ environmentId: SANDBOX_ENV_1, status: "starting" });
|
||||
expect(harness.acquisitions).toHaveLength(1);
|
||||
expect(harness.acquisitions[0]).toMatchObject({ adapterType: "gemini_local" });
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(400);
|
||||
expect(harness.acquisitions).toHaveLength(0);
|
||||
} finally {
|
||||
unregisterServerAdapter("gemini_local");
|
||||
}
|
||||
});
|
||||
|
||||
it("starts a device login for a non-Codex adapter that maps to a login command key", async () => {
|
||||
// A non-Codex adapter type that maps to a login command key declares a
|
||||
// displayed-code login capability. The guard reads the registry capability
|
||||
// and the command map, not the adapter name, so the adapter passes the guard
|
||||
// and starts a session. This proves no adapter-name branch remains in the
|
||||
// guard path. The test overrides the mapped `claude_local` type with a
|
||||
// displayed-code capability so the guard admits it.
|
||||
const app = await createApp();
|
||||
const { registerServerAdapter, unregisterServerAdapter } = await import("../adapters/index.js");
|
||||
registerServerAdapter({
|
||||
type: "claude_local",
|
||||
execute: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
testEnvironment: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
loginCapability: {
|
||||
panelMode: "displayed_code",
|
||||
timeoutPolicy: "caller_bounded",
|
||||
getCommand: () => "vendor login",
|
||||
parsePrompt: () => null,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post(loginPath(COMPANY_1, "claude_local"))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(res.body).toMatchObject({ environmentId: SANDBOX_ENV_1, status: "starting" });
|
||||
expect(harness.acquisitions).toHaveLength(1);
|
||||
expect(harness.acquisitions[0]).toMatchObject({ adapterType: "claude_local" });
|
||||
} finally {
|
||||
unregisterServerAdapter("claude_local");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a provider that does not advertise the login pseudo-terminal capability", async () => {
|
||||
// The device login runs on a real pseudo-terminal, so it needs a provider
|
||||
// that advertises the login pseudo-terminal capability. The provider resolves
|
||||
// to a driver with no capability, so the route gate fails closed before any
|
||||
// session row or lease.
|
||||
mockResolvePluginSandboxProviderDriverByKey.mockImplementation(
|
||||
async ({ driverKey }: { driverKey: string }) =>
|
||||
driverKey === "daytona"
|
||||
? { plugin: { id: "plugin-daytona" }, driver: { supportsLoginPty: false } }
|
||||
: null,
|
||||
);
|
||||
const app = await createApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(res.body).toMatchObject({ code: "codex_device_login_provider_unsupported" });
|
||||
// The gate ran before any session row or lease, so the runtime acquired none.
|
||||
expect(harness.acquisitions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a malformed start body with the strict schema before any side effect", async () => {
|
||||
const app = await createApp();
|
||||
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, symlinkSync, chmodSync, linkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
AUTH_JSON_FILE_NAME,
|
||||
DEVICE_LOGIN_AUTH_READ_ERROR,
|
||||
DEVICE_LOGIN_AUTH_READ_SCRIPT,
|
||||
MAX_AUTH_JSON_BYTES,
|
||||
decodeAuthReadOutput,
|
||||
runDescriptorBoundAuthRead,
|
||||
} from "../services/codex-device-login-credential-read.ts";
|
||||
|
||||
// The helper runs on node and walks the path with `/proc/self/fd`, which is
|
||||
// Linux only. A non-Linux host skips the script tests and keeps the pure-decode
|
||||
// tests. The login sandbox is Linux, so the walk runs there.
|
||||
const scriptRunnable = process.platform === "linux";
|
||||
const describeLinux = scriptRunnable ? describe : describe.skip;
|
||||
|
||||
if (!scriptRunnable) {
|
||||
console.warn(
|
||||
"Skipping descriptor-bound read script tests on this host: the /proc walk needs Linux.",
|
||||
);
|
||||
}
|
||||
|
||||
describe("decodeAuthReadOutput", () => {
|
||||
it("decodes strict base64 to the exact bytes", () => {
|
||||
const payload = Buffer.from('{"tokens":{"refresh_token":"r"}}', "utf8");
|
||||
const decoded = decodeAuthReadOutput(payload.toString("base64"));
|
||||
expect(decoded.equals(payload)).toBe(true);
|
||||
});
|
||||
|
||||
it("tolerates a trailing newline from the helper", () => {
|
||||
const payload = Buffer.from("abc", "utf8");
|
||||
const decoded = decodeAuthReadOutput(`${payload.toString("base64")}\n`);
|
||||
expect(decoded.toString("utf8")).toBe("abc");
|
||||
});
|
||||
|
||||
it("returns an empty buffer for empty output", () => {
|
||||
expect(decodeAuthReadOutput("").length).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a non-base64 payload", () => {
|
||||
expect(() => decodeAuthReadOutput("not*base64*text")).toThrow(DEVICE_LOGIN_AUTH_READ_ERROR);
|
||||
});
|
||||
|
||||
it("rejects a wrong-padding payload", () => {
|
||||
// A single stray character is not a multiple of four, so it is malformed.
|
||||
expect(() => decodeAuthReadOutput("QQ")).toThrow(DEVICE_LOGIN_AUTH_READ_ERROR);
|
||||
});
|
||||
|
||||
it("rejects a decoded payload over the bound", () => {
|
||||
const oversize = Buffer.alloc(MAX_AUTH_JSON_BYTES + 1, 0x61).toString("base64");
|
||||
expect(() => decodeAuthReadOutput(oversize)).toThrow(DEVICE_LOGIN_AUTH_READ_ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
describeLinux("the descriptor-bound read helper script", () => {
|
||||
let root: string;
|
||||
|
||||
beforeAll(() => {
|
||||
root = mkdtempSync(path.join(tmpdir(), "codex-auth-read-"));
|
||||
});
|
||||
afterAll(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Run the helper against `home`. An explicit `expectedUid` forces the owner
|
||||
// check; the production caller omits it and the helper uses its own uid.
|
||||
function runScript(home: string, expectedUid?: number): { status: number | null; stdout: string } {
|
||||
const args = ["-e", DEVICE_LOGIN_AUTH_READ_SCRIPT, home, String(MAX_AUTH_JSON_BYTES)];
|
||||
if (expectedUid !== undefined) args.push(String(expectedUid));
|
||||
const result = spawnSync("node", args, { encoding: "utf8" });
|
||||
return { status: result.status, stdout: result.stdout ?? "" };
|
||||
}
|
||||
|
||||
// Build a fresh session home with a `0700` directory. The caller writes the
|
||||
// credential file inside it.
|
||||
function makeHome(name: string): string {
|
||||
const home = path.join(root, name);
|
||||
mkdirSync(home, { mode: 0o700 });
|
||||
return home;
|
||||
}
|
||||
|
||||
it("reads a regular 0600 credential owned by the login user", () => {
|
||||
const home = makeHome("valid");
|
||||
const payload = '{"tokens":{"refresh_token":"r"}}';
|
||||
const file = path.join(home, AUTH_JSON_FILE_NAME);
|
||||
writeFileSync(file, payload, { mode: 0o600 });
|
||||
chmodSync(file, 0o600);
|
||||
const { status, stdout } = runScript(home);
|
||||
expect(status).toBe(0);
|
||||
expect(Buffer.from(stdout.trim(), "base64").toString("utf8")).toBe(payload);
|
||||
});
|
||||
|
||||
it("rejects a missing credential", () => {
|
||||
const home = makeHome("missing");
|
||||
expect(runScript(home).status).not.toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a symlink credential (a swap between the check and the read)", () => {
|
||||
// The attacker swaps `auth.json` for a symlink to a file outside the home.
|
||||
// The single no-follow open rejects the symlink, so the read never follows
|
||||
// it. There is no separate check to race.
|
||||
const home = makeHome("symlink");
|
||||
const secret = path.join(root, "outside-secret");
|
||||
writeFileSync(secret, "outside", { mode: 0o600 });
|
||||
symlinkSync(secret, path.join(home, AUTH_JSON_FILE_NAME));
|
||||
expect(runScript(home).status).not.toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a directory in the credential position", () => {
|
||||
const home = makeHome("dir");
|
||||
mkdirSync(path.join(home, AUTH_JSON_FILE_NAME), { mode: 0o700 });
|
||||
expect(runScript(home).status).not.toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a wrong-mode credential", () => {
|
||||
const home = makeHome("mode");
|
||||
const file = path.join(home, AUTH_JSON_FILE_NAME);
|
||||
writeFileSync(file, "{}", { mode: 0o644 });
|
||||
chmodSync(file, 0o644);
|
||||
expect(runScript(home).status).not.toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a wrong-owner credential", () => {
|
||||
const home = makeHome("owner");
|
||||
const file = path.join(home, AUTH_JSON_FILE_NAME);
|
||||
writeFileSync(file, "{}", { mode: 0o600 });
|
||||
chmodSync(file, 0o600);
|
||||
// Force a uid that differs from the file owner. The check rejects it.
|
||||
const bogusUid = process.getuid ? process.getuid()! + 1 : 999999;
|
||||
expect(runScript(home, bogusUid).status).not.toBe(0);
|
||||
});
|
||||
|
||||
it("rejects an oversize credential", () => {
|
||||
const home = makeHome("oversize");
|
||||
const file = path.join(home, AUTH_JSON_FILE_NAME);
|
||||
writeFileSync(file, Buffer.alloc(MAX_AUTH_JSON_BYTES + 1, 0x61), { mode: 0o600 });
|
||||
chmodSync(file, 0o600);
|
||||
expect(runScript(home).status).not.toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a symlink session home", () => {
|
||||
const real = makeHome("real-home");
|
||||
const file = path.join(real, AUTH_JSON_FILE_NAME);
|
||||
writeFileSync(file, "{}", { mode: 0o600 });
|
||||
chmodSync(file, 0o600);
|
||||
const link = path.join(root, "home-symlink");
|
||||
symlinkSync(real, link);
|
||||
expect(runScript(link).status).not.toBe(0);
|
||||
});
|
||||
|
||||
it("rejects an intermediate-path symlink ancestor and never follows it", () => {
|
||||
// The attacker swaps an ancestor directory of the session home for a symlink
|
||||
// to an attacker-controlled tree. That tree holds a valid-looking 0600
|
||||
// credential. A single composite-path open with `O_NOFOLLOW` would follow the
|
||||
// ancestor symlink, because `O_NOFOLLOW` protects only the final component.
|
||||
// The per-component walk opens the ancestor with its own no-follow open, so
|
||||
// the read fails closed and returns no bytes.
|
||||
const evil = path.join(root, "evil-target");
|
||||
mkdirSync(path.join(evil, "session"), { recursive: true, mode: 0o700 });
|
||||
const file = path.join(evil, "session", AUTH_JSON_FILE_NAME);
|
||||
writeFileSync(file, '{"stolen":true}', { mode: 0o600 });
|
||||
chmodSync(file, 0o600);
|
||||
// `ancestor` is a symlink to the attacker tree. The home path routes through it.
|
||||
const ancestor = path.join(root, "ancestor-symlink");
|
||||
symlinkSync(evil, ancestor);
|
||||
const home = path.join(ancestor, "session");
|
||||
const { status, stdout } = runScript(home);
|
||||
expect(status).not.toBe(0);
|
||||
// The read leaked no bytes from the attacker tree.
|
||||
expect(stdout.trim()).toBe("");
|
||||
});
|
||||
|
||||
it("fails closed when an ancestor is swapped to a symlink between two reads", () => {
|
||||
// The first read sees a real ancestor and returns the private credential. An
|
||||
// attacker then swaps the ancestor for a symlink to an attacker tree with a
|
||||
// valid-looking credential. The second read walks the ancestor with a
|
||||
// no-follow open, so it fails closed and never returns the attacker bytes.
|
||||
const base = mkdtempSync(path.join(root, "swap-"));
|
||||
const mid = path.join(base, "mid");
|
||||
mkdirSync(path.join(mid, "session"), { recursive: true, mode: 0o700 });
|
||||
const goodFile = path.join(mid, "session", AUTH_JSON_FILE_NAME);
|
||||
writeFileSync(goodFile, '{"good":true}', { mode: 0o600 });
|
||||
chmodSync(goodFile, 0o600);
|
||||
const home = path.join(mid, "session");
|
||||
|
||||
const first = runScript(home);
|
||||
expect(first.status).toBe(0);
|
||||
expect(Buffer.from(first.stdout.trim(), "base64").toString("utf8")).toBe('{"good":true}');
|
||||
|
||||
// The attacker tree carries a same-shape credential.
|
||||
const evil = path.join(base, "evil");
|
||||
mkdirSync(path.join(evil, "session"), { recursive: true, mode: 0o700 });
|
||||
const evilFile = path.join(evil, "session", AUTH_JSON_FILE_NAME);
|
||||
writeFileSync(evilFile, '{"evil":true}', { mode: 0o600 });
|
||||
chmodSync(evilFile, 0o600);
|
||||
|
||||
// Swap the ancestor `mid` for a symlink to the attacker tree.
|
||||
rmSync(mid, { recursive: true, force: true });
|
||||
symlinkSync(evil, mid);
|
||||
|
||||
const second = runScript(home);
|
||||
expect(second.status).not.toBe(0);
|
||||
expect(second.stdout.trim()).toBe("");
|
||||
});
|
||||
|
||||
it("reads a hardlink credential inside the private home", () => {
|
||||
// A hardlink is not a symlink. The no-follow open allows it, and the fstat
|
||||
// sees a regular file. A hardlink inside the private `0700` home is not a
|
||||
// privilege escalation, so the read returns the linked inode content.
|
||||
const home = makeHome("hardlink");
|
||||
const backing = path.join(home, "backing.json");
|
||||
writeFileSync(backing, '{"ok":true}', { mode: 0o600 });
|
||||
chmodSync(backing, 0o600);
|
||||
linkSync(backing, path.join(home, AUTH_JSON_FILE_NAME));
|
||||
const { status, stdout } = runScript(home);
|
||||
expect(status).toBe(0);
|
||||
expect(Buffer.from(stdout.trim(), "base64").toString("utf8")).toBe('{"ok":true}');
|
||||
});
|
||||
});
|
||||
|
||||
describeLinux("runDescriptorBoundAuthRead", () => {
|
||||
let root: string;
|
||||
|
||||
beforeAll(() => {
|
||||
root = mkdtempSync(path.join(tmpdir(), "codex-auth-wrap-"));
|
||||
});
|
||||
afterAll(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// A fake environment runtime that runs the exact command and arguments the
|
||||
// wrapper composes. It exercises the real helper against a local home, so the
|
||||
// wrapper and the script are tested together. The session home shape must match
|
||||
// the validated pattern, so the fake maps the validated home onto a local
|
||||
// directory before it runs the helper.
|
||||
function createLocalRuntime(localHome: string) {
|
||||
return {
|
||||
async execute(input: { command: string; args?: string[]; bypassSession?: boolean }) {
|
||||
expect(input.command).toBe("node");
|
||||
expect(input.bypassSession).toBe(true);
|
||||
const args = [...(input.args ?? [])];
|
||||
// Replace the validated session-home argument with the local directory. The
|
||||
// wrapper composes `["-e", <script>, <sessionHome>, <maxBytes>]`, so the
|
||||
// session home is the third argument.
|
||||
args[2] = localHome;
|
||||
const result = spawnSync("node", args, { encoding: "utf8" });
|
||||
return { exitCode: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// A syntactically valid session home. The wrapper revalidates the shape.
|
||||
const VALID_HOME = "/tmp/paperclip-adapter-login/11111111-1111-1111-1111-111111111111";
|
||||
|
||||
it("returns the credential bytes on a valid read", async () => {
|
||||
const localHome = mkdtempSync(path.join(root, "valid-"));
|
||||
const payload = '{"tokens":{"refresh_token":"r"}}';
|
||||
const file = path.join(localHome, AUTH_JSON_FILE_NAME);
|
||||
writeFileSync(file, payload, { mode: 0o600 });
|
||||
chmodSync(file, 0o600);
|
||||
const bytes = await runDescriptorBoundAuthRead({
|
||||
environmentRuntime: createLocalRuntime(localHome) as never,
|
||||
environment: { id: "env", driver: "sandbox" } as never,
|
||||
lease: { id: "lease" } as never,
|
||||
sessionHome: VALID_HOME,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect(bytes.toString("utf8")).toBe(payload);
|
||||
});
|
||||
|
||||
it("returns the fixed error when the helper exits non-zero", async () => {
|
||||
const localHome = mkdtempSync(path.join(root, "missing-"));
|
||||
await expect(
|
||||
runDescriptorBoundAuthRead({
|
||||
environmentRuntime: createLocalRuntime(localHome) as never,
|
||||
environment: { id: "env", driver: "sandbox" } as never,
|
||||
lease: { id: "lease" } as never,
|
||||
sessionHome: VALID_HOME,
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
).rejects.toThrow(DEVICE_LOGIN_AUTH_READ_ERROR);
|
||||
});
|
||||
|
||||
it("rejects an invalid session-home shape before it runs the helper", async () => {
|
||||
let executeCalls = 0;
|
||||
const runtime = {
|
||||
async execute() {
|
||||
executeCalls += 1;
|
||||
return { exitCode: 0, stdout: "", stderr: "" };
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
runDescriptorBoundAuthRead({
|
||||
environmentRuntime: runtime as never,
|
||||
environment: { id: "env", driver: "sandbox" } as never,
|
||||
lease: { id: "lease" } as never,
|
||||
sessionHome: "/etc/passwd",
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
expect(executeCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,8 @@ const execHang: ExecBehavior = ({ onStdout }) => {
|
||||
interface FakeRuntimeOptions {
|
||||
exec: ExecBehavior;
|
||||
authBytes?: Buffer;
|
||||
/** When set, the descriptor-bound read rejects with this error on success. */
|
||||
readError?: Error;
|
||||
delete?: () => Promise<SandboxDeleteResult>;
|
||||
}
|
||||
|
||||
@@ -130,8 +132,11 @@ function createFakeRuntime(opts: FakeRuntimeOptions) {
|
||||
providerLeaseId: `lease-${input.sessionId}`,
|
||||
authPath: sessionCredentialPath(input.sessionId),
|
||||
driver: {
|
||||
execStreaming: (_command, onStdout) => opts.exec({ onStdout, input }),
|
||||
readFile: async () => opts.authBytes ?? Buffer.from("{}"),
|
||||
start: (_command, onData) => opts.exec({ onStdout: onData, input }),
|
||||
readFile: async () => {
|
||||
if (opts.readError) throw opts.readError;
|
||||
return opts.authBytes ?? Buffer.from("{}");
|
||||
},
|
||||
dispose: async () => {},
|
||||
},
|
||||
deleteSandbox: async () => {
|
||||
@@ -241,7 +246,7 @@ describe("codex device login service", () => {
|
||||
providerLeaseId: `lease-${input.sessionId}`,
|
||||
authPath: sessionCredentialPath(input.sessionId),
|
||||
driver: {
|
||||
execStreaming: (_command, onStdout) => execSuccess({ onStdout, input }),
|
||||
start: (_command, onData) => execSuccess({ onStdout: onData, input }),
|
||||
readFile: async () => Buffer.from("{}"),
|
||||
dispose: async () => {},
|
||||
},
|
||||
@@ -416,6 +421,73 @@ describe("codex device login service", () => {
|
||||
expect(deleteCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("fails closed and deletes the sandbox when the descriptor-bound read fails", async () => {
|
||||
const store = createMemoryStore();
|
||||
let promoteCalls = 0;
|
||||
const { runtime, deleteCalls } = createFakeRuntime({
|
||||
// The login command exits 0, but the descriptor-bound read rejects. The
|
||||
// runner converts the read error to a fixed driver error, so the run fails.
|
||||
exec: execSuccess,
|
||||
readError: new Error("device login failed: the sandbox credential read errored."),
|
||||
});
|
||||
const service = makeService({
|
||||
store,
|
||||
runtime,
|
||||
promotion: {
|
||||
promote: () => {
|
||||
promoteCalls += 1;
|
||||
},
|
||||
},
|
||||
});
|
||||
const companyId = randomUUID();
|
||||
const { session, completed } = await service.start({
|
||||
companyId,
|
||||
environmentId: randomUUID(),
|
||||
adapterType: ADAPTER_TYPE,
|
||||
startedByUserId: OWNER_A,
|
||||
});
|
||||
const outcome = await completed;
|
||||
// A failed read never promotes a credential and still deletes the sandbox.
|
||||
expect(outcome.status).toBe("failed");
|
||||
expect(promoteCalls).toBe(0);
|
||||
expect(deleteCalls).toHaveLength(1);
|
||||
const row = await store.getByPublicId(session.sessionId, companyId);
|
||||
expect(row?.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("fails closed and deletes the sandbox when the credential schema is malformed", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { runtime, deleteCalls } = createFakeRuntime({
|
||||
// The read returns a well-formed file, but the promotion rejects the shape.
|
||||
// A malformed schema fails the same way as a failed check: no authenticate,
|
||||
// and a sandbox delete.
|
||||
exec: execSuccess,
|
||||
authBytes: Buffer.from('{"not":"a subscription auth"}', "utf8"),
|
||||
});
|
||||
const service = makeService({
|
||||
store,
|
||||
runtime,
|
||||
promotion: {
|
||||
promote: () => {
|
||||
throw new Error("malformed credential schema");
|
||||
},
|
||||
},
|
||||
});
|
||||
const companyId = randomUUID();
|
||||
const { session, completed } = await service.start({
|
||||
companyId,
|
||||
environmentId: randomUUID(),
|
||||
adapterType: ADAPTER_TYPE,
|
||||
startedByUserId: OWNER_A,
|
||||
});
|
||||
const outcome = await completed;
|
||||
expect(outcome.status).toBe("failed");
|
||||
expect(deleteCalls).toHaveLength(1);
|
||||
const failed = await service.readOwnerSession(session.sessionId, companyId, OWNER_A);
|
||||
expect(failed?.status).toBe("failed");
|
||||
expect(failed?.failure?.reason).toBe("promotion_failed");
|
||||
});
|
||||
|
||||
it("deletes the sandbox and records a cancelled terminal on a cancellation", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { runtime, deleteCalls } = createFakeRuntime({ exec: execHang });
|
||||
@@ -789,45 +861,53 @@ describe("codex device login service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a production driver that sets an empty session home and reads the fixed credential path", async () => {
|
||||
// Record the program and the argument vector for each call. The provider
|
||||
// quotes each element as one shell token, so the driver must pass a program
|
||||
// plus its arguments, never one compound string. A one-shot exec streams no
|
||||
// output, so the login command must set `forceSession` to open the session.
|
||||
const calls: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
forceSession?: boolean;
|
||||
bypassSession?: boolean;
|
||||
}[] = [];
|
||||
it("runs the login over the shared pseudo-terminal and reads the credential with the descriptor-bound read", async () => {
|
||||
const sessionId = randomUUID();
|
||||
const sessionHome = sessionCodexHomePath(sessionId);
|
||||
const authPath = sessionCredentialPath(sessionId);
|
||||
|
||||
// The fake pseudo-terminal session streams the prompt and exits with code
|
||||
// zero. It records the command the transport opened.
|
||||
let openedCommand: string | null = null;
|
||||
let closed = false;
|
||||
const openPtySession = async (command: string) => {
|
||||
openedCommand = command;
|
||||
return {
|
||||
onData(listener: (chunk: string) => void) {
|
||||
listener(PROMPT_OUTPUT);
|
||||
},
|
||||
write() {},
|
||||
async wait() {
|
||||
return { exitCode: 0 };
|
||||
},
|
||||
kill() {},
|
||||
async close() {
|
||||
closed = true;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// The fake runtime runs only the descriptor-bound read. It records the fixed,
|
||||
// no-follow helper shape and returns the base64 of the credential bytes.
|
||||
const execCalls: { command: string; args?: string[]; bypassSession?: boolean }[] = [];
|
||||
const credential = '{"tokens":{"refresh_token":"r"}}';
|
||||
const environmentRuntime = {
|
||||
execute: async (input: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
forceSession?: boolean;
|
||||
bypassSession?: boolean;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => void | Promise<void>;
|
||||
}) => {
|
||||
calls.push({
|
||||
execute: async (input: { command: string; args?: string[]; bypassSession?: boolean }) => {
|
||||
execCalls.push({
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
forceSession: input.forceSession,
|
||||
bypassSession: input.bypassSession,
|
||||
});
|
||||
const script = input.args?.join(" ") ?? "";
|
||||
if (script.includes("codex login")) {
|
||||
await input.onLog?.("stdout", PROMPT_OUTPUT);
|
||||
return { exitCode: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
// The credential read.
|
||||
return { exitCode: 0, stdout: '{"token":"secret"}', stderr: "" };
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: Buffer.from(credential, "utf8").toString("base64"),
|
||||
stderr: "",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const driver = buildSandboxLoginDriver({
|
||||
// The helper only calls `execute`; a partial runtime is enough here.
|
||||
openPtySession,
|
||||
environmentRuntime: environmentRuntime as never,
|
||||
environment: { id: "env", driver: "sandbox" } as never,
|
||||
lease: { id: "lease" } as never,
|
||||
@@ -836,25 +916,28 @@ describe("codex device login service", () => {
|
||||
});
|
||||
|
||||
const chunks: string[] = [];
|
||||
const result = await driver.execStreaming("codex login --device-auth", (chunk) => chunks.push(chunk));
|
||||
const result = await driver.start("codex login --device-auth", (chunk) => chunks.push(chunk));
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(openedCommand).toBe("codex login --device-auth");
|
||||
expect(chunks.join("")).toContain(DEVICE_LOGIN_URL);
|
||||
// The login command runs through `sh -c`, so the runtime runs the whole
|
||||
// compound command in one shell. It sets an empty session-specific Codex
|
||||
// home before the login. It opens the session, so the prompt streams.
|
||||
expect(calls[0].command).toBe("sh");
|
||||
expect(calls[0].args).toEqual([
|
||||
"-c",
|
||||
`rm -rf ${sessionHome} && mkdir -p ${sessionHome} && CODEX_HOME=${sessionHome} codex login --device-auth`,
|
||||
]);
|
||||
expect(calls[0].forceSession).toBe(true);
|
||||
// The login runs over the pseudo-terminal, so the descriptor read did not run
|
||||
// during the login.
|
||||
expect(execCalls.length).toBe(0);
|
||||
|
||||
// The runner path passes the credential path, but the descriptor-bound read
|
||||
// ignores it and reads the fixed file under the verified session home.
|
||||
const authBytes = await driver.readFile(authPath);
|
||||
expect(authBytes.toString("utf8")).toBe('{"token":"secret"}');
|
||||
// The credential read runs `cat` with the path as one argument, one-shot.
|
||||
expect(calls[1].command).toBe("cat");
|
||||
expect(calls[1].args).toEqual([authPath]);
|
||||
expect(calls[1].bypassSession).toBe(true);
|
||||
expect(authBytes.toString("utf8")).toBe(credential);
|
||||
expect(execCalls.length).toBe(1);
|
||||
// The read runs the fixed no-follow helper as a one-shot, non-session command.
|
||||
expect(execCalls[0].command).toBe("node");
|
||||
expect(execCalls[0].bypassSession).toBe(true);
|
||||
expect(execCalls[0].args?.[0]).toBe("-e");
|
||||
// The third argument is the verified session home the helper opens no-follow.
|
||||
expect(execCalls[0].args?.[2]).toBe(sessionHome);
|
||||
|
||||
await driver.dispose();
|
||||
expect(closed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AgentAdapterType } from "@paperclipai/shared";
|
||||
|
||||
// The production runtime resolves the environment through `environmentService`.
|
||||
// These tests replace it with a fake, so the runtime runs with no database. The
|
||||
// fake returns a fixed environment and records the lease-metadata tag write.
|
||||
const mockEnvironmentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
updateLeaseMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/environments.js", () => ({
|
||||
environmentService: () => mockEnvironmentService,
|
||||
}));
|
||||
|
||||
import {
|
||||
createCodexWorkerBoundLoginPtyOpener,
|
||||
createProductionLoginSessionRuntime,
|
||||
sessionCodexHomePath,
|
||||
CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED,
|
||||
type LoginPtySessionBinding,
|
||||
} from "../services/codex-device-login-service.js";
|
||||
|
||||
// A fake worker pseudo-terminal session. It satisfies the shared transport
|
||||
// contract, so the opener can return it.
|
||||
function fakeWorkerSession() {
|
||||
return {
|
||||
onData() {},
|
||||
write() {},
|
||||
async wait() {
|
||||
return { exitCode: 0 };
|
||||
},
|
||||
kill() {},
|
||||
async close() {},
|
||||
};
|
||||
}
|
||||
|
||||
// A verified binding for one login session. The runtime builds it after it
|
||||
// acquires the lease. The tests set the server-controlled session home.
|
||||
function makeBinding(overrides: Partial<LoginPtySessionBinding> = {}): LoginPtySessionBinding {
|
||||
return {
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
adapterType: "codex_local" as AgentAdapterType,
|
||||
providerLeaseId: "provider-lease-9",
|
||||
sessionHome: sessionCodexHomePath(randomUUID()),
|
||||
environment: { id: "env-1", driver: "sandbox" } as never,
|
||||
lease: {
|
||||
id: "lease-1",
|
||||
metadata: { pluginId: "paperclip.daytona", provider: "daytona" },
|
||||
} as never,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("createCodexWorkerBoundLoginPtyOpener", () => {
|
||||
it("passes the binding's server-controlled session home to the worker route", async () => {
|
||||
const sessionHome = sessionCodexHomePath(randomUUID());
|
||||
const session = fakeWorkerSession();
|
||||
const openLoginPtySession = vi.fn(
|
||||
async (_pluginId: string, _input: Record<string, unknown>) => session,
|
||||
);
|
||||
const openLivePtySession = createCodexWorkerBoundLoginPtyOpener({
|
||||
workerManager: { openLoginPtySession },
|
||||
});
|
||||
|
||||
const opener = await openLivePtySession(makeBinding({ sessionHome }));
|
||||
// The opener ignores the incoming command argument. It resolves the closed
|
||||
// command key from the trusted adapter type.
|
||||
const opened = await opener("caller-supplied-ignored");
|
||||
|
||||
expect(openLoginPtySession).toHaveBeenCalledTimes(1);
|
||||
const [pluginId, input] = openLoginPtySession.mock.calls[0];
|
||||
expect(pluginId).toBe("paperclip.daytona");
|
||||
expect(input).toMatchObject({
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: "provider-lease-9",
|
||||
loginCommandKey: "codex",
|
||||
// The opener passes the binding's server-controlled home, not a fresh UUID
|
||||
// home. This home is the one the descriptor-bound credential read opens, so
|
||||
// the sandbox `CODEX_HOME` and the read target match.
|
||||
sessionHome,
|
||||
});
|
||||
expect(input).not.toHaveProperty("command");
|
||||
expect(opened).toBe(session);
|
||||
});
|
||||
|
||||
it("fails closed when the lease carries no sandbox worker binding", async () => {
|
||||
const openLoginPtySession = vi.fn();
|
||||
const openLivePtySession = createCodexWorkerBoundLoginPtyOpener({
|
||||
workerManager: { openLoginPtySession },
|
||||
});
|
||||
|
||||
await expect(
|
||||
openLivePtySession(makeBinding({ lease: { id: "lease-1", metadata: {} } as never })),
|
||||
).rejects.toThrow();
|
||||
expect(openLoginPtySession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when the adapter type has no login command key", async () => {
|
||||
const openLoginPtySession = vi.fn();
|
||||
const openLivePtySession = createCodexWorkerBoundLoginPtyOpener({
|
||||
workerManager: { openLoginPtySession },
|
||||
});
|
||||
|
||||
await expect(
|
||||
openLivePtySession(makeBinding({ adapterType: "gemini_local" as AgentAdapterType })),
|
||||
).rejects.toThrow();
|
||||
expect(openLoginPtySession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createProductionLoginSessionRuntime lease-acquisition gate", () => {
|
||||
const acquireInput = {
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
adapterType: "codex_local" as AgentAdapterType,
|
||||
startedByUserId: "user-a",
|
||||
};
|
||||
|
||||
it("re-checks the provider capability and fails before the provider lease when it is unsupported", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "env-1",
|
||||
driver: "sandbox",
|
||||
config: { provider: "daytona" },
|
||||
});
|
||||
const acquireRunLease = vi.fn();
|
||||
// The capability flipped to unsupported after the route gate. The lease gate
|
||||
// reads the current capability and fails closed.
|
||||
const assertProviderSupportsLoginPty = vi.fn(async () => {
|
||||
throw new Error(CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED);
|
||||
});
|
||||
const runtime = createProductionLoginSessionRuntime({
|
||||
db: {} as never,
|
||||
environmentRuntime: { acquireRunLease, getDriver: vi.fn() } as never,
|
||||
assertProviderSupportsLoginPty,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runtime.acquireLoginLease({ ...acquireInput, sessionId: randomUUID() }),
|
||||
).rejects.toThrow(CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED);
|
||||
// The runtime re-checked the current capability from the environment id.
|
||||
expect(assertProviderSupportsLoginPty).toHaveBeenCalledWith("env-1");
|
||||
// The gate failed before the provider lease, so no lease was acquired.
|
||||
expect(acquireRunLease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("acquires the provider lease after the capability check passes", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "env-1",
|
||||
driver: "sandbox",
|
||||
config: { provider: "daytona" },
|
||||
});
|
||||
mockEnvironmentService.updateLeaseMetadata.mockResolvedValue(undefined);
|
||||
const lease = { id: "lease-1", providerLeaseId: "provider-lease-1", metadata: {} };
|
||||
const acquireRunLease = vi.fn(async () => ({
|
||||
lease,
|
||||
environment: { id: "env-1", driver: "sandbox" },
|
||||
}));
|
||||
const assertProviderSupportsLoginPty = vi.fn(async () => {});
|
||||
const runtime = createProductionLoginSessionRuntime({
|
||||
db: {} as never,
|
||||
environmentRuntime: { acquireRunLease, getDriver: vi.fn() } as never,
|
||||
assertProviderSupportsLoginPty,
|
||||
});
|
||||
|
||||
const acquired = await runtime.acquireLoginLease({ ...acquireInput, sessionId: randomUUID() });
|
||||
|
||||
expect(assertProviderSupportsLoginPty).toHaveBeenCalledWith("env-1");
|
||||
// The gate passed, so the runtime acquired the provider lease after it.
|
||||
expect(acquireRunLease).toHaveBeenCalledTimes(1);
|
||||
expect(acquired.providerLeaseId).toBe("provider-lease-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES } from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
|
||||
import { resolveDuplexAggregateCeilingBytesFromEnv } from "../duplex-aggregate-ceiling-env.js";
|
||||
|
||||
describe("resolveDuplexAggregateCeilingBytesFromEnv", () => {
|
||||
it("uses the safe default and reports nothing when the variable is absent", () => {
|
||||
const onRejectedOverride = vi.fn();
|
||||
|
||||
const resolved = resolveDuplexAggregateCeilingBytesFromEnv(undefined, onRejectedOverride);
|
||||
|
||||
expect(resolved).toBe(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES);
|
||||
expect(onRejectedOverride).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats a whitespace-only value as a present invalid override, not as absent", () => {
|
||||
const rawOverride = " ";
|
||||
const onRejectedOverride = vi.fn();
|
||||
|
||||
// The resolver must continue with the safe default. Startup does not fail.
|
||||
const resolved = resolveDuplexAggregateCeilingBytesFromEnv(rawOverride, onRejectedOverride);
|
||||
|
||||
expect(resolved).toBe(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES);
|
||||
// The rejection reporter fires one time. A blank value is now visible as
|
||||
// invalid instead of silent as absent.
|
||||
expect(onRejectedOverride).toHaveBeenCalledTimes(1);
|
||||
// The reporter receives only the parsed number. The raw whitespace string
|
||||
// never reaches the reporter, so no operator-supplied text can reach a log
|
||||
// line.
|
||||
const reportedValue = onRejectedOverride.mock.calls[0]?.[0];
|
||||
expect(typeof reportedValue).toBe("number");
|
||||
expect(reportedValue).not.toBe(rawOverride);
|
||||
expect(Number.isFinite(reportedValue)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats an empty-string value as a present invalid override", () => {
|
||||
const onRejectedOverride = vi.fn();
|
||||
|
||||
const resolved = resolveDuplexAggregateCeilingBytesFromEnv("", onRejectedOverride);
|
||||
|
||||
expect(resolved).toBe(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES);
|
||||
expect(onRejectedOverride).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects a present non-numeric value and uses the safe default", () => {
|
||||
const onRejectedOverride = vi.fn();
|
||||
|
||||
const resolved = resolveDuplexAggregateCeilingBytesFromEnv("not-a-number", onRejectedOverride);
|
||||
|
||||
expect(resolved).toBe(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES);
|
||||
expect(onRejectedOverride).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes a valid override through unchanged", () => {
|
||||
const onRejectedOverride = vi.fn();
|
||||
|
||||
const resolved = resolveDuplexAggregateCeilingBytesFromEnv("1048576", onRejectedOverride);
|
||||
|
||||
expect(resolved).toBe(1048576);
|
||||
expect(onRejectedOverride).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a valid override that carries surrounding whitespace", () => {
|
||||
const onRejectedOverride = vi.fn();
|
||||
|
||||
const resolved = resolveDuplexAggregateCeilingBytesFromEnv(" 1048576 ", onRejectedOverride);
|
||||
|
||||
expect(resolved).toBe(1048576);
|
||||
expect(onRejectedOverride).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,11 @@ vi.mock("../services/plugin-environment-driver.js", async (importActual) => ({
|
||||
}));
|
||||
|
||||
import type { EffectiveSandboxCapabilities } from "@paperclipai/adapter-utils/execution-target";
|
||||
import { adapterExecutionTargetDuplexAggregateByteLedger } from "@paperclipai/adapter-utils/execution-target";
|
||||
import {
|
||||
DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
|
||||
DuplexAggregateByteLedger,
|
||||
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
|
||||
import { createSshCommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/ssh";
|
||||
import type { Environment, EnvironmentLease } from "@paperclipai/shared";
|
||||
import { resolveEnvironmentExecutionTarget } from "../services/environment-execution-target.js";
|
||||
@@ -293,6 +298,50 @@ describe("EnvironmentRuntimeService.openDuplexChannel capability gate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sandbox execution target aggregate byte ledger stamp", () => {
|
||||
it("stamps the injected ledger on the target with object identity", async () => {
|
||||
const ledger = new DuplexAggregateByteLedger({
|
||||
ceilingBytes: DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
|
||||
});
|
||||
const target = await resolveEnvironmentExecutionTarget({
|
||||
db: {} as never,
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
environment: { id: "env-1", driver: "sandbox", config: { provider: "daytona" } },
|
||||
leaseId: "lease-1",
|
||||
leaseMetadata: { remoteCwd: "/work" },
|
||||
lease: { id: "lease-1", leasePolicy: "reuse_by_environment" } as never,
|
||||
duplexAggregateByteLedger: ledger,
|
||||
});
|
||||
if (!target || target.kind !== "remote" || target.transport !== "sandbox") {
|
||||
throw new Error("expected a sandbox execution target");
|
||||
}
|
||||
// The stamped field and the accessor both return the one injected object, so
|
||||
// one process-owned ledger reaches the host bridge seam.
|
||||
expect(target.duplexAggregateByteLedger).toBe(ledger);
|
||||
expect(adapterExecutionTargetDuplexAggregateByteLedger(target)).toBe(ledger);
|
||||
});
|
||||
|
||||
it("leaves the ledger absent when the host injects none", async () => {
|
||||
const target = await resolveEnvironmentExecutionTarget({
|
||||
db: {} as never,
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
environment: { id: "env-1", driver: "sandbox", config: { provider: "daytona" } },
|
||||
leaseId: "lease-1",
|
||||
leaseMetadata: { remoteCwd: "/work" },
|
||||
lease: { id: "lease-1", leasePolicy: "reuse_by_environment" } as never,
|
||||
});
|
||||
if (!target || target.kind !== "remote" || target.transport !== "sandbox") {
|
||||
throw new Error("expected a sandbox execution target");
|
||||
}
|
||||
// A run with no injected ledger keeps the seam null; the accessor never makes
|
||||
// a fresh ledger.
|
||||
expect(target.duplexAggregateByteLedger).toBeNull();
|
||||
expect(adapterExecutionTargetDuplexAggregateByteLedger(target)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Build a sandbox execution target with a fixed capability snapshot and a fake
|
||||
// environment runtime whose openDuplexChannel is a spy. The helper returns the
|
||||
// runner and the spy so a test reads the capability-gated member.
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
// `sid` or `rid` to prove the host drops a mismatched-pair notification and
|
||||
// counts a protocol error.
|
||||
// - `exitCode`: when set, the fixture emits an exit notification after the data.
|
||||
// - `transportClosed`: when true, the exit notification marks a reason-less
|
||||
// transport close and carries no exit code.
|
||||
// - `dataAfterExit`: an array of `{ chunk, sid? }`, same shape as `data`. The
|
||||
// fixture emits these as data notifications after the exit notification, so a
|
||||
// test can script a batch where a data frame arrives after the exit in the
|
||||
@@ -21,6 +23,18 @@
|
||||
// and never lets the exit crowd a data frame out of the pre-open hold.
|
||||
// - `echoInput`: when true, the fixture echoes each `duplexChannelWrite` back as
|
||||
// one data notification for the bound session.
|
||||
// - `writeReplyDelayMs`: when a positive number, the fixture delays each
|
||||
// `duplexChannelWrite` reply by that many milliseconds, so a test proves the
|
||||
// host holds the pending-write reservation until the RPC settles.
|
||||
// - `stopReadingStdinAfterOpen`: when true, the fixture stops reading its stdin
|
||||
// right after it sends the open reply. The host writes then stay in the host
|
||||
// stdin write buffer, so a test proves the host meters the transport buffer and
|
||||
// never releases the transport token on the write-RPC timeout.
|
||||
// - `exitAfterStopMs`: when a positive number, and the fixture stopped reading
|
||||
// stdin, the fixture exits after that many milliseconds. The worker exit
|
||||
// discards the stdin buffer, so a test proves the host releases every held
|
||||
// transport token on the worker exit. It gives a fast exit for a worker that no
|
||||
// longer reads its stdin and never sees the shutdown request.
|
||||
// - `closeMode`: "ack" | "bad-ack" | "no-ack" (default "ack"). It controls the
|
||||
// close reply, so a test proves the host retires the worker on an unconfirmed
|
||||
// close.
|
||||
@@ -52,11 +66,19 @@ function scriptedFrameLines(directive, hostRouteId, workerSessionId) {
|
||||
},
|
||||
})}\n`;
|
||||
}
|
||||
if (typeof directive.exitCode === "number") {
|
||||
if (typeof directive.exitCode === "number" || directive.transportClosed === true) {
|
||||
const exitParams = {
|
||||
hostRouteId,
|
||||
workerSessionId,
|
||||
exitCode: typeof directive.exitCode === "number" ? directive.exitCode : null,
|
||||
};
|
||||
// A transport-close directive marks a reason-less transport close, so the
|
||||
// fixture carries the discriminator the same way a real worker does.
|
||||
if (directive.transportClosed === true) exitParams.transportClosed = true;
|
||||
lines += `${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "duplexChannel.exit",
|
||||
params: { hostRouteId, workerSessionId, exitCode: directive.exitCode },
|
||||
params: exitParams,
|
||||
})}\n`;
|
||||
}
|
||||
const dataAfterExit = Array.isArray(directive.dataAfterExit) ? directive.dataAfterExit : [];
|
||||
@@ -124,6 +146,8 @@ rl.on("line", (line) => {
|
||||
closeMode,
|
||||
echoInput: directive.echoInput === true,
|
||||
noWriteReply: mode === "no-write-reply",
|
||||
writeReplyDelayMs:
|
||||
typeof directive.writeReplyDelayMs === "number" ? directive.writeReplyDelayMs : 0,
|
||||
emitAfterCloseChunk:
|
||||
typeof directive.emitAfterCloseChunk === "string" ? directive.emitAfterCloseChunk : null,
|
||||
});
|
||||
@@ -160,6 +184,20 @@ rl.on("line", (line) => {
|
||||
reply();
|
||||
}
|
||||
|
||||
if (directive.stopReadingStdinAfterOpen === true) {
|
||||
// Stop reading stdin, so every later host write stays in the host stdin
|
||||
// write buffer. The host meters the transport buffer and holds the transport
|
||||
// token until the stream flush, the stream error, or the worker exit.
|
||||
rl.pause();
|
||||
process.stdin.pause();
|
||||
if (typeof directive.exitAfterStopMs === "number" && directive.exitAfterStopMs >= 0) {
|
||||
// Exit after the delay, so a test observes the worker exit release the held
|
||||
// transport tokens without a slow shutdown drain on an unread stdin.
|
||||
setTimeout(() => process.exit(0), directive.exitAfterStopMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit the scripted data and the exit after the open reply, so the host
|
||||
// binds the route first. Each frame echoes the exact pair; a test overrides
|
||||
// `sid` or `rid` to force a mismatch.
|
||||
@@ -195,7 +233,14 @@ rl.on("line", (line) => {
|
||||
},
|
||||
});
|
||||
}
|
||||
send({ jsonrpc: "2.0", id: message.id, result: null });
|
||||
const replyWrite = () => send({ jsonrpc: "2.0", id: message.id, result: null });
|
||||
if (entry.writeReplyDelayMs > 0) {
|
||||
// Delay the write reply, so the host holds the pending-write reservation for
|
||||
// a measurable time before the RPC settles.
|
||||
setTimeout(replyWrite, entry.writeReplyDelayMs);
|
||||
} else {
|
||||
replyWrite();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+11
-11
@@ -50,17 +50,17 @@ rl.on("line", (line) => {
|
||||
result: {
|
||||
ok: true,
|
||||
supportedMethods: [
|
||||
"setupTokenPtyOpen",
|
||||
"setupTokenPtyInput",
|
||||
"setupTokenPtyStop",
|
||||
"setupTokenPtyClose",
|
||||
"loginPtyOpen",
|
||||
"loginPtyInput",
|
||||
"loginPtyStop",
|
||||
"loginPtyClose",
|
||||
],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "setupTokenPtyOpen") {
|
||||
if (method === "loginPtyOpen") {
|
||||
const directive = parseDirective(params.providerLeaseId);
|
||||
const mode = directive.mode ?? "normal";
|
||||
const workerSessionId = directive.workerSessionId ?? "ws-1";
|
||||
@@ -92,7 +92,7 @@ rl.on("line", (line) => {
|
||||
for (const entry of outputs) {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "setupTokenPty.output",
|
||||
method: "loginPty.output",
|
||||
params: {
|
||||
workerSessionId: entry.sid ?? workerSessionId,
|
||||
chunk: entry.chunk,
|
||||
@@ -102,7 +102,7 @@ rl.on("line", (line) => {
|
||||
if (typeof directive.exitCode === "number") {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "setupTokenPty.exit",
|
||||
method: "loginPty.exit",
|
||||
params: { workerSessionId, exitCode: directive.exitCode },
|
||||
});
|
||||
}
|
||||
@@ -110,14 +110,14 @@ rl.on("line", (line) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "setupTokenPtyInput") {
|
||||
if (method === "loginPtyInput") {
|
||||
// Echo the input back as one output notification for the bound session, so a
|
||||
// test proves the input reaches the worker and the output routes back.
|
||||
for (const entry of routes.values()) {
|
||||
if (entry.workerSessionId === params.workerSessionId) {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "setupTokenPty.output",
|
||||
method: "loginPty.output",
|
||||
params: { workerSessionId: entry.workerSessionId, chunk: `echo:${params.data}` },
|
||||
});
|
||||
}
|
||||
@@ -126,12 +126,12 @@ rl.on("line", (line) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "setupTokenPtyStop") {
|
||||
if (method === "loginPtyStop") {
|
||||
send({ jsonrpc: "2.0", id: message.id, result: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "setupTokenPtyClose") {
|
||||
if (method === "loginPtyClose") {
|
||||
const entry = routes.get(params.hostRouteId);
|
||||
routes.delete(params.hostRouteId);
|
||||
const closeMode = entry ? entry.closeMode : "ack";
|
||||
@@ -72,7 +72,10 @@ import { instanceSettingsService } from "../services/instance-settings.ts";
|
||||
import { issueService } from "../services/issues.ts";
|
||||
import { runningProcesses } from "../adapters/index.ts";
|
||||
import { DEFAULT_LIVENESS_REESCALATION_COOLDOWN_MS } from "../services/recovery/service.ts";
|
||||
import { buildIssueBlockersResolvedWakeStateKey } from "../services/issue-dependency-wakeups.ts";
|
||||
import {
|
||||
buildIssueBlockersResolvedWakeStateKey,
|
||||
buildIssueBlockersResolvedWakeStateKeyWithoutCycle,
|
||||
} from "../services/issue-dependency-wakeups.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -812,6 +815,101 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
||||
expect(stateKeyWakes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("heals a blocked dependent after a terminal reset when a previous-cycle old-key wake exists", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, agentId, blockedIssueId, blockerIssueId } =
|
||||
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
|
||||
const previousCycleWakeAt = new Date("2026-07-01T12:00:00.000Z");
|
||||
const blockedTransitionAt = new Date("2026-08-01T12:00:00.000Z");
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ blockedTransitionAt, updatedAt: blockedTransitionAt })
|
||||
.where(eq(issues.id, blockedIssueId));
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
companyId,
|
||||
agentId,
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_blockers_resolved",
|
||||
payload: {
|
||||
issueId: blockedIssueId,
|
||||
resolvedBlockerIssueId: blockerIssueId,
|
||||
blockerIssueIds: [blockerIssueId],
|
||||
},
|
||||
status: "completed",
|
||||
finishedAt: previousCycleWakeAt,
|
||||
requestedAt: previousCycleWakeAt,
|
||||
idempotencyKey: buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
|
||||
dependentIssueId: blockedIssueId,
|
||||
blockerIssueIds: [blockerIssueId],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
|
||||
|
||||
expect(result.dependencyWakesHealed).toBe(1);
|
||||
expect(result.dependencyWakeIssueIds).toEqual([blockedIssueId]);
|
||||
expect(result.dependencyWakeExistingSkipped).toBe(0);
|
||||
|
||||
const cycleKey = buildIssueBlockersResolvedWakeStateKey({
|
||||
dependentIssueId: blockedIssueId,
|
||||
blockerIssueIds: [blockerIssueId],
|
||||
blockedTransitionAt,
|
||||
});
|
||||
const healedWake = await db
|
||||
.select({ status: agentWakeupRequests.status, idempotencyKey: agentWakeupRequests.idempotencyKey })
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.idempotencyKey, cycleKey)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(healedWake).not.toBeNull();
|
||||
expect(["queued", "claimed", "completed"]).toContain(healedWake?.status);
|
||||
|
||||
const secondPass = await heartbeatService(db).reconcileIssueGraphLiveness();
|
||||
expect(secondPass.dependencyWakesHealed).toBe(0);
|
||||
|
||||
const cycleKeyWakes = await db
|
||||
.select({ id: agentWakeupRequests.id })
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.idempotencyKey, cycleKey)));
|
||||
expect(cycleKeyWakes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not re-heal when a completed old-key wake is from the current blocked cycle", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, agentId, blockedIssueId, blockerIssueId } =
|
||||
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
|
||||
const blockedTransitionAt = new Date("2026-08-01T12:00:00.000Z");
|
||||
const sameCycleWakeAt = new Date("2026-08-01T12:00:01.000Z");
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ blockedTransitionAt, updatedAt: blockedTransitionAt })
|
||||
.where(eq(issues.id, blockedIssueId));
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
companyId,
|
||||
agentId,
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_blockers_resolved",
|
||||
payload: {
|
||||
issueId: blockedIssueId,
|
||||
resolvedBlockerIssueId: blockerIssueId,
|
||||
blockerIssueIds: [blockerIssueId],
|
||||
},
|
||||
status: "completed",
|
||||
finishedAt: sameCycleWakeAt,
|
||||
requestedAt: sameCycleWakeAt,
|
||||
idempotencyKey: buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
|
||||
dependentIssueId: blockedIssueId,
|
||||
blockerIssueIds: [blockerIssueId],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
|
||||
|
||||
expect(result.dependencyWakesHealed).toBe(0);
|
||||
expect(result.dependencyWakeExistingSkipped).toBe(1);
|
||||
});
|
||||
|
||||
it("counts null dependency wake returns as deferred instead of enqueue failures", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, agentId } =
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildIssueBlockersResolvedWakeStateKey,
|
||||
} from "../services/issue-dependency-wakeups.ts";
|
||||
|
||||
// The first test in this suite imports the large `routes/issues.ts` module
|
||||
// through `vi.importActual` inside `createApp`. `vi.resetModules()` in
|
||||
@@ -11,7 +14,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
// default 5000ms test timeout and produce a flaky "Test timed out in 5000ms"
|
||||
// failure. Give the suite generous headroom, far above the observed cold-start
|
||||
// yet still below the 30s hook timeout.
|
||||
vi.setConfig({ testTimeout: 15000 });
|
||||
vi.setConfig({ testTimeout: 30000 });
|
||||
|
||||
const mockWakeup = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const mockFindExistingIssueBlockersResolvedWakeForReadyState = vi.hoisted(() => vi.fn(async () => null));
|
||||
@@ -402,4 +405,280 @@ describe("issue dependency wakeups in issue routes", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function issueRecord(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-100",
|
||||
title: "Issue",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
parentId: null,
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
executionWorkspaceId: null,
|
||||
blockedTransitionAt: null,
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("wakes a Release-like dependent after a terminal reset using the current blocked cycle", async () => {
|
||||
const reviewIssueId = "11111111-1111-4111-8111-111111111111";
|
||||
const releaseIssueId = "22222222-2222-4222-8222-222222222222";
|
||||
const releaseBlockedAt = new Date("2026-08-01T15:00:00.000Z");
|
||||
mockIssueService.getById.mockResolvedValue(issueRecord({
|
||||
id: reviewIssueId,
|
||||
identifier: "PAP-REVIEW",
|
||||
title: "Review",
|
||||
status: "in_progress",
|
||||
}));
|
||||
mockIssueService.update.mockResolvedValue(issueRecord({
|
||||
id: reviewIssueId,
|
||||
identifier: "PAP-REVIEW",
|
||||
title: "Review",
|
||||
status: "done",
|
||||
}));
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([
|
||||
{
|
||||
id: releaseIssueId,
|
||||
assigneeAgentId: "agent-release",
|
||||
blockerIssueIds: [reviewIssueId],
|
||||
blockedTransitionAt: releaseBlockedAt,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await request(await createApp()).patch(`/api/issues/${reviewIssueId}`).send({ status: "done" });
|
||||
expect(res.status).toBe(200);
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFindExistingIssueBlockersResolvedWakeForReadyState).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
dependentIssueId: releaseIssueId,
|
||||
blockerIssueIds: [reviewIssueId],
|
||||
blockedTransitionAt: releaseBlockedAt,
|
||||
}),
|
||||
);
|
||||
expect(mockWakeup).toHaveBeenCalledTimes(1);
|
||||
expect(mockWakeup).toHaveBeenCalledWith(
|
||||
"agent-release",
|
||||
expect.objectContaining({
|
||||
reason: "issue_blockers_resolved",
|
||||
idempotencyKey: buildIssueBlockersResolvedWakeStateKey({
|
||||
dependentIssueId: releaseIssueId,
|
||||
blockerIssueIds: [reviewIssueId],
|
||||
blockedTransitionAt: releaseBlockedAt,
|
||||
}),
|
||||
payload: expect.objectContaining({
|
||||
issueId: releaseIssueId,
|
||||
resolvedBlockerIssueId: reviewIssueId,
|
||||
mutation: "blocker_done",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not enqueue a second wake when the blocker is already done", async () => {
|
||||
const reviewIssueId = "11111111-1111-4111-8111-111111111111";
|
||||
mockIssueService.getById.mockResolvedValue(issueRecord({
|
||||
id: reviewIssueId,
|
||||
identifier: "PAP-REVIEW",
|
||||
title: "Review",
|
||||
status: "done",
|
||||
}));
|
||||
mockIssueService.update.mockResolvedValue(issueRecord({
|
||||
id: reviewIssueId,
|
||||
identifier: "PAP-REVIEW",
|
||||
title: "Review",
|
||||
status: "done",
|
||||
}));
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([
|
||||
{
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
assigneeAgentId: "agent-release",
|
||||
blockerIssueIds: [reviewIssueId],
|
||||
blockedTransitionAt: new Date("2026-08-01T15:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await request(await createApp()).patch(`/api/issues/${reviewIssueId}`).send({ status: "done" });
|
||||
expect(res.status).toBe(200);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(mockWakeup).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.listWakeableBlockedDependents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("wakes a QA-like chain one dependent at a time after each blocker completes", async () => {
|
||||
const reviewIssueId = "11111111-1111-4111-8111-111111111111";
|
||||
const releaseIssueId = "22222222-2222-4222-8222-222222222222";
|
||||
const qaIssueId = "33333333-3333-4333-8333-333333333333";
|
||||
const releaseBlockedAt = new Date("2026-08-02T10:00:00.000Z");
|
||||
const qaBlockedAt = new Date("2026-08-02T10:05:00.000Z");
|
||||
const app = await createApp();
|
||||
|
||||
mockIssueService.getById.mockResolvedValue(issueRecord({
|
||||
id: reviewIssueId,
|
||||
identifier: "PAP-REVIEW",
|
||||
title: "Review",
|
||||
status: "in_progress",
|
||||
}));
|
||||
mockIssueService.update.mockResolvedValue(issueRecord({
|
||||
id: reviewIssueId,
|
||||
identifier: "PAP-REVIEW",
|
||||
title: "Review",
|
||||
status: "done",
|
||||
}));
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([
|
||||
{
|
||||
id: releaseIssueId,
|
||||
assigneeAgentId: "agent-release",
|
||||
blockerIssueIds: [reviewIssueId],
|
||||
blockedTransitionAt: releaseBlockedAt,
|
||||
},
|
||||
]);
|
||||
|
||||
expect((await request(app).patch(`/api/issues/${reviewIssueId}`).send({ status: "done" })).status).toBe(200);
|
||||
await vi.waitFor(() => {
|
||||
expect(mockWakeup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(mockWakeup).toHaveBeenCalledWith(
|
||||
"agent-release",
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({ issueId: releaseIssueId }),
|
||||
idempotencyKey: buildIssueBlockersResolvedWakeStateKey({
|
||||
dependentIssueId: releaseIssueId,
|
||||
blockerIssueIds: [reviewIssueId],
|
||||
blockedTransitionAt: releaseBlockedAt,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
mockWakeup.mockClear();
|
||||
mockFindExistingIssueBlockersResolvedWakeForReadyState.mockClear();
|
||||
mockIssueService.getById.mockResolvedValue(issueRecord({
|
||||
id: releaseIssueId,
|
||||
identifier: "PAP-RELEASE",
|
||||
title: "Release",
|
||||
status: "blocked",
|
||||
assigneeAgentId: "agent-release",
|
||||
blockedTransitionAt: releaseBlockedAt,
|
||||
}));
|
||||
mockIssueService.update.mockResolvedValue(issueRecord({
|
||||
id: releaseIssueId,
|
||||
identifier: "PAP-RELEASE",
|
||||
title: "Release",
|
||||
status: "done",
|
||||
assigneeAgentId: "agent-release",
|
||||
}));
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([
|
||||
{
|
||||
id: qaIssueId,
|
||||
assigneeAgentId: "agent-qa",
|
||||
blockerIssueIds: [releaseIssueId],
|
||||
blockedTransitionAt: qaBlockedAt,
|
||||
},
|
||||
]);
|
||||
|
||||
expect((await request(app).patch(`/api/issues/${releaseIssueId}`).send({ status: "done" })).status).toBe(200);
|
||||
await vi.waitFor(() => {
|
||||
expect(mockWakeup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(mockWakeup).toHaveBeenCalledWith(
|
||||
"agent-qa",
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
issueId: qaIssueId,
|
||||
resolvedBlockerIssueId: releaseIssueId,
|
||||
}),
|
||||
idempotencyKey: buildIssueBlockersResolvedWakeStateKey({
|
||||
dependentIssueId: qaIssueId,
|
||||
blockerIssueIds: [releaseIssueId],
|
||||
blockedTransitionAt: qaBlockedAt,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockWakeup).not.toHaveBeenCalledWith("agent-release", expect.anything());
|
||||
});
|
||||
|
||||
it("restores a blocked-and-ready dependent under the new blocked cycle key", async () => {
|
||||
const parentIssueId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const childIssueId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
const blockedTransitionAt = new Date("2026-08-03T18:00:00.000Z");
|
||||
mockIssueService.getById.mockResolvedValue(issueRecord({
|
||||
id: parentIssueId,
|
||||
identifier: "PAP-200",
|
||||
title: "Blocked after completion",
|
||||
status: "done",
|
||||
assigneeAgentId: "agent-2",
|
||||
}));
|
||||
mockIssueService.update.mockResolvedValue(issueRecord({
|
||||
id: parentIssueId,
|
||||
identifier: "PAP-200",
|
||||
title: "Blocked after completion",
|
||||
status: "blocked",
|
||||
assigneeAgentId: "agent-2",
|
||||
blockedTransitionAt,
|
||||
}));
|
||||
mockIssueService.getDependencyReadiness.mockResolvedValue({
|
||||
issueId: parentIssueId,
|
||||
blockerIssueIds: [childIssueId],
|
||||
unresolvedBlockerIssueIds: [],
|
||||
unresolvedBlockerCount: 0,
|
||||
pendingFinalizeBlockerIssueIds: [],
|
||||
allBlockersDone: true,
|
||||
isDependencyReady: true,
|
||||
});
|
||||
|
||||
const res = await request(await createApp())
|
||||
.patch(`/api/issues/${parentIssueId}`)
|
||||
.send({
|
||||
status: "blocked",
|
||||
blockedByIssueIds: [childIssueId],
|
||||
unblockDescriptor: { owner: "board", action: "Review the restored dependency" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFindExistingIssueBlockersResolvedWakeForReadyState).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
dependentIssueId: parentIssueId,
|
||||
blockerIssueIds: [childIssueId],
|
||||
blockedTransitionAt,
|
||||
}),
|
||||
);
|
||||
expect(mockWakeup).toHaveBeenCalledWith(
|
||||
"agent-2",
|
||||
expect.objectContaining({
|
||||
reason: "issue_blockers_resolved",
|
||||
idempotencyKey: buildIssueBlockersResolvedWakeStateKey({
|
||||
dependentIssueId: parentIssueId,
|
||||
blockerIssueIds: [childIssueId],
|
||||
blockedTransitionAt,
|
||||
}),
|
||||
payload: expect.objectContaining({
|
||||
mutation: "blocked_dependency_restored",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not emit a dependency wake when an unresolved or cancelled blocker remains", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(issueRecord({ status: "in_progress" }));
|
||||
mockIssueService.update.mockResolvedValue(issueRecord({ status: "done" }));
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||
|
||||
const res = await request(await createApp()).patch("/api/issues/issue-1").send({ status: "done" });
|
||||
expect(res.status).toBe(200);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(mockWakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import {
|
||||
DuplexAggregateByteLedger,
|
||||
type DuplexAggregateByteLedgerTelemetry,
|
||||
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
|
||||
import {
|
||||
createDuplexRouteSlotController,
|
||||
createPluginWorkerHandle,
|
||||
} from "../services/plugin-worker-manager.js";
|
||||
|
||||
// This suite proves the plugin worker manager charges every retained duplex route
|
||||
// representation against the injected aggregate byte ledger, and releases each
|
||||
// token exactly once through the one cleanup path. The tests drive a real worker
|
||||
// fixture process, so they exercise the true frame order across the bind boundary.
|
||||
|
||||
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
|
||||
const DUPLEX_CHANNEL_WORKER_ENTRYPOINT = path.join(
|
||||
FIXTURES_DIR,
|
||||
"plugin-worker-duplex-channel.cjs",
|
||||
);
|
||||
|
||||
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
|
||||
id: "test.plugin",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Test plugin",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: [],
|
||||
entrypoints: { worker: "dist/worker.js" },
|
||||
};
|
||||
|
||||
function makeDuplexHandle(extra?: Record<string, unknown>) {
|
||||
return createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: DUPLEX_CHANNEL_WORKER_ENTRYPOINT,
|
||||
manifest: TEST_MANIFEST,
|
||||
config: {},
|
||||
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
|
||||
apiVersion: 1,
|
||||
hostHandlers: {},
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
// The test directive rides in `providerLeaseId`, an opaque field the manager
|
||||
// forwards to the worker unchanged.
|
||||
function duplexOpenInput(directive: unknown, companyId = "company-1") {
|
||||
return {
|
||||
driverKey: "daytona",
|
||||
companyId,
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: JSON.stringify(directive),
|
||||
command: "bridge-callback",
|
||||
};
|
||||
}
|
||||
|
||||
// A counting telemetry surface. It records how many times the ledger rejected a
|
||||
// reservation and how many accounting defects it saw, so a test asserts a
|
||||
// fail-closed rejection and proves the cleanup never double-releases.
|
||||
function countingTelemetry(): DuplexAggregateByteLedgerTelemetry & {
|
||||
rejections: number;
|
||||
underflows: number;
|
||||
} {
|
||||
const state = {
|
||||
rejections: 0,
|
||||
underflows: 0,
|
||||
setBytesInUse() {},
|
||||
recordReservationRejection() {
|
||||
state.rejections += 1;
|
||||
},
|
||||
recordAccountingUnderflow() {
|
||||
state.underflows += 1;
|
||||
},
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("plugin worker manager duplex aggregate byte ledger", () => {
|
||||
it("keeps terminalized buffered bytes charged until a late listener drains them, then a worker exit is harmless", async () => {
|
||||
const telemetry = countingTelemetry();
|
||||
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
|
||||
// Lower the per-chunk char bound, so the second chunk ends the route while the
|
||||
// first chunk stays buffered.
|
||||
const handle = makeDuplexHandle({
|
||||
duplexAggregateByteLedger: ledger,
|
||||
duplexChannelLimits: { maxChunkChars: 4 },
|
||||
});
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openDuplexChannel(
|
||||
// No listener attaches, so "ok" buffers and charges its two raw bytes. The
|
||||
// "toolong" chunk passes the per-chunk bound, so the route terminalizes and
|
||||
// moves the still-buffered "ok" token to the terminal registry.
|
||||
duplexOpenInput({ data: [{ chunk: "ok" }, { chunk: "toolong" }] }),
|
||||
);
|
||||
// The buffered "ok" stays charged after the route leaves the live map.
|
||||
await vi.waitFor(() => {
|
||||
expect(ledger.bytesInUse).toBe(2);
|
||||
expect(ledger.liveTokenCount).toBe(1);
|
||||
});
|
||||
// A late listener drains the terminal buffered record and releases its token.
|
||||
const chunks: string[] = [];
|
||||
session.onData((chunk) => chunks.push(chunk));
|
||||
await vi.waitFor(() => {
|
||||
expect(chunks).toEqual(["ok"]);
|
||||
expect(ledger.bytesInUse).toBe(0);
|
||||
expect(ledger.liveTokenCount).toBe(0);
|
||||
});
|
||||
// The over-bound chunk retained nothing, so no reservation ever rejected.
|
||||
expect(telemetry.rejections).toBe(0);
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
// The worker exit sweep runs on stop. The ledger stays at zero, and the sweep
|
||||
// records no accounting defect, so the drain and the exit never double-release.
|
||||
expect(ledger.bytesInUse).toBe(0);
|
||||
expect(ledger.liveTokenCount).toBe(0);
|
||||
expect(telemetry.underflows).toBe(0);
|
||||
});
|
||||
|
||||
it("transfers pre-bind held bytes to the buffered representation across the bind, then releases them on drain", async () => {
|
||||
const telemetry = countingTelemetry();
|
||||
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
|
||||
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openDuplexChannel(
|
||||
// The worker writes the open reply and the two data frames in one stdout
|
||||
// write, so both frames arrive before the route binds. The host holds them
|
||||
// as pre-bind events, then the bind replays and transfers each token to the
|
||||
// buffered representation.
|
||||
duplexOpenInput({ batchWithOpenReply: true, data: [{ chunk: "aa" }, { chunk: "bb" }] }),
|
||||
);
|
||||
// The two held events keep their exact reserved bytes across the transfer, so
|
||||
// the live-token count never grows and the gauge never re-admits.
|
||||
await vi.waitFor(() => {
|
||||
expect(ledger.bytesInUse).toBe(4);
|
||||
expect(ledger.liveTokenCount).toBe(2);
|
||||
});
|
||||
const chunks: string[] = [];
|
||||
session.onData((chunk) => chunks.push(chunk));
|
||||
await vi.waitFor(() => {
|
||||
expect(chunks).toEqual(["aa", "bb"]);
|
||||
expect(ledger.bytesInUse).toBe(0);
|
||||
expect(ledger.liveTokenCount).toBe(0);
|
||||
});
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
expect(ledger.bytesInUse).toBe(0);
|
||||
expect(telemetry.underflows).toBe(0);
|
||||
});
|
||||
|
||||
it("fails closed and retains nothing when a buffered reservation would pass the ceiling", async () => {
|
||||
const telemetry = countingTelemetry();
|
||||
// A four-byte ceiling. One five-byte chunk cannot fit.
|
||||
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 4, telemetry });
|
||||
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
|
||||
try {
|
||||
await handle.start();
|
||||
await handle.openDuplexChannel(
|
||||
// No listener attaches, so "hello" tries to buffer. Its five raw bytes pass
|
||||
// the four-byte ceiling, so the reservation rejects and the route ends.
|
||||
duplexOpenInput({ data: [{ chunk: "hello" }] }),
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(telemetry.rejections).toBeGreaterThanOrEqual(1);
|
||||
expect(ledger.bytesInUse).toBe(0);
|
||||
expect(ledger.liveTokenCount).toBe(0);
|
||||
});
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
expect(ledger.bytesInUse).toBe(0);
|
||||
expect(ledger.liveTokenCount).toBe(0);
|
||||
expect(telemetry.underflows).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// A telemetry surface that also tracks the peak gauge value. A test asserts the
|
||||
// peak never passes the ceiling, so it proves aggregate admission stops at the
|
||||
// ceiling instead of overshooting it.
|
||||
function peakTrackingTelemetry(): DuplexAggregateByteLedgerTelemetry & {
|
||||
gauge: number;
|
||||
peak: number;
|
||||
rejections: number;
|
||||
underflows: number;
|
||||
} {
|
||||
const state = {
|
||||
gauge: 0,
|
||||
peak: 0,
|
||||
rejections: 0,
|
||||
underflows: 0,
|
||||
setBytesInUse(bytes: number) {
|
||||
state.gauge = bytes;
|
||||
if (bytes > state.peak) state.peak = bytes;
|
||||
},
|
||||
recordReservationRejection() {
|
||||
state.rejections += 1;
|
||||
},
|
||||
recordAccountingUnderflow() {
|
||||
state.underflows += 1;
|
||||
},
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
// The exact raw byte count of one buffered chunk each route retains.
|
||||
const LOAD_CHUNK_BYTES = 8;
|
||||
const LOAD_CHUNK = "x".repeat(LOAD_CHUNK_BYTES);
|
||||
|
||||
describe("plugin worker manager duplex aggregate byte ledger load across workers", () => {
|
||||
it("stops aggregate byte admission at the ceiling across many routes and workers while the route-count controller stays open", async () => {
|
||||
const telemetry = peakTrackingTelemetry();
|
||||
// The ceiling holds exactly six eight-byte buffered chunks.
|
||||
const fittingRoutes = 6;
|
||||
const ceilingBytes = fittingRoutes * LOAD_CHUNK_BYTES;
|
||||
const ledger = new DuplexAggregateByteLedger({ ceilingBytes, telemetry });
|
||||
// The route-count controller has ample room, so the byte ledger binds first.
|
||||
const routeSlots = createDuplexRouteSlotController(100);
|
||||
const handleCount = 3;
|
||||
const routesPerHandle = 3;
|
||||
const totalRoutes = handleCount * routesPerHandle;
|
||||
const rejectedRoutes = totalRoutes - fittingRoutes;
|
||||
const handles: Array<ReturnType<typeof makeDuplexHandle>> = [];
|
||||
try {
|
||||
for (let h = 0; h < handleCount; h += 1) {
|
||||
const handle = makeDuplexHandle({
|
||||
duplexAggregateByteLedger: ledger,
|
||||
duplexRouteSlots: routeSlots,
|
||||
});
|
||||
await handle.start();
|
||||
handles.push(handle);
|
||||
}
|
||||
// Open every route in order. Each worker batches one data chunk with the open
|
||||
// reply, so the host holds the chunk as a pre-bind event and reserves its exact
|
||||
// raw bytes before the route binds. No listener attaches, so a bound route holds
|
||||
// the chunk. The first six chunks fit the ceiling. Each later reservation passes
|
||||
// the ceiling, so the ledger rejects it and the route fails closed with the open
|
||||
// marker (not the route-busy marker), because the route-count controller has room.
|
||||
let opened = 0;
|
||||
let failedClosed = 0;
|
||||
for (let h = 0; h < handleCount; h += 1) {
|
||||
for (let r = 0; r < routesPerHandle; r += 1) {
|
||||
try {
|
||||
await handles[h].openDuplexChannel(
|
||||
duplexOpenInput({
|
||||
workerSessionId: `ws-${h}-${r}`,
|
||||
batchWithOpenReply: true,
|
||||
data: [{ chunk: LOAD_CHUNK }],
|
||||
}),
|
||||
);
|
||||
opened += 1;
|
||||
} catch (err) {
|
||||
// A byte-ceiling rejection fails the route closed with the open marker.
|
||||
// The route-count controller had room, so this is never the route-busy
|
||||
// marker.
|
||||
expect(String(err)).toContain("DUPLEX_CHANNEL_OPEN_FAILED");
|
||||
expect(String(err)).not.toContain("DUPLEX_CHANNEL_ROUTE_BUSY");
|
||||
failedClosed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Six routes bound and hold their bytes; three failed closed at the ceiling.
|
||||
expect(opened).toBe(fittingRoutes);
|
||||
expect(failedClosed).toBe(rejectedRoutes);
|
||||
expect(opened + failedClosed).toBe(totalRoutes);
|
||||
// The aggregate gauge holds at the ceiling and the ledger recorded one rejection
|
||||
// per over-ceiling route.
|
||||
await vi.waitFor(() => {
|
||||
expect(telemetry.rejections).toBe(rejectedRoutes);
|
||||
expect(ledger.bytesInUse).toBe(ceilingBytes);
|
||||
expect(ledger.liveTokenCount).toBe(fittingRoutes);
|
||||
});
|
||||
// The gauge never passed the ceiling, so admission stopped at the ceiling and
|
||||
// never overshot it.
|
||||
expect(telemetry.peak).toBe(ceilingBytes);
|
||||
} finally {
|
||||
for (const handle of handles) await handle.stop().catch(() => undefined);
|
||||
}
|
||||
// Every worker exit sweep released its buffered tokens once, so the ledger ends
|
||||
// at zero with no accounting defect.
|
||||
expect(ledger.bytesInUse).toBe(0);
|
||||
expect(ledger.liveTokenCount).toBe(0);
|
||||
expect(telemetry.underflows).toBe(0);
|
||||
});
|
||||
|
||||
it("still bounds the route count for fixed per-route overhead when the byte ceiling has ample room", async () => {
|
||||
const telemetry = peakTrackingTelemetry();
|
||||
// A large byte ceiling, so retained bytes never bind admission.
|
||||
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
|
||||
// The route-count controller holds two slots for the whole process.
|
||||
const routeSlots = createDuplexRouteSlotController(2);
|
||||
const handleA = makeDuplexHandle({
|
||||
duplexAggregateByteLedger: ledger,
|
||||
duplexRouteSlots: routeSlots,
|
||||
});
|
||||
const handleB = makeDuplexHandle({
|
||||
duplexAggregateByteLedger: ledger,
|
||||
duplexRouteSlots: routeSlots,
|
||||
});
|
||||
let opened = 0;
|
||||
let busy = 0;
|
||||
try {
|
||||
await handleA.start();
|
||||
await handleB.start();
|
||||
// Attempt four routes across the two workers. The shared controller admits the
|
||||
// first two and rejects the last two with the fixed route-busy error.
|
||||
const attempts: Array<[ReturnType<typeof makeDuplexHandle>, string]> = [
|
||||
[handleA, "ws-a1"],
|
||||
[handleA, "ws-a2"],
|
||||
[handleB, "ws-b1"],
|
||||
[handleB, "ws-b2"],
|
||||
];
|
||||
for (const [handle, ws] of attempts) {
|
||||
try {
|
||||
await handle.openDuplexChannel(
|
||||
duplexOpenInput({ workerSessionId: ws, data: [{ chunk: "aa" }] }),
|
||||
);
|
||||
opened += 1;
|
||||
} catch (err) {
|
||||
expect(String(err)).toContain("DUPLEX_CHANNEL_ROUTE_BUSY");
|
||||
busy += 1;
|
||||
}
|
||||
}
|
||||
// The route-count controller admitted two routes and rejected the rest, so it
|
||||
// still bounds the fixed per-route overhead on its own.
|
||||
expect(opened).toBe(2);
|
||||
expect(busy).toBe(2);
|
||||
// The two open routes buffered four raw bytes, far under the byte ceiling, and
|
||||
// the byte ledger rejected nothing.
|
||||
await vi.waitFor(() => {
|
||||
expect(ledger.bytesInUse).toBe(4);
|
||||
expect(ledger.liveTokenCount).toBe(2);
|
||||
});
|
||||
expect(telemetry.rejections).toBe(0);
|
||||
} finally {
|
||||
await handleA.stop().catch(() => undefined);
|
||||
await handleB.stop().catch(() => undefined);
|
||||
}
|
||||
expect(ledger.bytesInUse).toBe(0);
|
||||
expect(telemetry.underflows).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import {
|
||||
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
|
||||
DuplexAggregateByteLedger,
|
||||
type DuplexAggregateByteLedgerTelemetry,
|
||||
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
|
||||
|
||||
// Mock the shared logger, so a test reads the fixed rejection marker the manager
|
||||
// logs when a pending-write reservation fails. The child logger returns the same
|
||||
// object, so `log.warn` is this mock's `warn`.
|
||||
vi.mock("../middleware/logger.js", () => {
|
||||
const mockLogger: Record<string, unknown> = {
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
child: vi.fn(() => mockLogger),
|
||||
};
|
||||
return { logger: mockLogger, httpLogger: vi.fn() };
|
||||
});
|
||||
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { createPluginWorkerHandle } from "../services/plugin-worker-manager.js";
|
||||
|
||||
// This suite proves the plugin worker manager charges every host→worker write raw
|
||||
// payload against the injected aggregate byte ledger under the `pending_write`
|
||||
// owner, and releases each token one time when the write RPC settles. The child
|
||||
// reads its stdin here, so the separate `stdin_write` transport token flushes and
|
||||
// releases at once. Each test waits for that flush, so the assertions isolate the
|
||||
// raw-payload token. The transport token has its own suite.
|
||||
|
||||
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
|
||||
const DUPLEX_CHANNEL_WORKER_ENTRYPOINT = path.join(
|
||||
FIXTURES_DIR,
|
||||
"plugin-worker-duplex-channel.cjs",
|
||||
);
|
||||
|
||||
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
|
||||
id: "test.plugin",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Test plugin",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: [],
|
||||
entrypoints: { worker: "dist/worker.js" },
|
||||
};
|
||||
|
||||
function makeDuplexHandle(extra?: Record<string, unknown>) {
|
||||
return createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: DUPLEX_CHANNEL_WORKER_ENTRYPOINT,
|
||||
manifest: TEST_MANIFEST,
|
||||
config: {},
|
||||
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
|
||||
apiVersion: 1,
|
||||
hostHandlers: {},
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
// The test directive rides in `providerLeaseId`, an opaque field the manager
|
||||
// forwards to the worker unchanged.
|
||||
function duplexOpenInput(directive: unknown, companyId = "company-1") {
|
||||
return {
|
||||
driverKey: "daytona",
|
||||
companyId,
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: JSON.stringify(directive),
|
||||
command: "bridge-callback",
|
||||
};
|
||||
}
|
||||
|
||||
// A telemetry surface that tracks the peak gauge value and the two counters. A
|
||||
// test asserts the peak never passes the ceiling, so it proves aggregate admission
|
||||
// stops at the ceiling instead of overshooting it.
|
||||
function peakTrackingTelemetry(): DuplexAggregateByteLedgerTelemetry & {
|
||||
gauge: number;
|
||||
peak: number;
|
||||
rejections: number;
|
||||
underflows: number;
|
||||
} {
|
||||
const state = {
|
||||
gauge: 0,
|
||||
peak: 0,
|
||||
rejections: 0,
|
||||
underflows: 0,
|
||||
setBytesInUse(bytes: number) {
|
||||
state.gauge = bytes;
|
||||
if (bytes > state.peak) state.peak = bytes;
|
||||
},
|
||||
recordReservationRejection() {
|
||||
state.rejections += 1;
|
||||
},
|
||||
recordAccountingUnderflow() {
|
||||
state.underflows += 1;
|
||||
},
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
// Return every reason string the manager logged through the mock `warn` sink.
|
||||
function loggedWarnReasons(): string[] {
|
||||
const calls = (logger.warn as unknown as { mock: { calls: unknown[][] } }).mock.calls;
|
||||
return calls
|
||||
.map((call) => {
|
||||
const first = call[0];
|
||||
return first && typeof first === "object"
|
||||
? (first as { reason?: unknown }).reason
|
||||
: undefined;
|
||||
})
|
||||
.filter((reason): reason is string => typeof reason === "string");
|
||||
}
|
||||
|
||||
describe("plugin worker manager duplex pending-write byte ledger", () => {
|
||||
it("charges each held raw payload and returns to zero after worker exit", async () => {
|
||||
const telemetry = peakTrackingTelemetry();
|
||||
// The ceiling has ample room, so a transient transport reservation never
|
||||
// rejects. Each write holds its raw payload until the RPC settles.
|
||||
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
|
||||
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
|
||||
const writeBytes = 1000;
|
||||
const writeData = "x".repeat(writeBytes);
|
||||
try {
|
||||
await handle.start();
|
||||
// The worker reads its stdin but never replies to a write, so each write RPC
|
||||
// stays pending and holds its raw payload.
|
||||
const route = await handle.openDuplexChannel(
|
||||
duplexOpenInput({ workerSessionId: "ws-a", mode: "no-write-reply" }),
|
||||
);
|
||||
route.write(writeData);
|
||||
route.write(writeData);
|
||||
route.write(writeData);
|
||||
// The worker reads its stdin, so each transport token flushes and releases.
|
||||
// Only the three held raw payloads remain, so the gauge settles at three
|
||||
// times the payload byte count with three live tokens.
|
||||
await vi.waitFor(() => {
|
||||
expect(ledger.liveTokenCount).toBe(3);
|
||||
expect(ledger.bytesInUse).toBe(3 * writeBytes);
|
||||
});
|
||||
expect(telemetry.peak).toBeLessThanOrEqual(ledger.ceilingBytes);
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
// The worker exit settles each pending write, so each token releases one time
|
||||
// and the ledger ends at zero with no accounting defect.
|
||||
await vi.waitFor(() => {
|
||||
expect(ledger.bytesInUse).toBe(0);
|
||||
expect(ledger.liveTokenCount).toBe(0);
|
||||
});
|
||||
expect(telemetry.underflows).toBe(0);
|
||||
});
|
||||
|
||||
it("releases the raw-payload token after a delayed write RPC settles, with no worker exit", async () => {
|
||||
const telemetry = peakTrackingTelemetry();
|
||||
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
|
||||
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
|
||||
try {
|
||||
await handle.start();
|
||||
// The worker reads its stdin and delays its write reply, so the host holds the
|
||||
// raw-payload reservation for a measurable time before the RPC settles.
|
||||
const route = await handle.openDuplexChannel(
|
||||
duplexOpenInput({ workerSessionId: "ws-d", writeReplyDelayMs: 150 }),
|
||||
);
|
||||
const writeBytes = 1000;
|
||||
route.write("x".repeat(writeBytes));
|
||||
// The transport token flushes at once, so one raw-payload token stays held for
|
||||
// the full write byte count while the RPC is in flight.
|
||||
await vi.waitFor(() => {
|
||||
expect(ledger.liveTokenCount).toBe(1);
|
||||
expect(ledger.bytesInUse).toBe(writeBytes);
|
||||
});
|
||||
// The delayed reply settles the RPC, and the `finally` releases the token.
|
||||
await vi.waitFor(() => {
|
||||
expect(ledger.bytesInUse).toBe(0);
|
||||
expect(ledger.liveTokenCount).toBe(0);
|
||||
});
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
expect(ledger.bytesInUse).toBe(0);
|
||||
expect(telemetry.underflows).toBe(0);
|
||||
});
|
||||
|
||||
it("reserves the exact UTF-8 byte count of the raw payload, not the character length", async () => {
|
||||
const telemetry = peakTrackingTelemetry();
|
||||
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
|
||||
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
|
||||
try {
|
||||
await handle.start();
|
||||
const route = await handle.openDuplexChannel(
|
||||
duplexOpenInput({ workerSessionId: "ws-u", mode: "no-write-reply" }),
|
||||
);
|
||||
// "a€" is two characters but four UTF-8 bytes (one plus three). The raw-payload
|
||||
// reservation must charge four bytes, so it uses the UTF-8 byte count.
|
||||
const data = "a€";
|
||||
expect(data.length).toBe(2);
|
||||
expect(Buffer.byteLength(data, "utf8")).toBe(4);
|
||||
route.write(data);
|
||||
// The transport token flushes, so one raw-payload token of four bytes remains.
|
||||
await vi.waitFor(() => {
|
||||
expect(ledger.liveTokenCount).toBe(1);
|
||||
expect(ledger.bytesInUse).toBe(4);
|
||||
});
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
await vi.waitFor(() => {
|
||||
expect(ledger.bytesInUse).toBe(0);
|
||||
expect(ledger.liveTokenCount).toBe(0);
|
||||
});
|
||||
expect(telemetry.underflows).toBe(0);
|
||||
});
|
||||
|
||||
it("ends an over-ceiling single write fail-closed with the aggregate marker, not the route-busy marker", async () => {
|
||||
vi.mocked(logger.warn).mockClear();
|
||||
const telemetry = peakTrackingTelemetry();
|
||||
// The ceiling is smaller than one raw payload, so the raw-payload reservation
|
||||
// fails before the frame reaches the transport. The manager retains nothing.
|
||||
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 100, telemetry });
|
||||
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
|
||||
try {
|
||||
await handle.start();
|
||||
const route = await handle.openDuplexChannel(
|
||||
duplexOpenInput({ workerSessionId: "ws-o", mode: "no-write-reply" }),
|
||||
);
|
||||
// The single write is larger than the ceiling. The raw-payload reservation
|
||||
// fails, so the manager ends the route with the aggregate marker, never the
|
||||
// route-busy marker, and never writes the frame.
|
||||
route.write("x".repeat(200));
|
||||
expect(telemetry.rejections).toBeGreaterThanOrEqual(1);
|
||||
expect(ledger.bytesInUse).toBe(0);
|
||||
expect(telemetry.peak).toBeLessThanOrEqual(ledger.ceilingBytes);
|
||||
const reasons = loggedWarnReasons();
|
||||
expect(reasons).toContain(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED);
|
||||
expect(reasons).not.toContain("DUPLEX_CHANNEL_ROUTE_BUSY");
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
await vi.waitFor(() => {
|
||||
expect(ledger.bytesInUse).toBe(0);
|
||||
expect(ledger.liveTokenCount).toBe(0);
|
||||
});
|
||||
expect(telemetry.underflows).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import {
|
||||
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
|
||||
DuplexAggregateByteLedger,
|
||||
type DuplexAggregateByteLedgerTelemetry,
|
||||
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
|
||||
|
||||
// Mock the shared logger, so a test reads the fixed rejection marker the manager
|
||||
// logs when a transport reservation fails. The child logger returns the same
|
||||
// object, so `log.warn` is this mock's `warn`.
|
||||
vi.mock("../middleware/logger.js", () => {
|
||||
const mockLogger: Record<string, unknown> = {
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
child: vi.fn(() => mockLogger),
|
||||
};
|
||||
return { logger: mockLogger, httpLogger: vi.fn() };
|
||||
});
|
||||
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { createPluginWorkerHandle } from "../services/plugin-worker-manager.js";
|
||||
|
||||
// This suite proves the plugin worker manager charges the child-stdin transport
|
||||
// buffer for every host→worker duplex write, under the `stdin_write` owner. The
|
||||
// worker completes the open, then stops reading its stdin. The host write then
|
||||
// stays in the host stdin write buffer. The tests prove the reservation covers the
|
||||
// serialized frame, holds until the flush or the worker exit, survives the write
|
||||
// RPC timeout and the route terminalization, and bounds the aggregate transport
|
||||
// bytes across several routes and workers.
|
||||
|
||||
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
|
||||
const DUPLEX_CHANNEL_WORKER_ENTRYPOINT = path.join(
|
||||
FIXTURES_DIR,
|
||||
"plugin-worker-duplex-channel.cjs",
|
||||
);
|
||||
|
||||
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
|
||||
id: "test.plugin",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Test plugin",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: [],
|
||||
entrypoints: { worker: "dist/worker.js" },
|
||||
};
|
||||
|
||||
function makeDuplexHandle(extra?: Record<string, unknown>) {
|
||||
return createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: DUPLEX_CHANNEL_WORKER_ENTRYPOINT,
|
||||
manifest: TEST_MANIFEST,
|
||||
config: {},
|
||||
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
|
||||
apiVersion: 1,
|
||||
hostHandlers: {},
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
// The test directive rides in `providerLeaseId`, an opaque field the manager
|
||||
// forwards to the worker unchanged.
|
||||
function duplexOpenInput(directive: unknown, companyId = "company-1") {
|
||||
return {
|
||||
driverKey: "daytona",
|
||||
companyId,
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: JSON.stringify(directive),
|
||||
command: "bridge-callback",
|
||||
};
|
||||
}
|
||||
|
||||
// A telemetry surface that tracks the peak gauge value and the two counters. A
|
||||
// test asserts the peak never passes the ceiling, so it proves aggregate admission
|
||||
// stops at the ceiling instead of overshooting it.
|
||||
function peakTrackingTelemetry(): DuplexAggregateByteLedgerTelemetry & {
|
||||
gauge: number;
|
||||
peak: number;
|
||||
rejections: number;
|
||||
underflows: number;
|
||||
} {
|
||||
const state = {
|
||||
gauge: 0,
|
||||
peak: 0,
|
||||
rejections: 0,
|
||||
underflows: 0,
|
||||
setBytesInUse(bytes: number) {
|
||||
state.gauge = bytes;
|
||||
if (bytes > state.peak) state.peak = bytes;
|
||||
},
|
||||
recordReservationRejection() {
|
||||
state.rejections += 1;
|
||||
},
|
||||
recordAccountingUnderflow() {
|
||||
state.underflows += 1;
|
||||
},
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
// Return every reason string the manager logged through the mock `warn` sink.
|
||||
function loggedWarnReasons(): string[] {
|
||||
const calls = (logger.warn as unknown as { mock: { calls: unknown[][] } }).mock.calls;
|
||||
return calls
|
||||
.map((call) => {
|
||||
const first = call[0];
|
||||
return first && typeof first === "object"
|
||||
? (first as { reason?: unknown }).reason
|
||||
: undefined;
|
||||
})
|
||||
.filter((reason): reason is string => typeof reason === "string");
|
||||
}
|
||||
|
||||
// A frame the host keeps in its stdin write buffer must pass the operating-system
|
||||
// pipe buffer (about 64 KiB), so the write callback stays pending while the child
|
||||
// does not read. Each write here is larger than that, so the host holds the
|
||||
// transport token until the worker exit.
|
||||
const HELD_WRITE_CHARS = 200_000;
|
||||
|
||||
describe("plugin worker manager duplex stdin transport byte ledger", () => {
|
||||
it("reserves the encoded serialized-frame size, larger than the raw payload, including JSON escaping", async () => {
|
||||
const telemetry = peakTrackingTelemetry();
|
||||
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 8 << 20, telemetry });
|
||||
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
|
||||
try {
|
||||
await handle.start();
|
||||
const route = await handle.openDuplexChannel(
|
||||
duplexOpenInput({
|
||||
workerSessionId: "ws-a",
|
||||
stopReadingStdinAfterOpen: true,
|
||||
exitAfterStopMs: 500,
|
||||
}),
|
||||
);
|
||||
// Every character is a quote. The JSON serialization escapes each quote to two
|
||||
// bytes, so the serialized frame is at least twice the raw payload byte count.
|
||||
const data = '"'.repeat(50_000);
|
||||
const rawBytes = Buffer.byteLength(data, "utf8");
|
||||
route.write(data);
|
||||
// The raw-payload token and the transport token both hold at once. The worker
|
||||
// does not read its stdin, so the transport token never flushes.
|
||||
expect(ledger.liveTokenCount).toBe(2);
|
||||
const transportBytes = ledger.bytesInUse - rawBytes;
|
||||
// The transport reservation covers the serialized frame, so it is larger than
|
||||
// the raw payload and it includes the doubled escaped quotes.
|
||||
expect(transportBytes).toBeGreaterThan(rawBytes);
|
||||
expect(transportBytes).toBeGreaterThanOrEqual(2 * rawBytes);
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
// The worker exit discards the stdin buffer, so every token releases one time.
|
||||
await vi.waitFor(() => {
|
||||
expect(ledger.bytesInUse).toBe(0);
|
||||
expect(ledger.liveTokenCount).toBe(0);
|
||||
});
|
||||
expect(telemetry.underflows).toBe(0);
|
||||
});
|
||||
|
||||
it("holds the transport token across a write-RPC timeout and releases it on worker exit", async () => {
|
||||
const telemetry = peakTrackingTelemetry();
|
||||
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 8 << 20, telemetry });
|
||||
// The write RPC times out fast; the worker exits later. The gap lets a test
|
||||
// prove the timeout does not release the transport token.
|
||||
const handle = makeDuplexHandle({
|
||||
duplexAggregateByteLedger: ledger,
|
||||
duplexChannelLimits: { openTimeoutMs: 150 },
|
||||
});
|
||||
try {
|
||||
await handle.start();
|
||||
const route = await handle.openDuplexChannel(
|
||||
duplexOpenInput({
|
||||
workerSessionId: "ws-t",
|
||||
stopReadingStdinAfterOpen: true,
|
||||
exitAfterStopMs: 900,
|
||||
}),
|
||||
);
|
||||
const data = "x".repeat(HELD_WRITE_CHARS);
|
||||
const rawBytes = Buffer.byteLength(data, "utf8");
|
||||
route.write(data);
|
||||
// Both tokens hold at first: the raw payload and the serialized frame.
|
||||
expect(ledger.liveTokenCount).toBe(2);
|
||||
const transportBytes = ledger.bytesInUse - rawBytes;
|
||||
expect(transportBytes).toBeGreaterThan(0);
|
||||
// The write RPC times out. The RPC settle releases the raw-payload token, so
|
||||
// one token remains: the transport token. The timeout never releases it.
|
||||
await vi.waitFor(() => {
|
||||
expect(ledger.liveTokenCount).toBe(1);
|
||||
});
|
||||
expect(ledger.bytesInUse).toBe(transportBytes);
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
// The worker exit releases the still-held transport token.
|
||||
await vi.waitFor(() => {
|
||||
expect(ledger.bytesInUse).toBe(0);
|
||||
expect(ledger.liveTokenCount).toBe(0);
|
||||
});
|
||||
expect(telemetry.underflows).toBe(0);
|
||||
});
|
||||
|
||||
it("does not release the transport token when the route terminalizes", async () => {
|
||||
const telemetry = peakTrackingTelemetry();
|
||||
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 8 << 20, telemetry });
|
||||
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
|
||||
try {
|
||||
await handle.start();
|
||||
const route = await handle.openDuplexChannel(
|
||||
duplexOpenInput({
|
||||
workerSessionId: "ws-c",
|
||||
stopReadingStdinAfterOpen: true,
|
||||
exitAfterStopMs: 700,
|
||||
}),
|
||||
);
|
||||
const data = "x".repeat(HELD_WRITE_CHARS);
|
||||
const rawBytes = Buffer.byteLength(data, "utf8");
|
||||
route.write(data);
|
||||
expect(ledger.liveTokenCount).toBe(2);
|
||||
const transportBytes = ledger.bytesInUse - rawBytes;
|
||||
// End the route. Route terminalization releases the route-owned tokens, but it
|
||||
// must never release the transport token, so at least the serialized frame
|
||||
// stays charged.
|
||||
void route.close();
|
||||
expect(ledger.bytesInUse).toBeGreaterThanOrEqual(transportBytes);
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
await vi.waitFor(() => {
|
||||
expect(ledger.bytesInUse).toBe(0);
|
||||
expect(ledger.liveTokenCount).toBe(0);
|
||||
});
|
||||
expect(telemetry.underflows).toBe(0);
|
||||
});
|
||||
|
||||
it("bounds the aggregate transport bytes across several routes and workers, ending an over-ceiling near-limit write with the aggregate marker", async () => {
|
||||
vi.mocked(logger.warn).mockClear();
|
||||
const telemetry = peakTrackingTelemetry();
|
||||
// The ceiling admits only a couple of held serialized frames, so a near-limit
|
||||
// write across the routes passes it and rejects.
|
||||
const ceilingBytes = 600_000;
|
||||
const ledger = new DuplexAggregateByteLedger({ ceilingBytes, telemetry });
|
||||
const handleA = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
|
||||
const handleB = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
|
||||
try {
|
||||
await handleA.start();
|
||||
await handleB.start();
|
||||
const routeA = await handleA.openDuplexChannel(
|
||||
duplexOpenInput({
|
||||
workerSessionId: "ws-a",
|
||||
stopReadingStdinAfterOpen: true,
|
||||
exitAfterStopMs: 900,
|
||||
}),
|
||||
);
|
||||
const routeB = await handleB.openDuplexChannel(
|
||||
duplexOpenInput({
|
||||
workerSessionId: "ws-b",
|
||||
stopReadingStdinAfterOpen: true,
|
||||
exitAfterStopMs: 900,
|
||||
}),
|
||||
);
|
||||
// Each write mixes escaped quotes and multi-byte characters near the per-write
|
||||
// size. The serialized frame escapes each quote and keeps each euro sign as
|
||||
// three UTF-8 bytes, so the reservation must count the encoded size.
|
||||
const escaped = '"'.repeat(40_000);
|
||||
const multiByte = "€".repeat(20_000);
|
||||
const nearLimit = escaped + multiByte;
|
||||
const routes = [routeA, routeB, routeA, routeB];
|
||||
for (const route of routes) {
|
||||
route.write(nearLimit);
|
||||
// The aggregate bytes never pass the ceiling. Admission stops at the ceiling.
|
||||
expect(ledger.bytesInUse).toBeLessThanOrEqual(ceilingBytes);
|
||||
}
|
||||
// At least one near-limit write passed the ceiling and rejected. The manager
|
||||
// ended that route with the aggregate marker, never the route-busy marker.
|
||||
expect(telemetry.rejections).toBeGreaterThanOrEqual(1);
|
||||
expect(telemetry.peak).toBeLessThanOrEqual(ceilingBytes);
|
||||
const reasons = loggedWarnReasons();
|
||||
expect(reasons).toContain(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED);
|
||||
expect(reasons).not.toContain("DUPLEX_CHANNEL_ROUTE_BUSY");
|
||||
} finally {
|
||||
await handleA.stop().catch(() => undefined);
|
||||
await handleB.stop().catch(() => undefined);
|
||||
}
|
||||
// Every worker exit releases its held transport tokens, so the ledger ends at
|
||||
// zero with no accounting defect.
|
||||
await vi.waitFor(() => {
|
||||
expect(ledger.bytesInUse).toBe(0);
|
||||
expect(ledger.liveTokenCount).toBe(0);
|
||||
});
|
||||
expect(telemetry.underflows).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -163,6 +163,30 @@ describe("plugin worker manager duplex channel route", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("carries a transport-close exit through to the wait result", async () => {
|
||||
const handle = makeDuplexHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openDuplexChannel(
|
||||
duplexOpenInput({
|
||||
workerSessionId: "ws-A",
|
||||
data: [{ chunk: "one" }],
|
||||
// The worker reports a reason-less transport close with no exit code.
|
||||
transportClosed: true,
|
||||
}),
|
||||
);
|
||||
const chunks: string[] = [];
|
||||
session.onData((chunk) => chunks.push(chunk));
|
||||
// The discriminator survives the worker exit notification, so the host wait
|
||||
// resolves with the transport-close mark and no exit code.
|
||||
await expect(session.wait()).resolves.toEqual({ exitCode: null, transportClosed: true });
|
||||
expect(chunks).toEqual(["one"]);
|
||||
await session.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("isolates a throwing listener during the buffered replay so every buffered chunk routes", async () => {
|
||||
const handle = makeDuplexHandle();
|
||||
try {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
type HostServices,
|
||||
type HostToWorkerMethods,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import { CLAUDE_SETUP_TOKEN_COMMAND } from "@paperclipai/adapter-claude-local/server";
|
||||
import {
|
||||
appendStderrExcerpt,
|
||||
createPluginWorkerHandle,
|
||||
@@ -1060,14 +1059,14 @@ describe("plugin worker manager execute.log route", () => {
|
||||
// Host-owned setup-token login pseudo-terminal route gate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SETUP_TOKEN_PTY_WORKER_ENTRYPOINT = path.join(
|
||||
const LOGIN_PTY_WORKER_ENTRYPOINT = path.join(
|
||||
FIXTURES_DIR,
|
||||
"plugin-worker-setup-token-pty.cjs",
|
||||
"plugin-worker-login-pty.cjs",
|
||||
);
|
||||
|
||||
function makeSetupTokenPtyHandle(extra?: Record<string, unknown>) {
|
||||
function makeLoginPtyHandle(extra?: Record<string, unknown>) {
|
||||
return createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: SETUP_TOKEN_PTY_WORKER_ENTRYPOINT,
|
||||
entrypointPath: LOGIN_PTY_WORKER_ENTRYPOINT,
|
||||
manifest: TEST_MANIFEST,
|
||||
config: {},
|
||||
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
|
||||
@@ -1077,40 +1076,72 @@ function makeSetupTokenPtyHandle(extra?: Record<string, unknown>) {
|
||||
});
|
||||
}
|
||||
|
||||
// One valid session home. The shape is the fixed root, one slash, and one UUID.
|
||||
const PTY_SESSION_HOME = "/tmp/paperclip-adapter-login/11111111-2222-4333-8444-555555555555";
|
||||
|
||||
function ptyOpenInput(directive: unknown) {
|
||||
return {
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
// The test directive rides in `providerLeaseId`, an opaque field the manager
|
||||
// forwards to the worker unchanged. The manager allowlists `command`, so the
|
||||
// command stays the fixed `CLAUDE_SETUP_TOKEN_COMMAND` for every route-gate
|
||||
// case.
|
||||
// forwards to the worker unchanged. The manager carries the closed command
|
||||
// key and the validated session home; it carries no command string.
|
||||
providerLeaseId: JSON.stringify(directive),
|
||||
command: CLAUDE_SETUP_TOKEN_COMMAND,
|
||||
loginCommandKey: "claude" as const,
|
||||
sessionHome: PTY_SESSION_HOME,
|
||||
};
|
||||
}
|
||||
|
||||
describe("plugin worker manager setup-token pty route gate", () => {
|
||||
it("rejects a command that is not the allowlisted setup-token command before the worker call", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
it("rejects a command key that is not in the closed set before the worker call", async () => {
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
// A caller passes a command other than the fixed `CLAUDE_SETUP_TOKEN_COMMAND`.
|
||||
// The manager rejects it with one fixed non-secret error before the worker
|
||||
// call, so no arbitrary process spawns in the sandbox pseudo-terminal.
|
||||
// The provider driver key routes the worker; it confers no command
|
||||
// authority. A key outside the closed set rejects with one fixed non-secret
|
||||
// error before the worker call, so a caller cannot select an arbitrary
|
||||
// command in the sandbox pseudo-terminal.
|
||||
await expect(
|
||||
handle.openSetupTokenPtySession({
|
||||
handle.openLoginPtySession({
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: JSON.stringify({ mode: "normal" }),
|
||||
command: "rm -rf /",
|
||||
loginCommandKey: "gemini" as unknown as "claude",
|
||||
sessionHome: PTY_SESSION_HOME,
|
||||
}),
|
||||
).rejects.toThrow("SETUP_TOKEN_PTY_COMMAND_NOT_ALLOWED");
|
||||
// The rejected open never consumed the single route, so a later open with
|
||||
// the allowlisted command still succeeds.
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
).rejects.toThrow("LOGIN_PTY_COMMAND_NOT_ALLOWED");
|
||||
// The rejected open never consumed the single route, so a later open with a
|
||||
// closed key still succeeds.
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
);
|
||||
expect(session).toBeDefined();
|
||||
await session.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a malformed session home before the worker call", async () => {
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
// The host revalidates the server-controlled session home shape before the
|
||||
// worker RPC. A traversal candidate fails closed at the last host gate.
|
||||
await expect(
|
||||
handle.openLoginPtySession({
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: JSON.stringify({ mode: "normal" }),
|
||||
loginCommandKey: "claude" as const,
|
||||
sessionHome: "/tmp/paperclip-adapter-login/../etc",
|
||||
}),
|
||||
).rejects.toThrow("LOGIN_PTY_INVALID_SESSION_HOME");
|
||||
// The rejected open never consumed the single route.
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
);
|
||||
expect(session).toBeDefined();
|
||||
@@ -1121,21 +1152,21 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
||||
});
|
||||
|
||||
it("permits one active credential pseudo-terminal per worker", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const first = await handle.openSetupTokenPtySession(
|
||||
const first = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
);
|
||||
// A second open while the first route is not closed rejects with one fixed
|
||||
// non-secret error before it reaches the worker.
|
||||
await expect(
|
||||
handle.openSetupTokenPtySession(ptyOpenInput({ mode: "normal" })),
|
||||
).rejects.toThrow("SETUP_TOKEN_PTY_ROUTE_BUSY");
|
||||
handle.openLoginPtySession(ptyOpenInput({ mode: "normal" })),
|
||||
).rejects.toThrow("LOGIN_PTY_ROUTE_BUSY");
|
||||
await first.close();
|
||||
// After the first route closes and the worker acknowledges the close, a new
|
||||
// open is admitted.
|
||||
const second = await handle.openSetupTokenPtySession(
|
||||
const second = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
);
|
||||
await second.close();
|
||||
@@ -1145,10 +1176,10 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
||||
});
|
||||
|
||||
it("delivers output only for the exact bound worker session id and drops a mismatch", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({
|
||||
workerSessionId: "ws-A",
|
||||
outputs: [
|
||||
@@ -1172,10 +1203,10 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
||||
});
|
||||
|
||||
it("routes delayed input to the worker and back to the listener", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-A" }),
|
||||
);
|
||||
const chunks: string[] = [];
|
||||
@@ -1191,12 +1222,12 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
||||
});
|
||||
|
||||
it("terminalizes the route when the cumulative output passes the per-route bound", async () => {
|
||||
const handle = makeSetupTokenPtyHandle({
|
||||
setupTokenPtyLimits: { maxTotalChars: 10 },
|
||||
const handle = makeLoginPtyHandle({
|
||||
loginPtyLimits: { maxTotalChars: 10 },
|
||||
});
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({
|
||||
outputs: [
|
||||
{ chunk: "aaaaa" }, // total 5 → delivered
|
||||
@@ -1217,15 +1248,15 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
||||
});
|
||||
|
||||
it("terminalizes and fails closed on a malformed open reply, then admits a later open", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
await expect(
|
||||
handle.openSetupTokenPtySession(ptyOpenInput({ mode: "malformed-open" })),
|
||||
).rejects.toThrow("SETUP_TOKEN_PTY_OPEN_FAILED");
|
||||
handle.openLoginPtySession(ptyOpenInput({ mode: "malformed-open" })),
|
||||
).rejects.toThrow("LOGIN_PTY_OPEN_FAILED");
|
||||
// The terminalize closed the route by the host route id and the worker
|
||||
// acknowledged the close, so a later open is admitted.
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
);
|
||||
expect(session).toBeDefined();
|
||||
@@ -1236,13 +1267,13 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
||||
});
|
||||
|
||||
it("terminalizes the route on an open timeout", async () => {
|
||||
const handle = makeSetupTokenPtyHandle({
|
||||
setupTokenPtyLimits: { openTimeoutMs: 200 },
|
||||
const handle = makeLoginPtyHandle({
|
||||
loginPtyLimits: { openTimeoutMs: 200 },
|
||||
});
|
||||
try {
|
||||
await handle.start();
|
||||
await expect(
|
||||
handle.openSetupTokenPtySession(ptyOpenInput({ mode: "no-open-reply" })),
|
||||
handle.openLoginPtySession(ptyOpenInput({ mode: "no-open-reply" })),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
@@ -1250,10 +1281,10 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
||||
});
|
||||
|
||||
it("binds the worker session id one time and ignores a duplicate open reply", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({
|
||||
mode: "duplicate-open-reply",
|
||||
workerSessionId: "ws-A",
|
||||
@@ -1274,10 +1305,10 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
||||
});
|
||||
|
||||
it("closes the route with a fixed exit when the worker exits", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
);
|
||||
const waitResult = session.wait();
|
||||
@@ -1291,15 +1322,15 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
||||
});
|
||||
|
||||
it("retires the worker on an unconfirmed close acknowledgement", async () => {
|
||||
const handle = makeSetupTokenPtyHandle({
|
||||
setupTokenPtyLimits: { closeTimeoutMs: 200 },
|
||||
const handle = makeLoginPtyHandle({
|
||||
loginPtyLimits: { closeTimeoutMs: 200 },
|
||||
});
|
||||
try {
|
||||
await handle.start();
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
handle.on("exit", () => resolve());
|
||||
});
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ mode: "normal", closeMode: "bad-ack" }),
|
||||
);
|
||||
await session.close();
|
||||
@@ -1307,7 +1338,7 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
||||
// fails closed and retires the worker before any reuse.
|
||||
await exited;
|
||||
await expect(
|
||||
handle.openSetupTokenPtySession(ptyOpenInput({ mode: "normal" })),
|
||||
handle.openLoginPtySession(ptyOpenInput({ mode: "normal" })),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
|
||||
@@ -18,7 +18,6 @@ function makeModule(loginCapability?: unknown) {
|
||||
|
||||
const validLoginCapability: AdapterLoginCapability = {
|
||||
panelMode: "displayed_code",
|
||||
sandboxTransport: "streamed_exec",
|
||||
timeoutPolicy: "caller_bounded",
|
||||
getCommand: () => "vendor login",
|
||||
parsePrompt: () => null,
|
||||
|
||||
@@ -26,7 +26,6 @@ describe("built-in adapter login capabilities", () => {
|
||||
expect(capability).toBeDefined();
|
||||
if (!capability) return;
|
||||
expect(capability.panelMode).toBe("displayed_code");
|
||||
expect(capability.sandboxTransport).toBe("streamed_exec");
|
||||
expect(capability.timeoutPolicy).toBe("caller_bounded");
|
||||
expect(capability.completionClaim).toBeUndefined();
|
||||
expect(typeof capability.getCommand).toBe("function");
|
||||
@@ -39,7 +38,6 @@ describe("built-in adapter login capabilities", () => {
|
||||
expect(capability).toBeDefined();
|
||||
if (!capability) return;
|
||||
expect(capability.panelMode).toBe("submitted_browser_code");
|
||||
expect(capability.sandboxTransport).toBe("pseudo_terminal");
|
||||
expect(capability.timeoutPolicy).toBe("fixed");
|
||||
expect(capability.completionClaim).toBe("storedSessionId");
|
||||
expect(typeof capability.getCommand).toBe("function");
|
||||
|
||||
@@ -604,7 +604,6 @@ Paperclip keeps this tombstone registered so stale acpx_local rows fail clearly
|
||||
// values only.
|
||||
const claudeLoginCapability: AdapterLoginCapability = {
|
||||
panelMode: "submitted_browser_code",
|
||||
sandboxTransport: "pseudo_terminal",
|
||||
timeoutPolicy: "fixed",
|
||||
getCommand: () => CLAUDE_SETUP_TOKEN_COMMAND,
|
||||
parsePrompt: (output) => {
|
||||
@@ -619,13 +618,13 @@ const claudeLoginCapability: AdapterLoginCapability = {
|
||||
};
|
||||
|
||||
// The Codex interactive login capability. Codex runs `codex login --device-auth`
|
||||
// over the streamed exec channel. The flow shows a one-time code that the user
|
||||
// enters in the browser. The caller sets the host-side timeout. The device-login
|
||||
// flow writes its credential inside the sandbox, so the capability declares no
|
||||
// terminal credential capture and no completion claim.
|
||||
// on a real pseudo-terminal, because a pipe emits no login prompt. The flow shows
|
||||
// a one-time code that the user enters in the browser. The caller sets the
|
||||
// host-side timeout. The device-login flow writes its credential inside the
|
||||
// sandbox, so the capability declares no terminal credential capture and no
|
||||
// completion claim.
|
||||
const codexLoginCapability: AdapterLoginCapability = {
|
||||
panelMode: "displayed_code",
|
||||
sandboxTransport: "streamed_exec",
|
||||
timeoutPolicy: "caller_bounded",
|
||||
getCommand: () => CODEX_DEVICE_LOGIN_COMMAND,
|
||||
parsePrompt: (output) => {
|
||||
|
||||
+2
-2
@@ -36,7 +36,7 @@ import {
|
||||
createProductionSetupTokenSandboxProvider,
|
||||
createProductionSetupTokenCleanupStore,
|
||||
createSetupTokenSecretWriter,
|
||||
createWorkerBoundSetupTokenPtyOpener,
|
||||
createWorkerBoundLoginPtyOpener,
|
||||
} from "./services/setup-token-transport-binding.js";
|
||||
import { environmentService } from "./services/environments.js";
|
||||
import { environmentRuntimeService } from "./services/environment-runtime.js";
|
||||
@@ -466,7 +466,7 @@ export async function createApp(
|
||||
sandbox: createProductionSetupTokenSandboxProvider({
|
||||
environments: environmentService(db),
|
||||
environmentRuntime: environmentRuntimeService(db, { pluginWorkerManager: workerManager }),
|
||||
openLivePtySession: createWorkerBoundSetupTokenPtyOpener({
|
||||
openLivePtySession: createWorkerBoundLoginPtyOpener({
|
||||
workerManager,
|
||||
environments: environmentService(db),
|
||||
log: (line) => logger.info(line),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
resolveDuplexAggregateCeilingBytes,
|
||||
type DuplexAggregateCeilingOverrideRejectionReporter,
|
||||
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
|
||||
|
||||
/**
|
||||
* Resolve the aggregate duplex route byte ceiling from the raw operator override
|
||||
* string. The host reads the override from
|
||||
* PAPERCLIP_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES.
|
||||
*
|
||||
* This helper distinguishes an absent variable from a present blank value. An
|
||||
* absent variable is `undefined`. The helper then uses the documented default and
|
||||
* does not report a rejection. A present variable is a string, even when the
|
||||
* string holds only whitespace. The helper sends every present string through
|
||||
* `Number`, so a blank or whitespace-only value becomes `0`. The numeric
|
||||
* validation in {@link resolveDuplexAggregateCeilingBytes} rejects `0`, reports it
|
||||
* through `onRejectedOverride`, and returns the safe default. So a present blank
|
||||
* value is visible as invalid instead of silent as absent.
|
||||
*
|
||||
* `Number` ignores surrounding whitespace, so a valid value with surrounding
|
||||
* spaces still parses. The reporter receives only the parsed number, never the raw
|
||||
* string, so no operator-supplied text reaches a log line.
|
||||
*/
|
||||
export function resolveDuplexAggregateCeilingBytesFromEnv(
|
||||
rawOverride: string | undefined,
|
||||
onRejectedOverride?: DuplexAggregateCeilingOverrideRejectionReporter,
|
||||
): number {
|
||||
return resolveDuplexAggregateCeilingBytes(
|
||||
rawOverride === undefined ? undefined : Number(rawOverride),
|
||||
onRejectedOverride,
|
||||
);
|
||||
}
|
||||
+57
-2
@@ -84,6 +84,13 @@ import { createFeedbackTraceShareClientFromConfig } from "./services/feedback-sh
|
||||
import { buildRuntimeApiCandidateUrls, choosePrimaryRuntimeApiUrl } from "./runtime-api.js";
|
||||
import { isLoopbackHost, rewriteLoopbackUrlPort } from "./url-utils.js";
|
||||
import { createPluginWorkerManager } from "./services/plugin-worker-manager.js";
|
||||
import {
|
||||
createDuplexAggregateByteLedgerTelemetry,
|
||||
DuplexAggregateByteLedger,
|
||||
type DuplexAggregateByteLedgerMetricSink,
|
||||
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
|
||||
import { resolveDuplexAggregateCeilingBytesFromEnv } from "./duplex-aggregate-ceiling-env.js";
|
||||
import { DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL } from "@paperclipai/adapter-utils/duplex-telemetry";
|
||||
import { createStorageServiceFromConfig } from "./storage/index.js";
|
||||
import { printStartupBanner } from "./startup-banner.js";
|
||||
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
|
||||
@@ -755,9 +762,57 @@ export async function startServer(): Promise<StartedServer> {
|
||||
databaseBackupInFlight = false;
|
||||
}
|
||||
};
|
||||
const pluginWorkerManager = createPluginWorkerManager();
|
||||
// The process-owned aggregate byte ledger for the sandbox duplex channel. One
|
||||
// ledger per host process bounds the aggregate bytes that all live duplex routes
|
||||
// retain. The route-count controller bounds only the route count, so without this
|
||||
// ledger the per-route byte bounds multiply to many gigabytes at the maximum
|
||||
// route count. The manager injects this same object into every worker handle, so
|
||||
// one shared gauge bounds every host-side retention site.
|
||||
//
|
||||
// The optional operator override reads PAPERCLIP_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES.
|
||||
// An absent variable uses the documented default. A present invalid, blank,
|
||||
// whitespace-only, non-finite, zero, negative, non-integer, unsafe, or
|
||||
// over-maximum value does not fail startup. The host process is multi-tenant, so
|
||||
// one invalid environment value must not brick the whole host. The helper sends
|
||||
// the raw string to the resolver, so a present blank value is invalid, not
|
||||
// absent. The resolver rejects the invalid override and returns the safe default.
|
||||
// The reporter logs the rejection loudly at error, so the misconfiguration stays
|
||||
// visible while the host stays up. The log line carries only the rejected numeric
|
||||
// value, never the raw string.
|
||||
const duplexAggregateCeilingBytes = resolveDuplexAggregateCeilingBytesFromEnv(
|
||||
process.env.PAPERCLIP_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
|
||||
(rejectedValue) => {
|
||||
logger.error(
|
||||
{ rejectedValue },
|
||||
"duplex aggregate byte ceiling override rejected; using the safe default",
|
||||
);
|
||||
},
|
||||
);
|
||||
// The server has no process metric pipeline yet, so the ledger telemetry maps to
|
||||
// the structured logger. The gauge logs at debug. A reservation rejection logs at
|
||||
// warn, because it marks an availability limit hit. An accounting-underflow defect
|
||||
// logs at error, because it marks a real cleanup bug. Each record carries only the
|
||||
// fixed metric name and the numeric value; no route, company, run, or payload
|
||||
// value reaches a log line.
|
||||
const duplexAggregateByteLedgerMetricSink: DuplexAggregateByteLedgerMetricSink = {
|
||||
setGauge(name, value) {
|
||||
logger.debug({ metric: name, value }, "duplex aggregate byte ledger gauge");
|
||||
},
|
||||
incrementCounter(name) {
|
||||
if (name === DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL) {
|
||||
logger.error({ metric: name }, "duplex aggregate byte ledger accounting underflow");
|
||||
return;
|
||||
}
|
||||
logger.warn({ metric: name }, "duplex aggregate byte ledger reservation rejected");
|
||||
},
|
||||
};
|
||||
const duplexAggregateByteLedger = new DuplexAggregateByteLedger({
|
||||
ceilingBytes: duplexAggregateCeilingBytes,
|
||||
telemetry: createDuplexAggregateByteLedgerTelemetry(duplexAggregateByteLedgerMetricSink),
|
||||
});
|
||||
const pluginWorkerManager = createPluginWorkerManager({ duplexAggregateByteLedger });
|
||||
const heartbeat = config.heartbeatSchedulerEnabled
|
||||
? heartbeatService(db as any, { pluginWorkerManager })
|
||||
? heartbeatService(db as any, { pluginWorkerManager, duplexAggregateByteLedger })
|
||||
: null;
|
||||
const decisionServiceOptions = {
|
||||
wakeOriginAgent: createDecisionWakeOriginAgent(heartbeat?.wakeup ?? null),
|
||||
|
||||
@@ -22,7 +22,6 @@ function makeAdapter(overrides: Partial<ServerAdapterModule> = {}): ServerAdapte
|
||||
|
||||
const displayedCodeLogin: AdapterLoginCapability = {
|
||||
panelMode: "displayed_code",
|
||||
sandboxTransport: "streamed_exec",
|
||||
timeoutPolicy: "caller_bounded",
|
||||
getCommand: () => "vendor login",
|
||||
parsePrompt: () => null,
|
||||
@@ -33,7 +32,6 @@ describe("buildAdapterCapabilities login projection", () => {
|
||||
const caps = buildAdapterCapabilities(makeAdapter({ loginCapability: displayedCodeLogin }));
|
||||
expect(caps.login).toEqual({
|
||||
panelMode: "displayed_code",
|
||||
sandboxTransport: "streamed_exec",
|
||||
timeoutPolicy: "caller_bounded",
|
||||
});
|
||||
});
|
||||
@@ -48,7 +46,6 @@ describe("buildAdapterCapabilities login projection", () => {
|
||||
makeAdapter({
|
||||
loginCapability: {
|
||||
panelMode: "submitted_browser_code",
|
||||
sandboxTransport: "pseudo_terminal",
|
||||
timeoutPolicy: "fixed",
|
||||
getCommand: () => "vendor setup-token",
|
||||
parsePrompt: () => null,
|
||||
@@ -59,7 +56,6 @@ describe("buildAdapterCapabilities login projection", () => {
|
||||
);
|
||||
expect(caps.login).toEqual({
|
||||
panelMode: "submitted_browser_code",
|
||||
sandboxTransport: "pseudo_terminal",
|
||||
timeoutPolicy: "fixed",
|
||||
});
|
||||
expect(caps.login).not.toHaveProperty("getCommand");
|
||||
|
||||
@@ -43,7 +43,6 @@ import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js";
|
||||
import type { ServerAdapterModule, AdapterConfigSchema } from "../adapters/types.js";
|
||||
import type {
|
||||
AdapterLoginPanelMode,
|
||||
AdapterLoginSandboxTransport,
|
||||
AdapterLoginTimeoutPolicy,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js";
|
||||
@@ -101,13 +100,13 @@ interface AdapterInstallRequest {
|
||||
|
||||
/**
|
||||
* The safe scalar login fields the adapter listing projects to the client. It
|
||||
* carries only the panel mode, the sandbox transport, and the timeout policy.
|
||||
* It carries no function member and no secret. The user interface reads it to
|
||||
* pick the login flow and the login panel.
|
||||
* carries only the panel mode and the timeout policy. It carries no function
|
||||
* member and no secret. The user interface reads it to pick the login flow and
|
||||
* the login panel. Every login runs on a real pseudo-terminal, so the projection
|
||||
* carries no transport field.
|
||||
*/
|
||||
interface AdapterLoginProjection {
|
||||
panelMode: AdapterLoginPanelMode;
|
||||
sandboxTransport: AdapterLoginSandboxTransport;
|
||||
timeoutPolicy: AdapterLoginTimeoutPolicy;
|
||||
}
|
||||
|
||||
@@ -189,7 +188,6 @@ export function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterC
|
||||
? {
|
||||
login: {
|
||||
panelMode: login.panelMode,
|
||||
sandboxTransport: login.sandboxTransport,
|
||||
timeoutPolicy: login.timeoutPolicy,
|
||||
},
|
||||
}
|
||||
|
||||
+94
-30
@@ -90,6 +90,7 @@ import {
|
||||
hasCompanyAccess,
|
||||
} from "./authz.js";
|
||||
import { runAdapterLoginStartSpine } from "./adapter-login-route-spine.js";
|
||||
import { isLoginCommandSupportedAdapterType } from "../services/login-command.js";
|
||||
import {
|
||||
assertNoAgentHostWorkspaceCommandMutation,
|
||||
collectAgentAdapterWorkspaceCommandPaths,
|
||||
@@ -187,8 +188,11 @@ import {
|
||||
import {
|
||||
AdapterAuthSessionConflictError,
|
||||
createCodexDeviceLoginService,
|
||||
createCodexWorkerBoundLoginPtyOpener,
|
||||
createDbAdapterAuthSessionStore,
|
||||
createProductionLoginSessionRuntime,
|
||||
CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED,
|
||||
CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE,
|
||||
} from "../services/codex-device-login-service.js";
|
||||
import type { AdapterAuthSessionOwnerResponse } from "@paperclipai/shared";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
@@ -572,7 +576,28 @@ export function agentRoutes(
|
||||
const adapterLoginStore = createDbAdapterAuthSessionStore(db);
|
||||
const adapterLoginService = createCodexDeviceLoginService({
|
||||
store: adapterLoginStore,
|
||||
runtime: createProductionLoginSessionRuntime({ db, environmentRuntime }),
|
||||
runtime: createProductionLoginSessionRuntime({
|
||||
db,
|
||||
environmentRuntime,
|
||||
// Re-check the provider login pseudo-terminal capability from current
|
||||
// runtime state immediately before the provider lease. The route gate ran
|
||||
// earlier, so a managed reconciliation can rebind the environment to an
|
||||
// unsupported provider between the gate and the acquire; this fails closed
|
||||
// before the lease and the pseudo-terminal.
|
||||
assertProviderSupportsLoginPty: (environmentId) =>
|
||||
assertCodexLoginProviderCapability(environmentId),
|
||||
// Wire the live Codex pseudo-terminal opener through the plugin worker
|
||||
// manager route. The opener sets the sandbox `CODEX_HOME` to the same
|
||||
// server-controlled session home the descriptor-bound credential read
|
||||
// opens. When no worker manager is bound, the runtime keeps its fail-closed
|
||||
// opener and the login fails closed.
|
||||
openLivePtySession: options.pluginWorkerManager
|
||||
? createCodexWorkerBoundLoginPtyOpener({
|
||||
workerManager: options.pluginWorkerManager,
|
||||
log: (line) => logger.info(line),
|
||||
})
|
||||
: undefined,
|
||||
}),
|
||||
// The mandatory credential promotion. A successful login authenticates only
|
||||
// after this promotion validates the exact staged credential, runs an
|
||||
// independent readiness check, confirms the session still holds the sole
|
||||
@@ -1405,15 +1430,25 @@ export function agentRoutes(
|
||||
return findActiveServerAdapter(type)?.loginCapability ?? null;
|
||||
}
|
||||
|
||||
// The device-login route drives a login over the streamed exec channel. It
|
||||
// serves any adapter whose registry login capability declares that transport.
|
||||
// The guard reads the capability, not the adapter name, so a new adapter with
|
||||
// the same transport passes with no code change. It rejects an adapter with no
|
||||
// matching capability with a fixed 400.
|
||||
function assertStreamedExecLoginAdapter(type: string): void {
|
||||
if (
|
||||
getRegistryLoginCapability(type)?.sandboxTransport !== "streamed_exec"
|
||||
) {
|
||||
// The device-login route drives a login that shows a one-time code on a real
|
||||
// pseudo-terminal. It serves an adapter whose registry login capability
|
||||
// declares the displayed-code panel mode and whose trusted adapter type maps
|
||||
// to a login command key. The guard reads the panel mode and the command map,
|
||||
// not the adapter name, so a new adapter that satisfies both passes with no
|
||||
// guard code change. It rejects an adapter with no matching capability, and an
|
||||
// adapter with no mapped command key, with the same fixed 400.
|
||||
//
|
||||
// The command-map check keeps admission consistent with the closed command
|
||||
// map. The login opener resolves the command key from the same map. An adapter
|
||||
// that declares the displayed-code capability but has no mapped key would pass
|
||||
// the panel-mode check, then fail at command resolution after the route
|
||||
// creates session state. The guard rejects it before any session or lease side
|
||||
// effect.
|
||||
function assertDeviceLoginAdapter(type: string): void {
|
||||
if (getRegistryLoginCapability(type)?.panelMode !== "displayed_code") {
|
||||
throw badRequest(`Adapter "${type}" does not support a device login.`);
|
||||
}
|
||||
if (!isLoginCommandSupportedAdapterType(type)) {
|
||||
throw badRequest(`Adapter "${type}" does not support a device login.`);
|
||||
}
|
||||
}
|
||||
@@ -1469,16 +1504,15 @@ export function agentRoutes(
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails closed when the environment provider does not advertise the Claude
|
||||
* setup-token login capability. It resolves the effective provider from the
|
||||
* environment config, then reads the static capability from the provider
|
||||
* plugin manifest. It never checks the provider by name. A missing plugin, a
|
||||
* non-plugin provider, and a provider without the flag all fail closed with
|
||||
* the fixed, typed error, so no session row, lease, or pseudo-terminal starts.
|
||||
* Reports whether the environment provider advertises the login pseudo-terminal
|
||||
* capability. It resolves the effective provider from the current environment
|
||||
* config, then reads the static capability from the provider plugin manifest. It
|
||||
* never checks the provider by name. A missing provider, a missing plugin, a
|
||||
* non-plugin provider, and a provider without the flag all return false. Both
|
||||
* login flows share this resolver, so both gates read the same current
|
||||
* capability.
|
||||
*/
|
||||
async function assertSetupTokenLoginProviderCapability(
|
||||
environmentId: string,
|
||||
): Promise<void> {
|
||||
async function resolveProviderSupportsLoginPty(environmentId: string): Promise<boolean> {
|
||||
const environment = await environmentsSvc.getById(environmentId);
|
||||
const config =
|
||||
environment?.config && typeof environment.config === "object"
|
||||
@@ -1491,13 +1525,39 @@ export function agentRoutes(
|
||||
driverKey: provider,
|
||||
})
|
||||
: null;
|
||||
if (!resolved?.driver.supportsLoginPty) {
|
||||
return resolved?.driver.supportsLoginPty === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails closed when the environment provider does not advertise the login
|
||||
* pseudo-terminal capability that the Claude setup-token login needs. It reads
|
||||
* the current provider capability. It fails closed with the fixed, typed error,
|
||||
* so no session row, lease, or pseudo-terminal starts.
|
||||
*/
|
||||
async function assertSetupTokenLoginProviderCapability(environmentId: string): Promise<void> {
|
||||
if (!(await resolveProviderSupportsLoginPty(environmentId))) {
|
||||
throw unprocessable(SETUP_TOKEN_PROVIDER_UNSUPPORTED, {
|
||||
code: SETUP_TOKEN_PROVIDER_UNSUPPORTED_CODE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails closed when the environment provider does not advertise the login
|
||||
* pseudo-terminal capability that the Codex device login needs. It reads the
|
||||
* current provider capability. The Codex route runs it before any session or
|
||||
* lease state, and the lease-acquisition path runs it again from current runtime
|
||||
* state before the provider lease. It fails closed with the fixed, typed error,
|
||||
* so no session row, lease, or pseudo-terminal starts.
|
||||
*/
|
||||
async function assertCodexLoginProviderCapability(environmentId: string): Promise<void> {
|
||||
if (!(await resolveProviderSupportsLoginPty(environmentId))) {
|
||||
throw unprocessable(CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED, {
|
||||
code: CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Read a login session for its owner. The durable row is the authority for the
|
||||
// company and the owner. This returns null when the row is absent, when it
|
||||
// belongs to another company or adapter, or when the requesting user is not the
|
||||
@@ -3076,12 +3136,19 @@ export function agentRoutes(
|
||||
req,
|
||||
res,
|
||||
deriveOwner: () => assertCanManageAdapterLogin(req, companyId),
|
||||
guardBeforeValidate: () => assertStreamedExecLoginAdapter(type),
|
||||
guardBeforeValidate: () => assertDeviceLoginAdapter(type),
|
||||
requestSchema: startAdapterAuthSessionRequestSchema,
|
||||
invalidRequestError: "The device login start request is invalid.",
|
||||
requestOverrides: { adapterType: type },
|
||||
assertSandbox: (data) =>
|
||||
assertSandboxLoginEnvironment(companyId, data.environmentId),
|
||||
assertSandbox: async (data) => {
|
||||
// The device login runs on a real pseudo-terminal, so it needs a
|
||||
// provider that advertises the login pseudo-terminal capability. Gate
|
||||
// the route on the current provider capability before any session or
|
||||
// lease state. The lease-acquisition path re-checks it from current
|
||||
// runtime state before the provider lease.
|
||||
await assertSandboxLoginEnvironment(companyId, data.environmentId);
|
||||
await assertCodexLoginProviderCapability(data.environmentId);
|
||||
},
|
||||
});
|
||||
if (!resolved) return;
|
||||
const { ownerUserId: startedByUserId, data } = resolved;
|
||||
@@ -3130,7 +3197,7 @@ export function agentRoutes(
|
||||
const type = req.params.type as string;
|
||||
const sessionId = req.params.sessionId as string;
|
||||
const ownerUserId = await assertCanManageAdapterLogin(req, companyId);
|
||||
assertStreamedExecLoginAdapter(type);
|
||||
assertDeviceLoginAdapter(type);
|
||||
|
||||
const owner = await readOwnerLoginSession(
|
||||
companyId,
|
||||
@@ -3155,7 +3222,7 @@ export function agentRoutes(
|
||||
const type = req.params.type as string;
|
||||
const sessionId = req.params.sessionId as string;
|
||||
const ownerUserId = await assertCanManageAdapterLogin(req, companyId);
|
||||
assertStreamedExecLoginAdapter(type);
|
||||
assertDeviceLoginAdapter(type);
|
||||
|
||||
// Scope the cancel to this company, adapter, and owner. A non-owner and a
|
||||
// cross-company caller both receive a 404 and cannot cancel a session.
|
||||
@@ -5747,15 +5814,12 @@ export function agentRoutes(
|
||||
guardAfterValidate: (data) => {
|
||||
// The setup-token route drives a login on a pseudo-terminal and records a
|
||||
// stored session identifier on success. It serves any adapter whose
|
||||
// registry login capability declares that transport and that claim. The
|
||||
// registry login capability records that completion claim. The
|
||||
// guard reads the capability, not the adapter name, so a new adapter with
|
||||
// the same capability passes with no code change. It rejects an adapter
|
||||
// with no matching capability with a fixed 400.
|
||||
const capability = getRegistryLoginCapability(data.adapterType);
|
||||
if (
|
||||
capability?.sandboxTransport !== "pseudo_terminal" ||
|
||||
capability.completionClaim !== "storedSessionId"
|
||||
) {
|
||||
if (capability?.completionClaim !== "storedSessionId") {
|
||||
res.status(400).json({
|
||||
error: "This adapter does not support a setup-token login.",
|
||||
});
|
||||
|
||||
@@ -10752,18 +10752,21 @@ export function issueRoutes(
|
||||
dependentIssueId: string;
|
||||
resolvedBlockerIssueId: string;
|
||||
blockerIssueIds: string[];
|
||||
blockedTransitionAt?: Date | string | null;
|
||||
source: string;
|
||||
mutation: string;
|
||||
}) => {
|
||||
const idempotencyKey = buildIssueBlockersResolvedWakeStateKey({
|
||||
dependentIssueId: input.dependentIssueId,
|
||||
blockerIssueIds: input.blockerIssueIds,
|
||||
blockedTransitionAt: input.blockedTransitionAt,
|
||||
});
|
||||
try {
|
||||
const existingWake = await findExistingIssueBlockersResolvedWakeForReadyState(db, {
|
||||
companyId: issue.companyId,
|
||||
dependentIssueId: input.dependentIssueId,
|
||||
blockerIssueIds: input.blockerIssueIds,
|
||||
blockedTransitionAt: input.blockedTransitionAt,
|
||||
});
|
||||
if (existingWake) return;
|
||||
} catch (err) {
|
||||
@@ -10948,6 +10951,7 @@ export function issueRoutes(
|
||||
dependentIssueId: dependent.id,
|
||||
resolvedBlockerIssueId: issue.id,
|
||||
blockerIssueIds: dependent.blockerIssueIds,
|
||||
blockedTransitionAt: dependent.blockedTransitionAt,
|
||||
source: "issue.blockers_resolved",
|
||||
mutation: "blocker_done",
|
||||
});
|
||||
@@ -10975,6 +10979,7 @@ export function issueRoutes(
|
||||
dependentIssueId: issue.id,
|
||||
resolvedBlockerIssueId,
|
||||
blockerIssueIds: readiness.blockerIssueIds,
|
||||
blockedTransitionAt: issue.blockedTransitionAt,
|
||||
source: "issue.blockers_restored",
|
||||
mutation: "blocked_dependency_restored",
|
||||
});
|
||||
@@ -13101,16 +13106,19 @@ export function issueRoutes(
|
||||
dependentIssueId: string;
|
||||
resolvedBlockerIssueId: string;
|
||||
blockerIssueIds: string[];
|
||||
blockedTransitionAt?: Date | string | null;
|
||||
}) => {
|
||||
const idempotencyKey = buildIssueBlockersResolvedWakeStateKey({
|
||||
dependentIssueId: input.dependentIssueId,
|
||||
blockerIssueIds: input.blockerIssueIds,
|
||||
blockedTransitionAt: input.blockedTransitionAt,
|
||||
});
|
||||
try {
|
||||
const existingWake = await findExistingIssueBlockersResolvedWakeForReadyState(db, {
|
||||
companyId: currentIssue.companyId,
|
||||
dependentIssueId: input.dependentIssueId,
|
||||
blockerIssueIds: input.blockerIssueIds,
|
||||
blockedTransitionAt: input.blockedTransitionAt,
|
||||
});
|
||||
if (existingWake) return;
|
||||
} catch (err) {
|
||||
@@ -13274,6 +13282,7 @@ export function issueRoutes(
|
||||
dependentIssueId: dependent.id,
|
||||
resolvedBlockerIssueId: currentIssue.id,
|
||||
blockerIssueIds: dependent.blockerIssueIds,
|
||||
blockedTransitionAt: dependent.blockedTransitionAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,11 +111,9 @@ const mockSecretService = vi.hoisted(() => ({
|
||||
const mockFindActiveServerAdapter = vi.hoisted(() => vi.fn());
|
||||
|
||||
// The Claude setup-token login capability the registry declares for the built-in
|
||||
// adapter. The guard requires the pseudo-terminal transport and the stored-session
|
||||
// completion claim.
|
||||
// adapter. The guard requires the stored-session completion claim.
|
||||
const CLAUDE_LOGIN_CAPABILITY = {
|
||||
panelMode: "submitted_browser_code",
|
||||
sandboxTransport: "pseudo_terminal",
|
||||
timeoutPolicy: "fixed",
|
||||
completionClaim: "storedSessionId",
|
||||
getCommand: () => "",
|
||||
|
||||
@@ -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