mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 15:09:57 +02:00
## Thinking Path > - Paperclip manages agents and projects for work. > - Their lifecycle changes already commit to a durable journal. > - Plugins need to read these records and track completed work. > - Existing resources need a one-time baseline before delivery is enabled. > - Worker crashes must leave unfinished records available for retry. > - Each plugin needs its own progress and company access checks. > - This PR adds a pull inbox through the existing plugin SDK and job system. ## Linked Issues or Issue Description **Problem or motivation** Plugins cannot consume the durable resource lifecycle journal. In-process notifications can disappear during a restart and cannot record successful completion. Resource plugins need retries, company boundaries, and ordered transitions. **Proposed solution** Add `ctx.events.listLifecycle(companyId, limit?, afterId?)` and `ctx.events.acknowledgeLifecycle(companyId, eventId)` under `events.subscribe`. Seed a one-time baseline from current resource state. Deliver creation first, then the remaining transitions in ID order. Store acknowledgments for each plugin. Use existing plugin jobs to poll configured companies. **Alternatives considered** A global sequence cursor can skip lower IDs that commit later. Fire-and-forget subscriptions cannot record completion. A new dispatcher is unnecessary because plugin jobs support polling. Consumers serialize polling and use provider idempotency keys. **Roadmap alignment** This extends the existing plugin system and builds on #15280 and #15306. Searches found no duplicate resource inbox work. Related #13306 exposes decision events on the in-process bus. This PR includes the initial journal baseline. Provider provisioning remains separate work. ## What Changed - Add company-scoped lifecycle reads and acknowledgments to the SDK and worker RPC host. - Gate both methods by capability, invocation or proactive company scope, plugin readiness, and company enablement. - Seed hired agents and all projects once. Preserve paused/terminated agent state and archived project state. Keep pending hires behind approval. - Preserve existing history and deliver backfilled creation before partial transition histories. - Deliver project archive events from #15371 through the SDK. Seed archive intents for archived projects and update intents for active projects with a partial archive history. - Store acknowledgments per plugin and reject acknowledgments that skip earlier resource events. - Page past failed resources while retaining their pending records. Reset the page cursor each sweep to include late commits. - Add acknowledgment storage, an index for resource ordering, tests, and authoring guidance. - Preserve native identity definitions and sequence progress in JavaScript backups. Repair journal ID generators lost by older backups before seeding the baseline, without changing existing IDs. ## Verification - `pnpm -r typecheck` passed after the final origin/master rebase. - `pnpm build` passed before the final metadata rebase. The final CI build also passed. - All 46 focused lifecycle, SDK harness/RPC, migration snapshot, and legacy restore checks passed again with migration 0309. - Checks cover retries, per-plugin progress, resource order, capability/company boundaries, baseline idempotency, approval gates, archive delivery, restored projects with partial histories, and atomic migration rollback. - Full CI passed on final commit `0e674b87fc`. One unrelated Cursor fixture hit a 10-second timeout; it passed locally in under one second, and the failed server shard passed on retry. - Greptile scored the final commit 5/5 with no unresolved review threads. - The branch is rebased onto origin/master and is conflict-free. - Earlier full local runs were stopped as scope changed. CI ran the complete repository suite. - `git diff --check` and a local secret/PII scan passed. ## Risks - Apply migration `0309_loving_the_hood.sql` before starting the new server. It creates acknowledgment storage and an index, then seeds the baseline in the same transaction. Agent/project writes wait for the migration to commit. Keep these writes quiesced through the migration and activation of the new capture-capable server; do not resume an older runtime that lacks capture after the baseline. - The baseline records current desired state, not historical transitions. It includes archived projects and terminated-agent cleanup intents. It runs once with the delivery migration; no later or runtime journal backfill is planned. - Delivery is at least once. Concurrent reads can repeat an event. Consumers must serialize polling, use stable company/event idempotency keys, and acknowledge successful operations only. - Reset `afterId` at the start of every polling sweep. It is a page cursor, not a persisted high-water mark. - Deleting a plugin removes its acknowledgment records. A new installation may replay existing journal events. - Records contain identity and action. Consumers must load current authorized data before acting. Cleanup and retention policy belong to the provider plugin. - Provider calls, VM/volume provisioning, and journal retention are outside this PR. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, repository inspection, code execution, and tool use. The exact deployment model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
2433 lines
90 KiB
TypeScript
2433 lines
90 KiB
TypeScript
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
|
import {
|
|
createHostClientHandlers,
|
|
JsonRpcCallError,
|
|
PLUGIN_RPC_ERROR_CODES,
|
|
type HostServices,
|
|
type HostToWorkerMethods,
|
|
} from "@paperclipai/plugin-sdk";
|
|
|
|
// Mock the shared logger, so a test reads the exact calls the manager makes
|
|
// when it logs a route event. The child logger returns the same mock object,
|
|
// so `log.warn`/`log.error` inside the manager are this mock's `warn`/`error`.
|
|
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 {
|
|
appendStderrExcerpt,
|
|
createDuplexRouteSlotController,
|
|
createPluginWorkerHandle,
|
|
formatWorkerFailureMessage,
|
|
resolveRpcCallTimeoutMs,
|
|
} from "../services/plugin-worker-manager.js";
|
|
|
|
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
|
|
const DELAYED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-delayed.cjs");
|
|
const INVOCATION_SCOPE_WORKER_ENTRYPOINT = path.join(
|
|
FIXTURES_DIR,
|
|
"plugin-worker-invocation-scope.cjs",
|
|
);
|
|
const TERMINATED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-terminated.cjs");
|
|
const EXECUTE_LOG_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-execute-log.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" },
|
|
};
|
|
|
|
describe("resolveRpcCallTimeoutMs", () => {
|
|
const MAX_RPC_TIMEOUT_MS = 15 * 60 * 1_000;
|
|
const MAX_NODE_TIMER_TIMEOUT_MS = 2_147_483_647;
|
|
const DEFAULT_RPC_TIMEOUT_MS = 30_000;
|
|
|
|
it("honors an explicit timeout above the 15-minute default ceiling", () => {
|
|
// The sandbox environment driver requests ~4h + 30s buffer for
|
|
// environmentExecute; this must not be clamped to 15 minutes.
|
|
const fourHoursPlusBuffer = 4 * 60 * 60 * 1_000 + 30_000;
|
|
expect(resolveRpcCallTimeoutMs(fourHoursPlusBuffer, DEFAULT_RPC_TIMEOUT_MS)).toBe(
|
|
fourHoursPlusBuffer,
|
|
);
|
|
});
|
|
|
|
it("honors an explicit timeout below the ceiling", () => {
|
|
expect(resolveRpcCallTimeoutMs(100, DEFAULT_RPC_TIMEOUT_MS)).toBe(100);
|
|
expect(resolveRpcCallTimeoutMs(MAX_RPC_TIMEOUT_MS - 1, DEFAULT_RPC_TIMEOUT_MS)).toBe(
|
|
MAX_RPC_TIMEOUT_MS - 1,
|
|
);
|
|
});
|
|
|
|
it("truncates fractional explicit timeouts", () => {
|
|
expect(resolveRpcCallTimeoutMs(1_000.9, DEFAULT_RPC_TIMEOUT_MS)).toBe(1_000);
|
|
});
|
|
|
|
it("normalizes explicit timeouts to Node's timer-safe range", () => {
|
|
expect(resolveRpcCallTimeoutMs(0.5, DEFAULT_RPC_TIMEOUT_MS)).toBe(1);
|
|
expect(resolveRpcCallTimeoutMs(MAX_NODE_TIMER_TIMEOUT_MS + 1, DEFAULT_RPC_TIMEOUT_MS)).toBe(
|
|
MAX_NODE_TIMER_TIMEOUT_MS,
|
|
);
|
|
});
|
|
|
|
it("uses the default timeout when no explicit timeout is provided", () => {
|
|
expect(resolveRpcCallTimeoutMs(undefined, DEFAULT_RPC_TIMEOUT_MS)).toBe(
|
|
DEFAULT_RPC_TIMEOUT_MS,
|
|
);
|
|
});
|
|
|
|
it("clamps only the default path to the 15-minute ceiling", () => {
|
|
expect(resolveRpcCallTimeoutMs(undefined, 24 * 60 * 60 * 1_000)).toBe(MAX_RPC_TIMEOUT_MS);
|
|
});
|
|
|
|
it("falls back to the clamped default for unusable explicit timeouts", () => {
|
|
for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
expect(resolveRpcCallTimeoutMs(bad, DEFAULT_RPC_TIMEOUT_MS)).toBe(DEFAULT_RPC_TIMEOUT_MS);
|
|
}
|
|
expect(resolveRpcCallTimeoutMs(Number.NaN, 24 * 60 * 60 * 1_000)).toBe(MAX_RPC_TIMEOUT_MS);
|
|
});
|
|
});
|
|
|
|
describe("plugin-worker-manager stderr failure context", () => {
|
|
it("appends worker stderr context to failure messages", () => {
|
|
expect(
|
|
formatWorkerFailureMessage(
|
|
"Worker process exited (code=1, signal=null)",
|
|
"TypeError: Unknown file extension \".ts\"",
|
|
),
|
|
).toBe(
|
|
"Worker process exited (code=1, signal=null)\n\nWorker stderr:\nTypeError: Unknown file extension \".ts\"",
|
|
);
|
|
});
|
|
|
|
it("does not duplicate stderr that is already present", () => {
|
|
const message = [
|
|
"Worker process exited (code=1, signal=null)",
|
|
"",
|
|
"Worker stderr:",
|
|
"TypeError: Unknown file extension \".ts\"",
|
|
].join("\n");
|
|
|
|
expect(
|
|
formatWorkerFailureMessage(message, "TypeError: Unknown file extension \".ts\""),
|
|
).toBe(message);
|
|
});
|
|
|
|
it("keeps only the latest stderr excerpt", () => {
|
|
let excerpt = "";
|
|
excerpt = appendStderrExcerpt(excerpt, "first line");
|
|
excerpt = appendStderrExcerpt(excerpt, "second line");
|
|
|
|
expect(excerpt).toContain("first line");
|
|
expect(excerpt).toContain("second line");
|
|
|
|
excerpt = appendStderrExcerpt(excerpt, "x".repeat(9_000));
|
|
|
|
expect(excerpt).not.toContain("first line");
|
|
expect(excerpt).not.toContain("second line");
|
|
expect(excerpt.length).toBeLessThanOrEqual(8_000);
|
|
});
|
|
|
|
it("times out environmentExecute calls using the handle default when no override is provided", async () => {
|
|
const handle = createPluginWorkerHandle("test.plugin", {
|
|
entrypointPath: DELAYED_WORKER_ENTRYPOINT,
|
|
manifest: TEST_MANIFEST,
|
|
config: {},
|
|
instanceInfo: {
|
|
instanceId: "instance-1",
|
|
hostVersion: "1.0.0",
|
|
},
|
|
apiVersion: 1,
|
|
hostHandlers: {},
|
|
rpcTimeoutMs: 10,
|
|
});
|
|
|
|
try {
|
|
await handle.start();
|
|
|
|
await expect(handle.call("environmentExecute", {
|
|
driverKey: "e2b",
|
|
companyId: "company-1",
|
|
environmentId: "environment-1",
|
|
config: {},
|
|
lease: { providerLeaseId: "lease-1" },
|
|
command: "echo",
|
|
delayMs: 50,
|
|
} as HostToWorkerMethods["environmentExecute"][0])).rejects.toMatchObject({
|
|
message: expect.stringContaining("timed out after 10ms"),
|
|
});
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("honors per-call timeout overrides for environmentExecute", async () => {
|
|
const handle = createPluginWorkerHandle("test.plugin", {
|
|
entrypointPath: DELAYED_WORKER_ENTRYPOINT,
|
|
manifest: TEST_MANIFEST,
|
|
config: {},
|
|
instanceInfo: {
|
|
instanceId: "instance-1",
|
|
hostVersion: "1.0.0",
|
|
},
|
|
apiVersion: 1,
|
|
hostHandlers: {},
|
|
rpcTimeoutMs: 10,
|
|
});
|
|
|
|
try {
|
|
await handle.start();
|
|
|
|
await expect(handle.call("environmentExecute", {
|
|
driverKey: "e2b",
|
|
companyId: "company-1",
|
|
environmentId: "environment-1",
|
|
config: {},
|
|
lease: { providerLeaseId: "lease-1" },
|
|
command: "echo",
|
|
delayMs: 50,
|
|
} as HostToWorkerMethods["environmentExecute"][0], 100)).resolves.toMatchObject({
|
|
exitCode: 0,
|
|
stdout: "ok\n",
|
|
});
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("does not emit an unhandled rejection when a plugin responds with terminated before callers attach handlers", async () => {
|
|
const unhandledRejection = vi.fn();
|
|
process.on("unhandledRejection", unhandledRejection);
|
|
|
|
const handle = createPluginWorkerHandle("test.plugin", {
|
|
entrypointPath: TERMINATED_WORKER_ENTRYPOINT,
|
|
manifest: TEST_MANIFEST,
|
|
config: {},
|
|
instanceInfo: {
|
|
instanceId: "instance-1",
|
|
hostVersion: "1.0.0",
|
|
},
|
|
apiVersion: 1,
|
|
hostHandlers: {},
|
|
});
|
|
|
|
try {
|
|
await handle.start();
|
|
|
|
const pendingCall = handle.call(
|
|
"environmentExecute" as keyof HostToWorkerMethods,
|
|
{
|
|
driverKey: "e2b",
|
|
companyId: "company-1",
|
|
environmentId: "environment-1",
|
|
config: {},
|
|
lease: { providerLeaseId: "lease-1" },
|
|
command: "echo",
|
|
} as HostToWorkerMethods[keyof HostToWorkerMethods][0],
|
|
);
|
|
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
await expect(pendingCall).rejects.toBeInstanceOf(JsonRpcCallError);
|
|
await expect(pendingCall).rejects.toMatchObject({
|
|
message: expect.stringContaining("terminated"),
|
|
});
|
|
expect(unhandledRejection).not.toHaveBeenCalled();
|
|
} finally {
|
|
process.off("unhandledRejection", unhandledRejection);
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("passes performAction invocation scope to nested worker host calls", async () => {
|
|
const companiesGet = vi.fn(async (
|
|
params: { companyId: string },
|
|
context?: { invocationScope?: { companyId?: string | null } | null },
|
|
) => ({
|
|
id: params.companyId,
|
|
scopedCompanyId: context?.invocationScope?.companyId ?? null,
|
|
}));
|
|
const handle = createPluginWorkerHandle("test.plugin", {
|
|
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
|
|
manifest: TEST_MANIFEST,
|
|
config: {},
|
|
instanceInfo: {
|
|
instanceId: "instance-1",
|
|
hostVersion: "1.0.0",
|
|
},
|
|
apiVersion: 1,
|
|
hostHandlers: {
|
|
"companies.get": companiesGet as never,
|
|
},
|
|
});
|
|
|
|
try {
|
|
await handle.start();
|
|
|
|
await expect(handle.call("performAction", {
|
|
key: "probe",
|
|
params: {
|
|
mode: "echo",
|
|
requestedCompanyId: "company-a",
|
|
},
|
|
actorContext: {
|
|
type: "agent",
|
|
userId: null,
|
|
agentId: "agent-1",
|
|
runId: "run-1",
|
|
companyId: "company-a",
|
|
},
|
|
renderEnvironment: null,
|
|
})).resolves.toEqual({
|
|
id: "company-a",
|
|
scopedCompanyId: "company-a",
|
|
});
|
|
expect(companiesGet).toHaveBeenCalledWith(
|
|
{ companyId: "company-a" },
|
|
{ invocationScope: { companyId: "company-a" } },
|
|
);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("passes echoed invocation scope to worker-to-host handlers", async () => {
|
|
const companiesGet = vi.fn(async () => ({ id: "company-1" }));
|
|
const handle = createPluginWorkerHandle("test.plugin", {
|
|
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
|
|
manifest: TEST_MANIFEST,
|
|
config: {},
|
|
instanceInfo: {
|
|
instanceId: "instance-1",
|
|
hostVersion: "1.0.0",
|
|
},
|
|
apiVersion: 1,
|
|
hostHandlers: {
|
|
"companies.get": companiesGet,
|
|
},
|
|
});
|
|
|
|
try {
|
|
await handle.start();
|
|
|
|
await expect(handle.call("getData", {
|
|
key: "probe",
|
|
companyId: "company-1",
|
|
params: {
|
|
mode: "echo",
|
|
requestedCompanyId: "company-1",
|
|
},
|
|
} as HostToWorkerMethods["getData"][0])).resolves.toEqual({ id: "company-1" });
|
|
|
|
expect(companiesGet).toHaveBeenCalledWith(
|
|
{ companyId: "company-1" },
|
|
{ invocationScope: { companyId: "company-1" } },
|
|
);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("rejects performAction nested host calls that omit the invocation id", async () => {
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "test.plugin",
|
|
capabilities: ["companies.read"],
|
|
services: {
|
|
companies: {
|
|
list: vi.fn(async () => []),
|
|
get: vi.fn(async (params: { companyId: string }) => ({ id: params.companyId })),
|
|
},
|
|
} as unknown as HostServices,
|
|
});
|
|
const handle = createPluginWorkerHandle("test.plugin", {
|
|
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
|
|
manifest: TEST_MANIFEST,
|
|
config: {},
|
|
instanceInfo: {
|
|
instanceId: "instance-1",
|
|
hostVersion: "1.0.0",
|
|
},
|
|
apiVersion: 1,
|
|
hostHandlers: handlers,
|
|
});
|
|
|
|
try {
|
|
await handle.start();
|
|
|
|
await expect(handle.call("performAction", {
|
|
key: "probe",
|
|
params: {
|
|
requestedCompanyId: "company-b",
|
|
},
|
|
actorContext: {
|
|
type: "agent",
|
|
userId: null,
|
|
agentId: "agent-1",
|
|
runId: "run-1",
|
|
companyId: "company-a",
|
|
},
|
|
renderEnvironment: null,
|
|
})).rejects.toMatchObject({
|
|
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
|
message: expect.stringContaining("unknown invocation scope"),
|
|
});
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("rejects nested worker host calls that forge an unknown invocation id", async () => {
|
|
const companiesGet = vi.fn(async (params: { companyId: string }) => ({ id: params.companyId }));
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "test.plugin",
|
|
capabilities: ["companies.read"],
|
|
services: {
|
|
companies: {
|
|
get: companiesGet,
|
|
},
|
|
} as unknown as HostServices,
|
|
});
|
|
const handle = createPluginWorkerHandle("test.plugin", {
|
|
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
|
|
manifest: TEST_MANIFEST,
|
|
config: {},
|
|
instanceInfo: {
|
|
instanceId: "instance-1",
|
|
hostVersion: "1.0.0",
|
|
},
|
|
apiVersion: 1,
|
|
hostHandlers: handlers,
|
|
});
|
|
|
|
try {
|
|
await handle.start();
|
|
|
|
await expect(handle.call("performAction", {
|
|
key: "probe",
|
|
params: {
|
|
mode: "unknown",
|
|
requestedCompanyId: "company-a",
|
|
},
|
|
actorContext: {
|
|
type: "agent",
|
|
userId: null,
|
|
agentId: "agent-1",
|
|
runId: "run-1",
|
|
companyId: "company-a",
|
|
},
|
|
renderEnvironment: null,
|
|
})).rejects.toMatchObject({
|
|
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
|
message: expect.stringContaining("unknown invocation scope"),
|
|
});
|
|
expect(companiesGet).not.toHaveBeenCalled();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("rejects missing or unknown invocation ids while a company invocation is active", async () => {
|
|
const companiesGet = vi.fn(async () => ({ id: "company-2" }));
|
|
const hostHandlers = createHostClientHandlers({
|
|
pluginId: "test.plugin",
|
|
capabilities: ["companies.read"],
|
|
services: {
|
|
companies: {
|
|
get: companiesGet,
|
|
},
|
|
} as unknown as HostServices,
|
|
});
|
|
const handle = createPluginWorkerHandle("test.plugin", {
|
|
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
|
|
manifest: TEST_MANIFEST,
|
|
config: {},
|
|
instanceInfo: {
|
|
instanceId: "instance-1",
|
|
hostVersion: "1.0.0",
|
|
},
|
|
apiVersion: 1,
|
|
hostHandlers,
|
|
});
|
|
|
|
try {
|
|
await handle.start();
|
|
|
|
for (const mode of ["omit", "unknown"]) {
|
|
await expect(handle.call("getData", {
|
|
key: "probe",
|
|
companyId: "company-1",
|
|
params: {
|
|
mode,
|
|
requestedCompanyId: "company-2",
|
|
},
|
|
} as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({
|
|
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
|
});
|
|
}
|
|
|
|
expect(companiesGet).not.toHaveBeenCalled();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
});
|
|
|
|
|
|
describe("plugin host company context guards", () => {
|
|
it("rejects config and secret calls without host-issued company context before host services run", async () => {
|
|
const configGet = vi.fn(async () => ({ apiKey: "unreachable" }));
|
|
const secretsResolve = vi.fn(async () => "unreachable");
|
|
const handlers = createHostClientHandlers({
|
|
pluginId: "test.plugin",
|
|
capabilities: ["secrets.read-ref"],
|
|
services: {
|
|
config: { get: configGet },
|
|
secrets: { resolve: secretsResolve },
|
|
} as unknown as HostServices,
|
|
});
|
|
|
|
await expect(handlers["config.get"]({})).rejects.toMatchObject({
|
|
name: "InvocationScopeDeniedError",
|
|
message: expect.stringContaining("company context is required"),
|
|
});
|
|
await expect(handlers["config.get"]({ companyId: "company-1" })).rejects.toMatchObject({
|
|
name: "InvocationScopeDeniedError",
|
|
message: expect.stringContaining("company context is required"),
|
|
});
|
|
await expect(
|
|
handlers["secrets.resolve"]({
|
|
secretRef: { type: "secret_ref", secretId: "11111111-1111-4111-8111-111111111111" },
|
|
}),
|
|
).rejects.toMatchObject({
|
|
name: "InvocationScopeDeniedError",
|
|
message: expect.stringContaining("company context is required"),
|
|
});
|
|
await expect(
|
|
handlers["secrets.resolve"]({
|
|
companyId: "company-1",
|
|
secretRef: { type: "secret_ref", secretId: "11111111-1111-4111-8111-111111111111" },
|
|
}),
|
|
).rejects.toMatchObject({
|
|
name: "InvocationScopeDeniedError",
|
|
message: expect.stringContaining("company context is required"),
|
|
});
|
|
|
|
expect(configGet).not.toHaveBeenCalled();
|
|
expect(secretsResolve).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects cross-company config and secret reads in scoped worker invocations before host services run", async () => {
|
|
const configGet = vi.fn(async () => ({ apiKeyRef: "unreachable" }));
|
|
const secretsResolve = vi.fn(async () => "unreachable");
|
|
const hostHandlers = createHostClientHandlers({
|
|
pluginId: "test.plugin",
|
|
capabilities: ["secrets.read-ref"],
|
|
services: {
|
|
config: { get: configGet },
|
|
secrets: { resolve: secretsResolve },
|
|
} as unknown as HostServices,
|
|
});
|
|
const handle = createPluginWorkerHandle("test.plugin", {
|
|
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
|
|
manifest: TEST_MANIFEST,
|
|
config: {},
|
|
instanceInfo: {
|
|
instanceId: "instance-1",
|
|
hostVersion: "1.0.0",
|
|
},
|
|
apiVersion: 1,
|
|
hostHandlers,
|
|
});
|
|
|
|
try {
|
|
await handle.start();
|
|
|
|
for (const hostMethod of ["config.get", "secrets.resolve"] as const) {
|
|
await expect(handle.call("performAction", {
|
|
key: "probe",
|
|
params: {
|
|
mode: "echo",
|
|
hostMethod,
|
|
requestedCompanyId: "company-b",
|
|
},
|
|
actorContext: {
|
|
type: "agent",
|
|
userId: null,
|
|
agentId: "agent-1",
|
|
runId: "run-1",
|
|
companyId: "company-a",
|
|
},
|
|
renderEnvironment: null,
|
|
})).rejects.toMatchObject({
|
|
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
|
message: expect.stringContaining('requested company "company-b"'),
|
|
});
|
|
}
|
|
|
|
expect(configGet).not.toHaveBeenCalled();
|
|
expect(secretsResolve).not.toHaveBeenCalled();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
});
|
|
|
|
|
|
describe("plugin proactive company scope (LOOA-629)", () => {
|
|
// A proactive plugin (e.g. the chat gateway) makes company-scoped worker→host
|
|
// calls from its own timers/loops — outside any host-issued invocation, so
|
|
// those calls carry no paperclipInvocationId (the fixture's "omit" mode). The
|
|
// host authorizes a bounded set of companies for such proactive work; calls
|
|
// referencing an authorized company resolve to that scope, all others stay
|
|
// denied. Each case drives a real worker so the nested call flows through the
|
|
// worker manager's context resolution, not just the SDK gate in isolation.
|
|
function makeHandle(overrides?: {
|
|
companiesGet?: ReturnType<typeof vi.fn>;
|
|
stateGet?: ReturnType<typeof vi.fn>;
|
|
}) {
|
|
const companiesGet = overrides?.companiesGet ?? vi.fn(async () => ({ id: "company-1", name: "Co" }));
|
|
const stateGet = overrides?.stateGet ?? vi.fn(async () => ({ value: "ok" }));
|
|
const listLifecycle = vi.fn(async () => [{ id: "1", companyId: "company-1" }]);
|
|
const hostHandlers = createHostClientHandlers({
|
|
pluginId: "test.plugin",
|
|
capabilities: ["companies.read", "plugin.state.read", "events.subscribe"],
|
|
services: {
|
|
companies: { get: companiesGet },
|
|
state: { get: stateGet },
|
|
events: { listLifecycle },
|
|
} as unknown as HostServices,
|
|
});
|
|
const handle = createPluginWorkerHandle("test.plugin", {
|
|
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
|
|
manifest: TEST_MANIFEST,
|
|
config: {},
|
|
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
|
|
apiVersion: 1,
|
|
hostHandlers,
|
|
});
|
|
return { handle, companiesGet, stateGet, listLifecycle };
|
|
}
|
|
|
|
it("denies a proactive company-scoped call when no company is authorized", async () => {
|
|
const { handle, companiesGet } = makeHandle();
|
|
try {
|
|
await handle.start();
|
|
await expect(handle.call("getData", {
|
|
params: { mode: "omit", hostMethod: "companies.get", requestedCompanyId: "company-1" },
|
|
} as unknown as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({
|
|
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
|
message: expect.stringContaining("company context is required"),
|
|
});
|
|
expect(companiesGet).not.toHaveBeenCalled();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("admits lifecycle polling only for configured proactive companies", async () => {
|
|
const { handle, listLifecycle } = makeHandle();
|
|
const poll = (companyId: string) => handle.call("getData", {
|
|
params: { mode: "omit", hostMethod: "events.listLifecycle", requestedCompanyId: companyId },
|
|
} as unknown as HostToWorkerMethods["getData"][0]);
|
|
try {
|
|
await handle.start();
|
|
await expect(poll("company-1")).rejects.toMatchObject({ code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED });
|
|
handle.setProactiveCompanyScopes(["company-1"]);
|
|
expect(await poll("company-1")).toEqual([{ id: "1", companyId: "company-1" }]);
|
|
await expect(poll("company-2")).rejects.toMatchObject({ code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED });
|
|
handle.setProactiveCompanyScopes([]);
|
|
await expect(poll("company-1")).rejects.toMatchObject({ code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED });
|
|
expect(listLifecycle).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("admits a proactive company-scoped call for an authorized company", async () => {
|
|
const { handle, companiesGet } = makeHandle();
|
|
try {
|
|
await handle.start();
|
|
handle.setProactiveCompanyScopes(["company-1"]);
|
|
const result = await handle.call("getData", {
|
|
params: { mode: "omit", hostMethod: "companies.get", requestedCompanyId: "company-1" },
|
|
} as unknown as HostToWorkerMethods["getData"][0]);
|
|
expect(result).toMatchObject({ id: "company-1" });
|
|
expect(companiesGet).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("admits a proactive state.get (scopeKind company) for an authorized company", async () => {
|
|
const { handle, stateGet } = makeHandle();
|
|
try {
|
|
await handle.start();
|
|
handle.setProactiveCompanyScopes(["company-1"]);
|
|
const result = await handle.call("getData", {
|
|
params: { mode: "omit", hostMethod: "state.get", requestedCompanyId: "company-1" },
|
|
} as unknown as HostToWorkerMethods["getData"][0]);
|
|
expect(result).toMatchObject({ value: "ok" });
|
|
expect(stateGet).toHaveBeenCalledTimes(1);
|
|
expect(stateGet.mock.calls[0]?.[0]).toMatchObject({ scopeKind: "company", scopeId: "company-1" });
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("still denies proactive calls for a company outside the authorized set", async () => {
|
|
const { handle, companiesGet } = makeHandle();
|
|
try {
|
|
await handle.start();
|
|
handle.setProactiveCompanyScopes(["company-1"]);
|
|
await expect(handle.call("getData", {
|
|
params: { mode: "omit", hostMethod: "companies.get", requestedCompanyId: "company-2" },
|
|
} as unknown as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({
|
|
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
|
message: expect.stringContaining("company context is required"),
|
|
});
|
|
expect(companiesGet).not.toHaveBeenCalled();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("revokes proactive access when the authorized set is cleared", async () => {
|
|
const { handle, companiesGet } = makeHandle();
|
|
try {
|
|
await handle.start();
|
|
handle.setProactiveCompanyScopes(["company-1"]);
|
|
await handle.call("getData", {
|
|
params: { mode: "omit", hostMethod: "companies.get", requestedCompanyId: "company-1" },
|
|
} as unknown as HostToWorkerMethods["getData"][0]);
|
|
expect(companiesGet).toHaveBeenCalledTimes(1);
|
|
|
|
handle.setProactiveCompanyScopes([]);
|
|
await expect(handle.call("getData", {
|
|
params: { mode: "omit", hostMethod: "companies.get", requestedCompanyId: "company-1" },
|
|
} as unknown as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({
|
|
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
|
});
|
|
expect(companiesGet).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("plugin proactive events.subscribe: options-seeded scope + filter parity (LOOA-695)", () => {
|
|
// The chat gateway subscribes to issue.*/approval.* from setup() via
|
|
// ctx.events.on(name, { companyId }, fn), which the SDK turns into a proactive
|
|
// (no-invocation) events.subscribe whose company lives in params.filter.companyId.
|
|
// Two things had to hold for outbound push to work and neither did before this
|
|
// fix:
|
|
// (1) the authorized company set must be present BEFORE the worker's setup()
|
|
// calls land — the loader used to set it only after startWorker resolved,
|
|
// so it was seeded via WorkerStartOptions at handle creation instead;
|
|
// (2) the host's proactive-scope resolver (referencedCompanyId) must derive
|
|
// events.subscribe's company from filter.companyId, mirroring the SDK
|
|
// gate (requestedCompanyScope).
|
|
// Each case drives a real worker so the subscribe flows through the manager's
|
|
// context resolution exactly as it does in production.
|
|
function makeEventsHandle(seededCompanies: readonly string[]) {
|
|
const eventsSubscribe = vi.fn(async () => undefined);
|
|
const hostHandlers = createHostClientHandlers({
|
|
pluginId: "test.plugin",
|
|
capabilities: ["events.subscribe"],
|
|
services: {
|
|
events: { subscribe: eventsSubscribe },
|
|
} as unknown as HostServices,
|
|
});
|
|
const handle = createPluginWorkerHandle("test.plugin", {
|
|
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
|
|
manifest: TEST_MANIFEST,
|
|
config: {},
|
|
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
|
|
apiVersion: 1,
|
|
hostHandlers,
|
|
// Seeded at handle creation — the loader now threads the plugin's
|
|
// configured companies here BEFORE startWorker, never via a post-start
|
|
// setProactiveCompanyScopes call.
|
|
proactiveCompanyScopes: seededCompanies,
|
|
});
|
|
return { handle, eventsSubscribe };
|
|
}
|
|
|
|
it("admits a setup()-time events.subscribe for a company seeded via WorkerStartOptions", async () => {
|
|
const { handle, eventsSubscribe } = makeEventsHandle(["company-1"]);
|
|
try {
|
|
await handle.start();
|
|
// No post-start setProactiveCompanyScopes call: the seed from options is
|
|
// the only authorization, exactly as it is when the worker subscribes
|
|
// during setup() before startWorker resolves.
|
|
await handle.call("getData", {
|
|
params: { mode: "omit", hostMethod: "events.subscribe", requestedCompanyId: "company-1" },
|
|
} as unknown as HostToWorkerMethods["getData"][0]);
|
|
expect(eventsSubscribe).toHaveBeenCalledTimes(1);
|
|
expect(eventsSubscribe.mock.calls[0]?.[0]).toMatchObject({
|
|
filter: { companyId: "company-1" },
|
|
});
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("still denies a proactive events.subscribe for a company outside the seeded set", async () => {
|
|
const { handle, eventsSubscribe } = makeEventsHandle(["company-1"]);
|
|
try {
|
|
await handle.start();
|
|
await expect(handle.call("getData", {
|
|
params: { mode: "omit", hostMethod: "events.subscribe", requestedCompanyId: "company-2" },
|
|
} as unknown as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({
|
|
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
|
message: expect.stringContaining("company context is required"),
|
|
});
|
|
expect(eventsSubscribe).not.toHaveBeenCalled();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("denies a proactive events.subscribe when no company is seeded", async () => {
|
|
const { handle, eventsSubscribe } = makeEventsHandle([]);
|
|
try {
|
|
await handle.start();
|
|
await expect(handle.call("getData", {
|
|
params: { mode: "omit", hostMethod: "events.subscribe", requestedCompanyId: "company-1" },
|
|
} as unknown as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({
|
|
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
|
});
|
|
expect(eventsSubscribe).not.toHaveBeenCalled();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// execute.log worker→host notification route
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeExecuteLogHandle(extra?: Record<string, unknown>) {
|
|
return createPluginWorkerHandle("test.plugin", {
|
|
entrypointPath: EXECUTE_LOG_WORKER_ENTRYPOINT,
|
|
manifest: TEST_MANIFEST,
|
|
config: {},
|
|
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
|
|
apiVersion: 1,
|
|
hostHandlers: {},
|
|
...extra,
|
|
});
|
|
}
|
|
|
|
function executeParams(
|
|
overrides: Record<string, unknown>,
|
|
): HostToWorkerMethods["environmentExecute"][0] {
|
|
return {
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
config: {},
|
|
lease: { providerLeaseId: "lease-1" },
|
|
command: "echo",
|
|
...overrides,
|
|
} as unknown as HostToWorkerMethods["environmentExecute"][0];
|
|
}
|
|
|
|
describe("plugin worker manager execute.log route", () => {
|
|
it("delivers ordered execute.log chunks to the execute log sink", async () => {
|
|
const handle = makeExecuteLogHandle();
|
|
const sink = vi.fn();
|
|
try {
|
|
await handle.start();
|
|
const result = await handle.call(
|
|
"environmentExecute",
|
|
executeParams({
|
|
logs: [
|
|
{ stream: "stdout", chunk: "one" },
|
|
{ stream: "stderr", chunk: "two" },
|
|
{ stream: "stdout", chunk: "three" },
|
|
],
|
|
finalStdout: "onethree",
|
|
finalStderr: "two",
|
|
}),
|
|
undefined,
|
|
sink,
|
|
);
|
|
expect(result).toMatchObject({ exitCode: 0 });
|
|
expect(sink.mock.calls).toEqual([
|
|
["stdout", "one"],
|
|
["stderr", "two"],
|
|
["stdout", "three"],
|
|
]);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("drops an execute.log chunk with a forged or missing invocation id", async () => {
|
|
const handle = makeExecuteLogHandle();
|
|
const sink = vi.fn();
|
|
try {
|
|
await handle.start();
|
|
await handle.call(
|
|
"environmentExecute",
|
|
executeParams({
|
|
logs: [
|
|
{ stream: "stdout", chunk: "valid", tag: "echo" },
|
|
{ stream: "stdout", chunk: "forged", tag: "unknown" },
|
|
{ stream: "stdout", chunk: "orphan", tag: "none" },
|
|
],
|
|
}),
|
|
undefined,
|
|
sink,
|
|
);
|
|
// Only the chunk that carries this call's own host-issued id is delivered.
|
|
expect(sink.mock.calls).toEqual([["stdout", "valid"]]);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("drops an execute.log chunk with an invalid stream name or an empty chunk", async () => {
|
|
const handle = makeExecuteLogHandle();
|
|
const sink = vi.fn();
|
|
try {
|
|
await handle.start();
|
|
await handle.call(
|
|
"environmentExecute",
|
|
executeParams({
|
|
logs: [
|
|
{ stream: "stdout", chunk: "keep" },
|
|
{ stream: "bogus", chunk: "dropped-stream" },
|
|
{ stream: "stdout", chunk: "" },
|
|
],
|
|
}),
|
|
undefined,
|
|
sink,
|
|
);
|
|
expect(sink.mock.calls).toEqual([["stdout", "keep"]]);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("routes two concurrent same-company execute calls to their own sink only", async () => {
|
|
const handle = makeExecuteLogHandle();
|
|
const sinkA = vi.fn();
|
|
const sinkB = vi.fn();
|
|
try {
|
|
await handle.start();
|
|
const callA = handle.call(
|
|
"environmentExecute",
|
|
executeParams({
|
|
companyId: "company-1",
|
|
logs: [{ stream: "stdout", chunk: "a1" }],
|
|
delayMs: 40,
|
|
}),
|
|
undefined,
|
|
sinkA,
|
|
);
|
|
const callB = handle.call(
|
|
"environmentExecute",
|
|
executeParams({
|
|
companyId: "company-1",
|
|
logs: [{ stream: "stdout", chunk: "b1" }],
|
|
delayMs: 40,
|
|
}),
|
|
undefined,
|
|
sinkB,
|
|
);
|
|
await Promise.all([callA, callB]);
|
|
// Both calls belong to one company, so the shared pipe stays
|
|
// single-company and each chunk reaches only its own call's sink.
|
|
expect(sinkA.mock.calls).toEqual([["stdout", "a1"]]);
|
|
expect(sinkB.mock.calls).toEqual([["stdout", "b1"]]);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("fails closed and never delivers execute.log across companies, even with a forged peer id", async () => {
|
|
// A single worker process serves every company, so it knows both companies'
|
|
// active invocation ids. While company B's execute stays active, company A's
|
|
// execute forges B's known, valid id and aims a chunk at B's route. The host
|
|
// must not deliver it to B. Before the exact-company-scope validation, the
|
|
// route lookup by the worker-supplied id delivered the forged chunk to B.
|
|
const handle = makeExecuteLogHandle();
|
|
const sinkA = vi.fn();
|
|
const sinkB = vi.fn();
|
|
try {
|
|
await handle.start();
|
|
// Company B opens first and stays active (delayed finish), so its route is
|
|
// registered and known to the worker when company A runs.
|
|
const callB = handle.call(
|
|
"environmentExecute",
|
|
executeParams({ companyId: "company-b", logs: [], delayMs: 200 }),
|
|
undefined,
|
|
sinkB,
|
|
);
|
|
// Let the worker process B's execute, so it records B's id as the peer id.
|
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
const callA = handle.call(
|
|
"environmentExecute",
|
|
executeParams({
|
|
companyId: "company-a",
|
|
logs: [{ stream: "stdout", chunk: "forged-into-b", tag: "forge-previous" }],
|
|
}),
|
|
undefined,
|
|
sinkA,
|
|
);
|
|
await Promise.all([callA, callB]);
|
|
expect(sinkB).not.toHaveBeenCalled();
|
|
expect(sinkA).not.toHaveBeenCalled();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("drops execute.log chunks once one execute call exceeds its output budget", async () => {
|
|
// Bound the total streamed output for one execute call. Past the ceiling the
|
|
// host drops further chunks, so one runaway or hostile execution cannot flood
|
|
// the host without limit.
|
|
const handle = makeExecuteLogHandle({
|
|
executeLogLimits: { maxTotalCharsPerExecute: 10 },
|
|
});
|
|
const sink = vi.fn();
|
|
try {
|
|
await handle.start();
|
|
await handle.call(
|
|
"environmentExecute",
|
|
executeParams({
|
|
logs: [
|
|
{ stream: "stdout", chunk: "aaaaa" }, // total 5 → delivered
|
|
{ stream: "stdout", chunk: "bbbbb" }, // total 10 → delivered
|
|
{ stream: "stdout", chunk: "c" }, // total 11 > 10 → dropped
|
|
],
|
|
}),
|
|
undefined,
|
|
sink,
|
|
);
|
|
expect(sink.mock.calls).toEqual([
|
|
["stdout", "aaaaa"],
|
|
["stdout", "bbbbb"],
|
|
]);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("drops an over-length worker line before parsing it and keeps serving the call", async () => {
|
|
// Enforce the framing bound before the JSON parse. The oversized note is a
|
|
// valid execute.log line for this call's own id, so without the pre-parse
|
|
// guard the host would parse and deliver it. The normal note stays under the
|
|
// limit and reaches the sink, and the call still completes.
|
|
const handle = makeExecuteLogHandle({
|
|
executeLogLimits: { maxIncomingMessageChars: 400 },
|
|
});
|
|
const sink = vi.fn();
|
|
try {
|
|
await handle.start();
|
|
const result = await handle.call(
|
|
"environmentExecute",
|
|
executeParams({
|
|
oversizedLogChunkChars: 1_000,
|
|
logs: [{ stream: "stdout", chunk: "kept" }],
|
|
finalStdout: "kept",
|
|
}),
|
|
undefined,
|
|
sink,
|
|
);
|
|
expect(result).toMatchObject({ exitCode: 0 });
|
|
expect(sink.mock.calls).toEqual([["stdout", "kept"]]);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("completes an execute call that sends no execute.log notification", async () => {
|
|
const handle = makeExecuteLogHandle();
|
|
const sink = vi.fn();
|
|
try {
|
|
await handle.start();
|
|
const result = await handle.call(
|
|
"environmentExecute",
|
|
executeParams({ logs: [], finalStdout: "done" }),
|
|
undefined,
|
|
sink,
|
|
);
|
|
expect(result).toMatchObject({ exitCode: 0, stdout: "done" });
|
|
expect(sink).not.toHaveBeenCalled();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("does not throw when execute.log arrives but no sink is registered", async () => {
|
|
const handle = makeExecuteLogHandle();
|
|
try {
|
|
await handle.start();
|
|
const result = await handle.call(
|
|
"environmentExecute",
|
|
executeParams({ logs: [{ stream: "stdout", chunk: "no-sink" }] }),
|
|
);
|
|
expect(result).toMatchObject({ exitCode: 0 });
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Host-owned setup-token login pseudo-terminal route gate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const LOGIN_PTY_WORKER_ENTRYPOINT = path.join(
|
|
FIXTURES_DIR,
|
|
"plugin-worker-login-pty.cjs",
|
|
);
|
|
|
|
function makeLoginPtyHandle(extra?: Record<string, unknown>) {
|
|
return createPluginWorkerHandle("test.plugin", {
|
|
entrypointPath: LOGIN_PTY_WORKER_ENTRYPOINT,
|
|
manifest: TEST_MANIFEST,
|
|
config: {},
|
|
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
|
|
apiVersion: 1,
|
|
hostHandlers: {},
|
|
...extra,
|
|
});
|
|
}
|
|
|
|
// 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 carries the closed command
|
|
// key and the validated session home; it carries no command string.
|
|
providerLeaseId: JSON.stringify(directive),
|
|
loginCommandKey: "claude" as const,
|
|
sessionHome: PTY_SESSION_HOME,
|
|
};
|
|
}
|
|
|
|
describe("plugin worker manager setup-token pty route gate", () => {
|
|
it("rejects a command key that is not in the closed set before the worker call", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
// 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.openLoginPtySession({
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
providerLeaseId: JSON.stringify({ mode: "normal" }),
|
|
loginCommandKey: "gemini" as unknown as "claude",
|
|
sessionHome: PTY_SESSION_HOME,
|
|
}),
|
|
).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();
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("permits two concurrent credential pseudo-terminals on one worker", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
// Neither open waits on the other. Both return a live route on the same
|
|
// worker, so a second owner is never blocked by a first owner's session.
|
|
const first = await handle.openLoginPtySession(
|
|
ptyOpenInput({ mode: "normal", workerSessionId: "ws-A" }),
|
|
);
|
|
const second = await handle.openLoginPtySession(
|
|
ptyOpenInput({ mode: "normal", workerSessionId: "ws-B" }),
|
|
);
|
|
expect(first).toBeDefined();
|
|
expect(second).toBeDefined();
|
|
await first.close();
|
|
await second.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("delivers output only for the exact bound worker session id and drops a mismatch", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
workerSessionId: "ws-A",
|
|
outputs: [
|
|
{ chunk: "good-1" },
|
|
{ chunk: "forged", sid: "ws-EVIL" },
|
|
{ chunk: "good-2" },
|
|
],
|
|
exitCode: 0,
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
session.onData((chunk) => chunks.push(chunk));
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: 0 });
|
|
// The forged notification carries a wrong worker session id, so the host
|
|
// drops it. Only the two bound chunks reach the listener, in order.
|
|
expect(chunks).toEqual(["good-1", "good-2"]);
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
}, 15_000);
|
|
|
|
it("routes delayed input to the worker and back to the listener", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-A" }),
|
|
);
|
|
const chunks: string[] = [];
|
|
session.onData((chunk) => chunks.push(chunk));
|
|
session.write("browser-code");
|
|
// The worker echoes the input as one output notification for the bound
|
|
// session, so the listener receives it.
|
|
await vi.waitFor(() => expect(chunks).toContain("echo:browser-code"));
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("terminalizes the route when the cumulative output passes the per-route bound", async () => {
|
|
const handle = makeLoginPtyHandle({
|
|
loginPtyLimits: { maxTotalChars: 10 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openLoginPtySession(
|
|
ptyOpenInput({ mode: "normal" }),
|
|
);
|
|
const chunks: string[] = [];
|
|
session.onData((chunk) => chunks.push(chunk));
|
|
// Each empty input produces the five-character `echo:` chunk. Attach the
|
|
// listener first, then drive exactly two accepted chunks and one overflow
|
|
// so process scheduling cannot move output ahead of listener registration.
|
|
session.write("");
|
|
session.write("");
|
|
session.write("");
|
|
// The per-route bound terminalizes the route, so the login wait resolves
|
|
// with a null exit code and the third chunk never reaches the listener.
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: null });
|
|
expect(chunks).toEqual(["echo:", "echo:"]);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("terminalizes and fails closed on a malformed open reply, then admits a later open", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
await expect(
|
|
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.openLoginPtySession(
|
|
ptyOpenInput({ mode: "normal" }),
|
|
);
|
|
expect(session).toBeDefined();
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("terminalizes the route on an open timeout", async () => {
|
|
const handle = makeLoginPtyHandle({
|
|
loginPtyLimits: { openTimeoutMs: 200 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
await expect(
|
|
handle.openLoginPtySession(ptyOpenInput({ mode: "no-open-reply" })),
|
|
).rejects.toThrow();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("binds the worker session id one time and ignores a duplicate open reply", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
mode: "duplicate-open-reply",
|
|
workerSessionId: "ws-A",
|
|
outputs: [{ chunk: "hello" }],
|
|
exitCode: 0,
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
session.onData((chunk) => chunks.push(chunk));
|
|
// The duplicate open reply never rebinds or reopens the route, so the
|
|
// session runs normally on the one bind.
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: 0 });
|
|
expect(chunks).toEqual(["hello"]);
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("closes the route with a fixed exit when the worker exits", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openLoginPtySession(
|
|
ptyOpenInput({ mode: "normal" }),
|
|
);
|
|
const waitResult = session.wait();
|
|
await handle.stop();
|
|
// A worker exit closes the one route and resolves the login wait with the
|
|
// fixed non-secret exit.
|
|
await expect(waitResult).resolves.toEqual({ exitCode: null });
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("retires the worker on an unconfirmed close acknowledgement", async () => {
|
|
const handle = makeLoginPtyHandle({
|
|
loginPtyLimits: { closeTimeoutMs: 200 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const exited = new Promise<void>((resolve) => {
|
|
handle.on("exit", () => resolve());
|
|
});
|
|
const session = await handle.openLoginPtySession(
|
|
ptyOpenInput({ mode: "normal", closeMode: "bad-ack" }),
|
|
);
|
|
await session.close();
|
|
// The close acknowledgement carried a mismatched host route id, so the host
|
|
// fails closed and retires the worker before any reuse.
|
|
await exited;
|
|
await expect(
|
|
handle.openLoginPtySession(ptyOpenInput({ mode: "normal" })),
|
|
).rejects.toThrow();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Login pseudo-terminal concurrency
|
|
// ---------------------------------------------------------------------------
|
|
// One shared plugin worker now holds more than one live login pseudo-terminal
|
|
// route at once, so a second owner is never blocked by a first owner's
|
|
// session. The host resolves an output or an exit notification by the host
|
|
// route identifier first, then checks the bound worker session identifier,
|
|
// so each route receives only its own data even while another route on the
|
|
// same worker is live.
|
|
|
|
describe("plugin worker manager login pseudo-terminal concurrency", () => {
|
|
it("delivers output to each concurrent route only, with no cross-talk", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
const routeA = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
workerSessionId: "ws-A",
|
|
outputs: [{ chunk: "a-1" }],
|
|
exitCode: 0,
|
|
}),
|
|
);
|
|
const routeB = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
workerSessionId: "ws-B",
|
|
outputs: [{ chunk: "b-1" }],
|
|
exitCode: 0,
|
|
}),
|
|
);
|
|
const aChunks: string[] = [];
|
|
const bChunks: string[] = [];
|
|
routeA.onData((chunk) => aChunks.push(chunk));
|
|
routeB.onData((chunk) => bChunks.push(chunk));
|
|
await expect(routeA.wait()).resolves.toEqual({ exitCode: 0 });
|
|
await expect(routeB.wait()).resolves.toEqual({ exitCode: 0 });
|
|
// The host resolves each notification by its own host route identifier, so
|
|
// neither route ever sees the other's chunk.
|
|
expect(aChunks).toEqual(["a-1"]);
|
|
expect(bChunks).toEqual(["b-1"]);
|
|
await routeA.close();
|
|
await routeB.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("settles the exit of one route only, while a second concurrent route stays live", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
const routeA = await handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-A" }),
|
|
);
|
|
const routeB = await handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-B", exitCode: 0 }),
|
|
);
|
|
await expect(routeB.wait()).resolves.toEqual({ exitCode: 0 });
|
|
// Route A never received an exit, so its wait is still pending. Race it
|
|
// against a short delay to prove it has not settled.
|
|
const stillPending = await Promise.race([
|
|
routeA.wait().then(() => "settled"),
|
|
new Promise((resolve) => setTimeout(() => resolve("pending"), 50)),
|
|
]);
|
|
expect(stillPending).toBe("pending");
|
|
await routeA.close();
|
|
await routeB.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("drops a swapped (hostRouteId, workerSessionId) notification and delivers it to neither route", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
const routeA = await handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-A" }),
|
|
);
|
|
// Route B emits one output that carries route A's host route identifier
|
|
// but route B's own worker session identifier — a swapped pair. The host
|
|
// resolves route A by the host route identifier, then finds route B's
|
|
// worker session identifier does not match route A's bound identifier,
|
|
// so it drops the notification. It never reaches route A, and route B
|
|
// never sent it under its own host route identifier, so it never
|
|
// reaches route B either.
|
|
const routeB = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
workerSessionId: "ws-B",
|
|
outputs: [{ chunk: "swapped", crossRoute: true }],
|
|
}),
|
|
);
|
|
const aChunks: string[] = [];
|
|
const bChunks: string[] = [];
|
|
routeA.onData((chunk) => aChunks.push(chunk));
|
|
routeB.onData((chunk) => bChunks.push(chunk));
|
|
// Give the swapped notification time to arrive and be dropped.
|
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
expect(aChunks).toEqual([]);
|
|
expect(bChunks).toEqual([]);
|
|
await routeA.close();
|
|
await routeB.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("drops an output notification for an unknown host route identifier", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
workerSessionId: "ws-A",
|
|
sequence: [
|
|
{ type: "output", chunk: "ghost", hostRouteId: "totally-unknown-route" },
|
|
{ type: "output", chunk: "good" },
|
|
],
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
session.onData((chunk) => chunks.push(chunk));
|
|
await vi.waitFor(() => expect(chunks).toContain("good"));
|
|
// The unknown host route identifier resolves to no route on this worker,
|
|
// so the host drops it before it can reach any listener.
|
|
expect(chunks).toEqual(["good"]);
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("replays a pre-bind output record to its own route while a second route is already open", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
const routeA = await handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-A" }),
|
|
);
|
|
// Route B's fixture writes its open reply and its output notification in
|
|
// one stdout write, so the host reads both before route B's bind, while
|
|
// route A is already open. The bind replays the held record into route
|
|
// B's own queue; it never reaches route A.
|
|
const routeB = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
batchWithOpenReply: true,
|
|
workerSessionId: "ws-B",
|
|
outputs: [{ chunk: "b-batched" }],
|
|
}),
|
|
);
|
|
const aChunks: string[] = [];
|
|
const bChunks: string[] = [];
|
|
routeA.onData((chunk) => aChunks.push(chunk));
|
|
routeB.onData((chunk) => bChunks.push(chunk));
|
|
expect(aChunks).toEqual([]);
|
|
expect(bChunks).toEqual(["b-batched"]);
|
|
await routeA.close();
|
|
await routeB.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("closes one route without affecting a second concurrent route on the same worker", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
const routeA = await handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-A" }),
|
|
);
|
|
const routeB = await handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-B" }),
|
|
);
|
|
await routeA.close();
|
|
// Route B is still live: writing to it still round-trips through the
|
|
// worker and back to the listener.
|
|
const bChunks: string[] = [];
|
|
routeB.onData((chunk) => bChunks.push(chunk));
|
|
routeB.write("still-here");
|
|
await vi.waitFor(() => expect(bChunks).toContain("echo:still-here"));
|
|
await routeB.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("settles both concurrent routes exactly once when the worker exits", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
const routeA = await handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-A" }),
|
|
);
|
|
const routeB = await handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-B" }),
|
|
);
|
|
const waitA = routeA.wait();
|
|
const waitB = routeB.wait();
|
|
await handle.stop();
|
|
// The worker exit closes every route it still holds and resolves each
|
|
// login wait with the fixed non-secret exit, in one sweep.
|
|
await expect(waitA).resolves.toEqual({ exitCode: null });
|
|
await expect(waitB).resolves.toEqual({ exitCode: null });
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("retires the worker on an unconfirmed close, settling every concurrent route and logging no raw output", async () => {
|
|
const handle = makeLoginPtyHandle({
|
|
loginPtyLimits: { closeTimeoutMs: 200 },
|
|
});
|
|
const secretMarker = "super-secret-login-code-must-never-reach-a-log-line";
|
|
vi.mocked(logger.warn).mockClear();
|
|
vi.mocked(logger.error).mockClear();
|
|
vi.mocked(logger.info).mockClear();
|
|
vi.mocked(logger.debug).mockClear();
|
|
try {
|
|
await handle.start();
|
|
const exited = new Promise<void>((resolve) => {
|
|
handle.on("exit", () => resolve());
|
|
});
|
|
const routeA = await handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-A", closeMode: "bad-ack" }),
|
|
);
|
|
const routeB = await handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-B", outputs: [{ chunk: secretMarker }] }),
|
|
);
|
|
const waitB = routeB.wait();
|
|
await routeA.close();
|
|
// Route A's close acknowledgement carried a mismatched host route id, so
|
|
// the host fails closed and retires the whole worker before any reuse.
|
|
// That retirement settles route B too, even though B's own close was
|
|
// never called.
|
|
await exited;
|
|
await expect(waitB).resolves.toEqual({ exitCode: null });
|
|
await expect(
|
|
handle.openLoginPtySession(ptyOpenInput({ mode: "normal" })),
|
|
).rejects.toThrow();
|
|
const loggedText = [
|
|
...vi.mocked(logger.warn).mock.calls,
|
|
...vi.mocked(logger.error).mock.calls,
|
|
...vi.mocked(logger.info).mock.calls,
|
|
...vi.mocked(logger.debug).mock.calls,
|
|
]
|
|
.flat()
|
|
.map((arg) => JSON.stringify(arg))
|
|
.join("\n");
|
|
expect(loggedText).not.toContain(secretMarker);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The missing-hostRouteId diagnostic. `hostRouteId` is the routing key the
|
|
// current protocol tags every login pseudo-terminal notification with, but a
|
|
// plugin build old enough to predate it still tags the same notification with
|
|
// the worker session identifier, the sole routing key the previous protocol
|
|
// used. The host warns once about the old build, then still resolves the
|
|
// route by that identifier, so a legacy worker keeps its login output and its
|
|
// exit notification instead of losing them.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("plugin worker manager login pseudo-terminal missing hostRouteId diagnostic", () => {
|
|
it("delivers output from a legacy worker with no hostRouteId, resolved by the worker session id, and warns once", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
vi.mocked(logger.warn).mockClear();
|
|
try {
|
|
await handle.start();
|
|
const chunks: string[] = [];
|
|
const route = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
workerSessionId: "ws-A",
|
|
emitOnInput: true,
|
|
outputs: [{ chunk: "legacy-output", omitHostRouteId: true }],
|
|
}),
|
|
);
|
|
route.onData((chunk) => chunks.push(chunk));
|
|
// Legacy notifications require the open reply to bind the worker ID.
|
|
route.write("emit-scripted-output");
|
|
await vi.waitFor(() => expect(chunks).toContain("legacy-output"));
|
|
|
|
const warnCalls = vi.mocked(logger.warn).mock.calls.flat().map((arg) => JSON.stringify(arg));
|
|
const matches = warnCalls.filter((call) => call.includes("hostRouteId"));
|
|
expect(matches).toHaveLength(1);
|
|
expect(matches[0]).toContain("plugin build is too old");
|
|
// No identifier and no raw output in the warning.
|
|
expect(matches[0]).not.toContain("legacy-output");
|
|
expect(matches[0]).not.toContain("ws-A");
|
|
|
|
await route.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("settles the login wait on a legacy worker's exit notification with no hostRouteId", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
const route = await handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-A", exitCode: 0, omitHostRouteIdOnExit: true, emitOnInput: true }),
|
|
);
|
|
route.write("emit-scripted-exit");
|
|
await expect(route.wait()).resolves.toEqual({ exitCode: 0 });
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("repeats the warning no more than one time for one worker", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
vi.mocked(logger.warn).mockClear();
|
|
try {
|
|
await handle.start();
|
|
const route = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
workerSessionId: "ws-A",
|
|
outputs: [
|
|
{ chunk: "one", omitHostRouteId: true },
|
|
{ chunk: "two", omitHostRouteId: true },
|
|
{ chunk: "three", omitHostRouteId: true },
|
|
],
|
|
}),
|
|
);
|
|
// Give every notification time to arrive.
|
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
|
|
const warnCalls = vi.mocked(logger.warn).mock.calls.flat().map((arg) => JSON.stringify(arg));
|
|
const matches = warnCalls.filter((call) => call.includes("hostRouteId"));
|
|
expect(matches).toHaveLength(1);
|
|
|
|
await route.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("drops a message with no hostRouteId and no worker session id", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
const chunks: string[] = [];
|
|
const route = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
workerSessionId: "ws-A",
|
|
outputs: [
|
|
{ chunk: "unroutable", omitHostRouteId: true, sid: "" },
|
|
{ chunk: "good" },
|
|
],
|
|
}),
|
|
);
|
|
route.onData((chunk) => chunks.push(chunk));
|
|
await vi.waitFor(() => expect(chunks).toContain("good"));
|
|
expect(chunks).toEqual(["good"]);
|
|
await route.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("drops a no-hostRouteId message naming a concurrent route's session instead of cross-delivering it", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
// Two live routes share this worker. A worker session id alone cannot
|
|
// prove which route a hostRouteId-less message belongs to once a
|
|
// second route is live, so the fallback must refuse to guess.
|
|
const first = await handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-A" }),
|
|
);
|
|
const second = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
workerSessionId: "ws-B",
|
|
outputs: [{ chunk: "cross-route", sid: "ws-A", omitHostRouteId: true }],
|
|
}),
|
|
);
|
|
const firstChunks: string[] = [];
|
|
const secondChunks: string[] = [];
|
|
first.onData((chunk) => firstChunks.push(chunk));
|
|
second.onData((chunk) => secondChunks.push(chunk));
|
|
// Give the ambiguous notification time to arrive and be dropped.
|
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
expect(firstChunks).toEqual([]);
|
|
expect(secondChunks).toEqual([]);
|
|
|
|
await first.close();
|
|
await second.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The process-wide login pseudo-terminal route ceiling. A host process can
|
|
// run only so many concurrent login pseudo-terminal routes before the
|
|
// underlying resources are exhausted, so the ceiling protects the host
|
|
// process itself, not one user or one worker.
|
|
// ---------------------------------------------------------------------------
|
|
// Every login pseudo-terminal route reserves one slot from the same
|
|
// process-wide aggregate route-slot controller the duplex channel route
|
|
// uses (`createDuplexRouteSlotController`), so the two route types share one
|
|
// ceiling. This is a host-process safety ceiling across every worker in the
|
|
// process, not a per-user or a per-worker quota.
|
|
|
|
describe("plugin worker manager login pseudo-terminal route ceiling", () => {
|
|
it("rejects the second open with the fixed capacity error before the worker call, when the process-wide ceiling is full", async () => {
|
|
// A process-scoped ceiling of one slot. The manager injects one shared
|
|
// controller into every worker; the test injects a small one directly —
|
|
// the same controller shape the duplex channel route shares.
|
|
const handle = makeLoginPtyHandle({
|
|
duplexRouteSlots: createDuplexRouteSlotController(1),
|
|
loginPtyLimits: { openTimeoutMs: 300 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const first = await handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-A" }),
|
|
);
|
|
const startedAt = Date.now();
|
|
// The refused open scripts `no-open-reply`: if it wrongly reached the
|
|
// worker, the promise would only settle after the 300ms open timeout,
|
|
// and with a different, timeout-shaped error — not the capacity error.
|
|
// A fast rejection with the exact capacity error proves the worker
|
|
// never received this open's request.
|
|
await expect(
|
|
handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-B", mode: "no-open-reply" }),
|
|
),
|
|
).rejects.toThrow("LOGIN_PTY_ROUTES_AT_CAPACITY");
|
|
expect(Date.now() - startedAt).toBeLessThan(150);
|
|
await first.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("admits a later open after the first route closes and releases its slot", async () => {
|
|
const handle = makeLoginPtyHandle({
|
|
duplexRouteSlots: createDuplexRouteSlotController(1),
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const first = await handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-A" }),
|
|
);
|
|
await expect(
|
|
handle.openLoginPtySession(ptyOpenInput({ workerSessionId: "ws-B" })),
|
|
).rejects.toThrow("LOGIN_PTY_ROUTES_AT_CAPACITY");
|
|
// Closing the first route releases its slot, so a later open is admitted.
|
|
await first.close();
|
|
const second = await handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-C" }),
|
|
);
|
|
await second.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("admits a later open on a different worker after a worker exit releases the shared slot", async () => {
|
|
// One shared controller instance, the same shape the manager injects into
|
|
// every worker handle, so both handles below draw from the SAME ceiling.
|
|
const sharedSlots = createDuplexRouteSlotController(1);
|
|
const handle = makeLoginPtyHandle({ duplexRouteSlots: sharedSlots });
|
|
const secondHandle = makeLoginPtyHandle({ duplexRouteSlots: sharedSlots });
|
|
try {
|
|
await handle.start();
|
|
const first = await handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-A" }),
|
|
);
|
|
const waitResult = first.wait();
|
|
await handle.stop();
|
|
// The worker exit settles the route and releases its slot.
|
|
await expect(waitResult).resolves.toEqual({ exitCode: null });
|
|
// Before the release, a second worker's open against the same shared
|
|
// ceiling would have rejected with the capacity error. After the
|
|
// release, the shared ceiling of one admits a fresh open on a
|
|
// different worker.
|
|
await secondHandle.start();
|
|
const second = await secondHandle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-B" }),
|
|
);
|
|
await second.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
await secondHandle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("releases exactly one slot when a route's terminal exit is followed by an explicit close", async () => {
|
|
// A double release (once on the terminal exit, once on the later close)
|
|
// would let a third open through a ceiling of one before its true
|
|
// capacity. This test proves the ceiling still holds after both events.
|
|
const handle = makeLoginPtyHandle({
|
|
duplexRouteSlots: createDuplexRouteSlotController(1),
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const first = await handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-A", exitCode: 0 }),
|
|
);
|
|
await expect(first.wait()).resolves.toEqual({ exitCode: 0 });
|
|
// The terminal exit already released the one slot. A second open now
|
|
// succeeds, consuming that same slot.
|
|
const second = await handle.openLoginPtySession(
|
|
ptyOpenInput({ workerSessionId: "ws-B" }),
|
|
);
|
|
// The explicit close on the first (already-exited) route must not
|
|
// release a second slot it no longer holds.
|
|
await first.close();
|
|
await expect(
|
|
handle.openLoginPtySession(ptyOpenInput({ workerSessionId: "ws-C" })),
|
|
).rejects.toThrow("LOGIN_PTY_ROUTES_AT_CAPACITY");
|
|
await second.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Login pseudo-terminal pre-bind queue
|
|
// ---------------------------------------------------------------------------
|
|
// The host reads the worker pipe and `readline` dispatches every line of one
|
|
// chunk synchronously. The route only becomes `open` inside the `await`
|
|
// continuation of the `loginPtyOpen` reply, which runs as a microtask after
|
|
// the whole synchronous line loop. So a worker that batches an output or an
|
|
// exit notification with the open reply floods the host before the bind. The
|
|
// tests below prove the host queues these pre-bind records and replays them
|
|
// through the live router right after the bind, instead of dropping them.
|
|
// The host holds every pre-bind record — output and exit alike — in one
|
|
// arrival-ordered queue, and it replays each record through the live router
|
|
// in that exact order. A replayed exit that carries the bound worker session
|
|
// identifier settles the route, so a record that arrived behind it — a real
|
|
// worker process cannot emit output after it exits, so this only matters for
|
|
// a forged or a queued record — replays into a route that already settled
|
|
// and is dropped.
|
|
|
|
describe("plugin worker manager login pseudo-terminal pre-bind queue", () => {
|
|
it("queues and replays a coalesced output notification that arrives before the bind", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
// The fixture writes the open reply and the output notification in one
|
|
// stdout write, so the host reads both before the route binds.
|
|
const session = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
batchWithOpenReply: true,
|
|
workerSessionId: "ws-A",
|
|
outputs: [{ chunk: "batched-output" }],
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
session.onData((chunk) => chunks.push(chunk));
|
|
// The bind already replayed the queued record into `buffered`, so
|
|
// `onData` drains it synchronously with no wait.
|
|
expect(chunks).toEqual(["batched-output"]);
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("delivers a coalesced output before the coalesced exit settles the wait", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
batchWithOpenReply: true,
|
|
workerSessionId: "ws-A",
|
|
outputs: [{ chunk: "batched-output" }],
|
|
exitCode: 0,
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
session.onData((chunk) => chunks.push(chunk));
|
|
// Both the output and the exit arrived before the bind and queued in
|
|
// order. The replay preserves that order, so the output reaches the
|
|
// listener before the wait settles.
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: 0 });
|
|
expect(chunks).toEqual(["batched-output"]);
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("settles the wait with a valid pre-bind exit that a later mismatched exit cannot displace", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
// The worker batches a valid exit for the real worker session id, then a
|
|
// second exit for a forged worker session id, both before the open
|
|
// reply. The bind still verifies the real session id, so the held valid
|
|
// exit settles the wait, and the mismatched exit that arrived after it
|
|
// never displaces it.
|
|
const session = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
batchWithOpenReply: true,
|
|
workerSessionId: "ws-A",
|
|
exitCode: 0,
|
|
extraExits: [{ exitCode: 1, sid: "ws-EVIL" }],
|
|
}),
|
|
);
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: 0 });
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("delivers a pre-bind output, then the exit, and drops output that arrives behind the exit", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
// The worker batches an output, then a valid exit, then a further
|
|
// output, all before the open reply. The host replays the three held
|
|
// records in this exact arrival order. The exit settles the route
|
|
// right after the first output, so the record behind the exit finds a
|
|
// route that already settled and never reaches the listener.
|
|
const session = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
batchWithOpenReply: true,
|
|
workerSessionId: "ws-A",
|
|
outputs: [{ chunk: "before-exit" }],
|
|
exitCode: 0,
|
|
outputsAfterExit: [{ chunk: "after-exit" }],
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
session.onData((chunk) => chunks.push(chunk));
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: 0 });
|
|
expect(chunks).toEqual(["before-exit"]);
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("drops output that arrives behind a valid pre-bind exit", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
batchWithOpenReply: true,
|
|
workerSessionId: "ws-A",
|
|
exitCode: 0,
|
|
// The worker batched this output behind the exit. The replay sends
|
|
// the exit first, in arrival order, which settles the route. The
|
|
// output record behind it then finds a route that is no longer
|
|
// `open`, the same drop the live path applies to output a real
|
|
// worker process could never emit after its own exit.
|
|
outputsAfterExit: [{ chunk: "late-output" }],
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
session.onData((chunk) => chunks.push(chunk));
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: 0 });
|
|
expect(chunks).toEqual([]);
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("settles the wait with the valid exit code and drops output behind it, even past the total-chars bound", async () => {
|
|
const handle = makeLoginPtyHandle({
|
|
loginPtyLimits: { maxTotalChars: 10, maxPreBindFrames: 1000, maxPreBindChars: 1000 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
// The worker batches a valid exit with the open reply, then an output
|
|
// record that would push the cumulative delivered total past the
|
|
// 10-character bound. The replay sends the exit first, in arrival
|
|
// order. The exit settles the route, so the replay drops the output
|
|
// record behind it before the total-chars check ever runs — the
|
|
// bound violation the check exists to catch never reaches it, because
|
|
// a real worker process could never emit that output in the first
|
|
// place.
|
|
const session = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
batchWithOpenReply: true,
|
|
workerSessionId: "ws-A",
|
|
exitCode: 0,
|
|
outputsAfterExit: [{ chunk: "aaaaaaaaaaaa" }],
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
session.onData((chunk) => chunks.push(chunk));
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: 0 });
|
|
expect(chunks).toEqual([]);
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("delivers output that arrived behind a mismatched pre-bind exit and settles with the later valid exit", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
// The worker batches a mismatched exit, then a genuine output, then
|
|
// the valid exit, all before the open reply. The mismatched exit
|
|
// arrives first, but the exact-match gate fails it, so it changes no
|
|
// state and the replay continues. The output that follows it still
|
|
// reaches the listener, and the valid exit that follows the output
|
|
// settles the wait.
|
|
const session = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
batchWithOpenReply: true,
|
|
workerSessionId: "ws-A",
|
|
sequence: [
|
|
{ type: "exit", exitCode: 1, sid: "ws-EVIL" },
|
|
{ type: "output", chunk: "genuine-output" },
|
|
{ type: "exit", exitCode: 0 },
|
|
],
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
session.onData((chunk) => chunks.push(chunk));
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: 0 });
|
|
expect(chunks).toEqual(["genuine-output"]);
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("drops a forged pre-bind worker session id sent before the valid bind", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
batchWithOpenReply: true,
|
|
workerSessionId: "ws-A",
|
|
outputs: [
|
|
{ chunk: "forged", sid: "ws-EVIL" },
|
|
{ chunk: "good" },
|
|
],
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
session.onData((chunk) => chunks.push(chunk));
|
|
// Both records queued before the bind, since the host cannot yet check
|
|
// a worker session id against a route that has not bound one. The
|
|
// replay applies the exact-match gate against the real bind ("ws-A"),
|
|
// so the forged record never reaches the listener.
|
|
expect(chunks).toEqual(["good"]);
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("terminalizes the route when batched pre-bind records pass the frame-count bound", async () => {
|
|
const handle = makeLoginPtyHandle({
|
|
loginPtyLimits: { maxPreBindFrames: 2 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
// The worker batches three output notifications with the open reply, so
|
|
// all three arrive, and queue, before the bind. The third record passes
|
|
// the frame-count bound, so the host terminalizes the route before the
|
|
// bind can complete, and the open call itself fails.
|
|
await expect(
|
|
handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
batchWithOpenReply: true,
|
|
outputs: [{ chunk: "a" }, { chunk: "b" }, { chunk: "c" }],
|
|
}),
|
|
),
|
|
).rejects.toThrow("LOGIN_PTY_OPEN_FAILED");
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("terminalizes the route when batched pre-bind output passes the character bound", async () => {
|
|
const handle = makeLoginPtyHandle({
|
|
loginPtyLimits: { maxPreBindChars: 10 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
await expect(
|
|
handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
batchWithOpenReply: true,
|
|
// 5 + 5 = 10 admits; the third record brings the queued total to
|
|
// 15, past the 10-character bound, so the host terminalizes the
|
|
// route before the bind can complete.
|
|
outputs: [{ chunk: "aaaaa" }, { chunk: "bbbbb" }, { chunk: "ccccc" }],
|
|
}),
|
|
),
|
|
).rejects.toThrow("LOGIN_PTY_OPEN_FAILED");
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("charges the retained worker session id characters on the pre-bind output path", async () => {
|
|
const handle = makeLoginPtyHandle({
|
|
loginPtyLimits: { maxPreBindChars: 10 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
// A 6-character worker session id and one 5-character chunk charge 11
|
|
// characters against the 10-character bound on the very first record,
|
|
// even though the chunk alone is under the bound. Without the identifier
|
|
// charge, this single record would pass.
|
|
await expect(
|
|
handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
batchWithOpenReply: true,
|
|
workerSessionId: "ABCDEF",
|
|
outputs: [{ chunk: "aaaaa" }],
|
|
}),
|
|
),
|
|
).rejects.toThrow("LOGIN_PTY_OPEN_FAILED");
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("terminalizes the route when batched pre-bind exit notifications with a large worker session id pass the character bound", async () => {
|
|
const handle = makeLoginPtyHandle({
|
|
loginPtyLimits: { maxPreBindChars: 10 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
await expect(
|
|
handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
batchWithOpenReply: true,
|
|
// An exit record carries no chunk, but it still retains the worker
|
|
// session id. This 5-character id makes 5 + 5 = 10 admit the first
|
|
// two exits; the third brings the queued total to 15, past the
|
|
// 10-character bound, so the host terminalizes the route before
|
|
// the bind can complete.
|
|
workerSessionId: "AAAAA",
|
|
sequence: [
|
|
{ type: "exit", exitCode: 1 },
|
|
{ type: "exit", exitCode: 2 },
|
|
{ type: "exit", exitCode: 3 },
|
|
],
|
|
}),
|
|
),
|
|
).rejects.toThrow("LOGIN_PTY_OPEN_FAILED");
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("settles with the first exit code against a repeated pre-bind exit for the same session, behind a filled output queue", async () => {
|
|
const handle = makeLoginPtyHandle({
|
|
loginPtyLimits: { maxPreBindFrames: 50 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
// Fill the pre-bind queue with 40 output records, then repeat the exit
|
|
// for the same worker session id after more output arrives. The first
|
|
// exit settles the route during the replay, so the repeat exit and the
|
|
// output around it never reach the listener.
|
|
const fillerOutputs = Array.from({ length: 40 }, (_, index) => ({
|
|
type: "output" as const,
|
|
chunk: `filler-${index}`,
|
|
}));
|
|
const session = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
batchWithOpenReply: true,
|
|
workerSessionId: "ws-A",
|
|
sequence: [
|
|
...fillerOutputs,
|
|
{ type: "exit", exitCode: 1 },
|
|
{ type: "output", chunk: "between-exits" },
|
|
{ type: "exit", exitCode: 2 },
|
|
{ type: "output", chunk: "after-repeat" },
|
|
],
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
session.onData((chunk) => chunks.push(chunk));
|
|
// The wait settles with the FIRST exit code, and the exit drops every
|
|
// output that arrived behind it, so neither "between-exits" nor
|
|
// "after-repeat" reaches the listener.
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: 1 });
|
|
expect(chunks).toEqual(fillerOutputs.map((entry) => entry.chunk));
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("settles with the first exit code against N repeated pre-bind exits for the same session", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
// The worker sends five repeat exits for the same session before the
|
|
// bind. The first exit settles the route during the replay, so every
|
|
// repeat drops there and the wait still settles with the first code.
|
|
const session = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
batchWithOpenReply: true,
|
|
workerSessionId: "ws-A",
|
|
exitCode: 1,
|
|
extraExits: [
|
|
{ exitCode: 2 },
|
|
{ exitCode: 3 },
|
|
{ exitCode: 4 },
|
|
{ exitCode: 5 },
|
|
],
|
|
}),
|
|
);
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: 1 });
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("clears the pre-bind queue on a malformed open reply", async () => {
|
|
const handle = makeLoginPtyHandle();
|
|
try {
|
|
await handle.start();
|
|
// The malformed reply carries no worker session id, so the host cannot
|
|
// bind. It terminalizes the route and clears the queued output before
|
|
// it ever reaches a listener.
|
|
await expect(
|
|
handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
mode: "malformed-open",
|
|
batchWithOpenReply: true,
|
|
outputs: [{ chunk: "leaked" }],
|
|
}),
|
|
),
|
|
).rejects.toThrow("LOGIN_PTY_OPEN_FAILED");
|
|
// A later open on the same worker starts a fresh route and receives
|
|
// only its own scripted output, never the cleared queue.
|
|
const session = await handle.openLoginPtySession(
|
|
ptyOpenInput({ mode: "normal", outputs: [{ chunk: "fresh" }] }),
|
|
);
|
|
const chunks: string[] = [];
|
|
session.onData((chunk) => chunks.push(chunk));
|
|
await vi.waitFor(() => expect(chunks).toContain("fresh"));
|
|
expect(chunks).not.toContain("leaked");
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("clears the pre-bind queue when the worker exits during the open window", async () => {
|
|
const handle = makeLoginPtyHandle({ autoRestart: false });
|
|
try {
|
|
await handle.start();
|
|
// The fixture emits one output notification, then exits before it ever
|
|
// sends the open reply. The route never binds. The worker-exit path
|
|
// must clear the queued output along with the route.
|
|
await expect(
|
|
handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
mode: "exit-before-open-reply",
|
|
outputs: [{ chunk: "queued-before-exit" }],
|
|
}),
|
|
),
|
|
).rejects.toThrow();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("terminalizes at replay when the queued output would pass the cumulative total-chars bound", async () => {
|
|
const handle = makeLoginPtyHandle({
|
|
loginPtyLimits: { maxTotalChars: 10, maxPreBindFrames: 1000, maxPreBindChars: 1000 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
// The generous pre-bind bounds admit all three records at intake, so
|
|
// the bind completes and the replay runs. The replay sends each record
|
|
// through the same live router an open route uses, so the cumulative
|
|
// `maxTotalChars` gate still applies: the third record would bring the
|
|
// delivered total to 15, past the 10-character bound, so the replay
|
|
// terminalizes the route partway through. The directive carries no exit
|
|
// notification, so only a mid-replay terminalize can settle the wait;
|
|
// without the cumulative gate applying during replay, this call would
|
|
// hang.
|
|
const session = await handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
batchWithOpenReply: true,
|
|
outputs: [{ chunk: "aaaaa" }, { chunk: "bbbbb" }, { chunk: "ccccc" }],
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
session.onData((chunk) => chunks.push(chunk));
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: null });
|
|
// The terminalize clears `route.buffered` by design (a terminalized
|
|
// login route settles with a null exit code, so buffered data has no
|
|
// consumer), so even the two records the replay delivered before the
|
|
// bound tripped never reach the listener.
|
|
expect(chunks).toEqual([]);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("never logs the raw chunk content, including on the pre-bind overflow path", async () => {
|
|
const handle = makeLoginPtyHandle({
|
|
loginPtyLimits: { maxPreBindFrames: 1 },
|
|
});
|
|
const secretMarker = "super-secret-login-code-must-never-reach-a-log-line";
|
|
vi.mocked(logger.warn).mockClear();
|
|
vi.mocked(logger.error).mockClear();
|
|
vi.mocked(logger.info).mockClear();
|
|
vi.mocked(logger.debug).mockClear();
|
|
try {
|
|
await handle.start();
|
|
// The first record admits, then the second passes the frame-count
|
|
// bound and terminalizes the route. The overflow path logs a fixed
|
|
// warning that must never carry the chunk text.
|
|
await expect(
|
|
handle.openLoginPtySession(
|
|
ptyOpenInput({
|
|
batchWithOpenReply: true,
|
|
outputs: [{ chunk: secretMarker }, { chunk: "overflow" }],
|
|
}),
|
|
),
|
|
).rejects.toThrow("LOGIN_PTY_OPEN_FAILED");
|
|
const loggedText = [
|
|
...vi.mocked(logger.warn).mock.calls,
|
|
...vi.mocked(logger.error).mock.calls,
|
|
...vi.mocked(logger.info).mock.calls,
|
|
...vi.mocked(logger.debug).mock.calls,
|
|
]
|
|
.flat()
|
|
.map((arg) => JSON.stringify(arg))
|
|
.join("\n");
|
|
expect(loggedText).not.toContain(secretMarker);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
});
|