mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
feat(workspaces): sign the workspace login handoff and gate readiness (#11671)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Managed worktree services run isolated Paperclip instances with cloned databases. > - A reachable service was reported as ready even when its database, runtime identity, or login path was not usable. > - The first candidate added verified database seeding and managed repair in #11665. > - This pull request consolidates that candidate with signed login handoff and a complete readiness contract. > - Post-QA fixes close five defects in repair identity, repair responses, UI retry, seed journal handling, and seed-source trust. > - The benefit is a workspace that either opens safely or reports one accurate recovery action. ## Linked Issues or Issue Description No public GitHub issue exists for this work, so the problem is described here. **What happened** Managed workspace URLs could return HTTP 200 and report ready while login failed. QA also found cases where repair used the wrong instance identity, returned a generic error, left the UI stuck, rejected a safe journal lag, or trusted a mutable workspace manifest. **Expected behavior** Opening a ready workspace signs the board user in to the correct isolated instance. Provisioning and repair use a registered source and report a structured recovery state. **Actual behavior** Entry depended on a password copied into the clone. Several failure paths could publish stale readiness, hide the repair precondition, or trust state that the workspace could modify. **Additional context** This pull request includes the commits first published in #11665. That pull request keeps the original base head for review history. This consolidated pull request is the merge candidate. Related open readiness work includes #11575 and #11621. ## What Changed - Adds a short-lived, signed, single-use login ticket. It binds the user, workspace, instance, and runtime origin. - Exchanges the ticket through Better Auth. It creates the session and cookie through the supported adapter path. - Adds protected workspace readiness fields for the database, clone data, login handoff, seed phase, and runtime identity. - Fails readiness closed when the guest has no company or execution-workspace binding. - Binds ticket issuance to the exact cloned user and active company membership selected for the handoff. - Verifies every current active board identity through the exact-user handoff before publication or reuse. - Gates managed runtime publication on the readiness contract and the recorded worktree instance identity. - Refreshes runtime work products from the live runtime row after a port change. - Adds one workspace access card with ready, degraded, repairing, and failed states. - Uses the runtime response identity for repair. It returns structured repair precondition errors. - Lets a valid source journal lag converge during provisioning. - Binds seed and repair manifests to a source registered outside the agent-writable worktree. - Clears recovered UI errors so a successful retry can open the workspace. - Makes runtime tests register canonical sources and avoid ports owned by live host listeners. - Keeps Vitest on source suites when compiled `dist` trees exist. - Isolates CLI and adapter tests from ambient AWS and runtime API environment variables. - Preserves a 404 response for cross-company workspace ID lookups before runtime authorization. - Makes concurrent single-flight coverage independent of path-canonicalization scheduling order. ## Verification The following checks passed on the integrated head: ```sh pnpm -r typecheck pnpm build pnpm check:token-gates pnpm --filter @paperclipai/db check:migrations ``` - The server source lane passed 420 files and 4,953 tests. Five tests were skipped. - The CLI lane passed 57 files and 385 tests. - The database lane passed 26 files and 97 tests. - The shared package passed 58 files and 506 tests. - The adapter utility lane passed 640 tests. Four tests were skipped. - The Claude adapter passed 220 tests. One test was skipped. - The Codex adapter passed 323 tests. - The OpenClaw adapter passed 13 tests. - The OpenCode adapter passed 42 tests. - The plugin SDK passed 45 tests. - The workspace runtime suite passed 124 tests. - The caller-scoped readiness and handoff suite passed 52 tests. - The workspace provisioning shell suite passed 7 tests. - The runtime exposure suite passed 17 tests while live host mappings occupied fixed test ports. - `git diff --check` passed and the worktree is clean. The serialized route lane will run in GitHub CI with its normal shards. No deployment or active-workspace migration was performed. ## Risks - This is a medium-risk authentication and runtime-readiness change. - The login ticket uses exact origin, workspace, instance, and user binding. It has a short expiry and a one-time nonce. - Runtime publication is stricter. A real readiness, identity, per-user handoff, or control-plane database disagreement now blocks publication. - This pull request supersedes #11665 as the merge candidate. Close #11665 after this pull request merges. - No new database migration is included. The lockfile and workflow files are unchanged. - Deployment and active-workspace migration are intentionally outside this pull request. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used Claude Opus 5 (`claude-opus-5[1m]`), 1M context, extended thinking, tool use, and code execution produced the main candidate. OpenAI GPT-5 (`gpt-5`) through Codex, with agentic reasoning, tool use, and code execution, integrated the post-QA fixes and hardened the test gates. The Codex context-window size was not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6aaef2998f
commit
a2bf936f9a
59 files changed
+7754
-243
No files matched your search
@@ -36,6 +36,36 @@ describe("dev-runner worktree env bootstrap", () => {
|
||||
expect(isWorktreeSeedPending(root)).toBe(false);
|
||||
});
|
||||
|
||||
it("guards every manifest state except a complete verified manifest", () => {
|
||||
const root = createTempRoot("paperclip-dev-runner-seed-manifest-");
|
||||
const manifestPath = path.join(root, ".paperclip", "seed-manifest.json");
|
||||
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
|
||||
fs.writeFileSync(manifestPath, JSON.stringify({ version: 2, state: "failed" }), "utf8");
|
||||
expect(isWorktreeSeedPending(root)).toBe(true);
|
||||
|
||||
fs.writeFileSync(manifestPath, JSON.stringify({ version: 2, state: "verified" }), "utf8");
|
||||
expect(isWorktreeSeedPending(root)).toBe(true);
|
||||
|
||||
fs.writeFileSync(manifestPath, JSON.stringify({
|
||||
version: 2,
|
||||
source: { instanceId: "source", configPath: "/source/config.json" },
|
||||
snapshotAt: "2026-08-18T00:00:00.000Z",
|
||||
seedMode: "minimal",
|
||||
migrationRevision: "0001",
|
||||
targetInstanceId: "target",
|
||||
phase: "complete",
|
||||
state: "verified",
|
||||
attemptId: "attempt",
|
||||
startedAt: "2026-08-18T00:00:00.000Z",
|
||||
finishedAt: "2026-08-18T00:01:00.000Z",
|
||||
diagnostics: [{ phase: "complete", status: "succeeded", at: "2026-08-18T00:01:00.000Z" }],
|
||||
}), "utf8");
|
||||
expect(isWorktreeSeedPending(root)).toBe(false);
|
||||
|
||||
fs.writeFileSync(manifestPath, "not-json", "utf8");
|
||||
expect(isWorktreeSeedPending(root)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects linked git worktrees from .git files", () => {
|
||||
const root = createTempRoot("paperclip-dev-runner-worktree-");
|
||||
fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8");
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -24,11 +29,18 @@ const mockStartRuntimeServices = vi.hoisted(() => vi.fn());
|
||||
const mockStopRuntimeServicesForExecutionWorkspace = vi.hoisted(() => vi.fn());
|
||||
const mockEnsurePersistedExecutionWorkspaceAvailable = vi.hoisted(() => vi.fn());
|
||||
const mockBuildWorkspaceRuntimeDesiredStatePatch = vi.hoisted(() => vi.fn());
|
||||
const mockListConfiguredRuntimeServiceEntries = vi.hoisted(() => vi.fn());
|
||||
// The integrated control path also takes the durable runtime-control lease (PAP-17205). This
|
||||
// suite covers the in-flight guard and failed-start reconciliation, so the lease always grants;
|
||||
// `execution-workspace-runtime-lease-route.test.ts` exercises the lease itself against a real db.
|
||||
const mockClaimRuntimeLease = vi.hoisted(() => vi.fn(async () => null));
|
||||
const mockReleaseRuntimeLease = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const mockSpawn = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:child_process", async () => ({
|
||||
...(await vi.importActual<typeof import("node:child_process")>("node:child_process")),
|
||||
spawn: mockSpawn,
|
||||
}));
|
||||
|
||||
vi.mock("../telemetry.js", () => ({ getTelemetryClient: mockGetTelemetryClient }));
|
||||
|
||||
@@ -45,14 +57,14 @@ vi.mock("../services/index.js", () => ({
|
||||
claim: mockClaimRuntimeLease,
|
||||
release: mockReleaseRuntimeLease,
|
||||
}),
|
||||
LEASED_WORKSPACE_RUNTIME_ACTIONS: ["start", "stop", "restart"],
|
||||
LEASED_WORKSPACE_RUNTIME_ACTIONS: ["start", "stop", "restart", "repair"],
|
||||
}));
|
||||
|
||||
vi.mock("../services/workspace-runtime.js", () => ({
|
||||
buildWorkspaceRuntimeDesiredStatePatch: mockBuildWorkspaceRuntimeDesiredStatePatch,
|
||||
cleanupExecutionWorkspaceArtifacts: vi.fn(),
|
||||
ensurePersistedExecutionWorkspaceAvailable: mockEnsurePersistedExecutionWorkspaceAvailable,
|
||||
listConfiguredRuntimeServiceEntries: vi.fn(() => []),
|
||||
listConfiguredRuntimeServiceEntries: mockListConfiguredRuntimeServiceEntries,
|
||||
runWorkspaceJobForControl: vi.fn(),
|
||||
startRuntimeServicesForWorkspaceControl: mockStartRuntimeServices,
|
||||
stopRuntimeServicesForExecutionWorkspace: mockStopRuntimeServicesForExecutionWorkspace,
|
||||
@@ -65,6 +77,9 @@ vi.mock("../routes/workspace-runtime-service-authz.js", () => ({
|
||||
}));
|
||||
|
||||
const executionWorkspaceId = "33333333-3333-4333-8333-333333333333";
|
||||
const projectId = "44444444-4444-4444-8444-444444444444";
|
||||
const projectWorkspaceId = "55555555-5555-4555-8555-555555555555";
|
||||
let registeredProjectWorkspace: { id: string; cwd: string; metadata: null } | null = null;
|
||||
|
||||
function buildExecutionWorkspace(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -122,11 +137,100 @@ async function createApp() {
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", executionWorkspaceRoutes({} as any));
|
||||
const db = {
|
||||
select: (selection: Record<string, unknown>) => ({
|
||||
from: () => ({
|
||||
where: () => Promise.resolve(
|
||||
Object.prototype.hasOwnProperty.call(selection, "cwd")
|
||||
? registeredProjectWorkspace ? [registeredProjectWorkspace] : []
|
||||
: [{ executionWorkspacePolicy: null }],
|
||||
),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
app.use("/api", executionWorkspaceRoutes(db as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function createRegisteredRepairFixture(
|
||||
prefix: string,
|
||||
options: { targetInstanceId?: string; withCli?: boolean } = {},
|
||||
) {
|
||||
const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
const baseCwd = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}base-`));
|
||||
const configDir = path.join(workspaceCwd, ".paperclip");
|
||||
const sourceConfigPath = path.join(baseCwd, ".paperclip", "config.json");
|
||||
const targetInstanceId = options.targetInstanceId ?? "repair-target";
|
||||
const cliRunner = path.join(baseCwd, "cli", "node_modules", "tsx", "dist", "cli.mjs");
|
||||
const cliEntry = path.join(baseCwd, "cli", "src", "index.ts");
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true });
|
||||
fs.writeFileSync(sourceConfigPath, "{}\n");
|
||||
fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=repair-source\n");
|
||||
fs.writeFileSync(path.join(configDir, "config.json"), "{}\n");
|
||||
fs.writeFileSync(path.join(configDir, ".env"), `PAPERCLIP_INSTANCE_ID=${targetInstanceId}\n`);
|
||||
if (options.withCli !== false) {
|
||||
fs.mkdirSync(path.dirname(cliRunner), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(cliEntry), { recursive: true });
|
||||
fs.writeFileSync(cliRunner, "// test runner\n");
|
||||
fs.writeFileSync(cliEntry, "// test entry\n");
|
||||
}
|
||||
fs.writeFileSync(path.join(configDir, "seed-manifest.json"), JSON.stringify({
|
||||
version: 2,
|
||||
source: { instanceId: "repair-source", configPath: sourceConfigPath },
|
||||
targetInstanceId,
|
||||
state: "failed",
|
||||
attemptId: "previous",
|
||||
}));
|
||||
registeredProjectWorkspace = { id: projectWorkspaceId, cwd: baseCwd, metadata: null };
|
||||
mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({
|
||||
cwd: workspaceCwd,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
}));
|
||||
return { workspaceCwd, baseCwd, configDir, sourceConfigPath, targetInstanceId };
|
||||
}
|
||||
|
||||
function removeRegisteredRepairFixture(fixture: { workspaceCwd: string; baseCwd: string }) {
|
||||
fs.rmSync(fixture.workspaceCwd, { recursive: true, force: true });
|
||||
fs.rmSync(fixture.baseCwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function mockVerifiedReseed(
|
||||
fixture: ReturnType<typeof createRegisteredRepairFixture>,
|
||||
targetInstanceId = fixture.targetInstanceId,
|
||||
) {
|
||||
mockSpawn.mockImplementation(() => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = vi.fn();
|
||||
queueMicrotask(() => {
|
||||
fs.writeFileSync(path.join(fixture.configDir, "seed-manifest.json"), JSON.stringify({
|
||||
version: 2,
|
||||
source: { instanceId: "repair-source", configPath: fixture.sourceConfigPath },
|
||||
snapshotAt: "2026-08-18T00:00:00.000Z",
|
||||
seedMode: "full",
|
||||
migrationRevision: "0142_test.sql",
|
||||
targetInstanceId,
|
||||
phase: "complete",
|
||||
state: "verified",
|
||||
attemptId: "repair-attempt",
|
||||
startedAt: "2026-08-18T00:00:00.000Z",
|
||||
finishedAt: "2026-08-18T00:01:00.000Z",
|
||||
diagnostics: [{ phase: "complete", status: "succeeded", at: "2026-08-18T00:01:00.000Z" }],
|
||||
}));
|
||||
child.emit("exit", 0);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-level guarantees for PAP-17249: the competing lane in the PAP-17207 report must get a
|
||||
* stable 409 while a control is genuinely live, and a start that fails must leave the workspace
|
||||
@@ -135,6 +239,8 @@ async function createApp() {
|
||||
describe.sequential("execution workspace runtime control conflict and failure reconciliation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSpawn.mockReset();
|
||||
registeredProjectWorkspace = null;
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: true,
|
||||
action: "runtime:manage",
|
||||
@@ -159,13 +265,14 @@ describe.sequential("execution workspace runtime control conflict and failure re
|
||||
repoRef: "main",
|
||||
});
|
||||
mockStopRuntimeServicesForExecutionWorkspace.mockResolvedValue(undefined);
|
||||
mockListConfiguredRuntimeServiceEntries.mockReturnValue([{ name: "app" }]);
|
||||
mockWorkspaceOperationService.createRecorder.mockReturnValue({
|
||||
attachExecutionWorkspaceId: vi.fn(),
|
||||
recordOperation: async (input: any) => {
|
||||
// Mirror the real recorder: a throwing `run` becomes a terminal failed operation and
|
||||
// the error still propagates to the caller.
|
||||
try {
|
||||
await input.run();
|
||||
await input.run(async () => undefined);
|
||||
} catch (error) {
|
||||
(error as any).recordedOperationStatus = "failed";
|
||||
throw error;
|
||||
@@ -247,6 +354,320 @@ describe.sequential("execution workspace runtime control conflict and failure re
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 422 with a stable reason when the repair seed manifest is malformed", async () => {
|
||||
const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-route-repair-malformed-"));
|
||||
try {
|
||||
const configDir = path.join(workspaceCwd, ".paperclip");
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, "config.json"), "{}\n");
|
||||
fs.writeFileSync(path.join(configDir, "seed-manifest.json"), "{ definitely-not-json\n");
|
||||
mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ cwd: workspaceCwd }));
|
||||
|
||||
const res = await request(await createApp())
|
||||
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body).toMatchObject({
|
||||
code: "workspace_repair_precondition_failed",
|
||||
reason: "seed_manifest_malformed",
|
||||
repairPhase: "precondition_validation",
|
||||
});
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(workspaceCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["sibling", "foreign_instance", "symlink", "instance_mismatch"] as const)(
|
||||
"rejects a %s repair manifest source before spawn or runtime mutation",
|
||||
async (variant) => {
|
||||
const fixture = createRegisteredRepairFixture(`paperclip-route-repair-${variant}-`);
|
||||
const attackerDir = path.join(path.dirname(fixture.workspaceCwd), `${path.basename(fixture.workspaceCwd)}-attacker`);
|
||||
try {
|
||||
fs.mkdirSync(attackerDir, { recursive: true });
|
||||
const attackerConfig = path.join(attackerDir, "config.json");
|
||||
fs.writeFileSync(attackerConfig, "{}\n");
|
||||
fs.writeFileSync(
|
||||
path.join(attackerDir, ".env"),
|
||||
`PAPERCLIP_INSTANCE_ID=${variant === "foreign_instance" ? "foreign-source" : "repair-source"}\n`,
|
||||
);
|
||||
const diagnosticPath = variant === "instance_mismatch"
|
||||
? fixture.sourceConfigPath
|
||||
: variant === "symlink"
|
||||
? path.join(fixture.baseCwd, "source-alias.json")
|
||||
: attackerConfig;
|
||||
if (variant === "symlink") fs.symlinkSync(fixture.sourceConfigPath, diagnosticPath);
|
||||
fs.writeFileSync(path.join(fixture.configDir, "seed-manifest.json"), JSON.stringify({
|
||||
version: 2,
|
||||
source: {
|
||||
instanceId: variant === "foreign_instance" || variant === "instance_mismatch"
|
||||
? "foreign-source"
|
||||
: "repair-source",
|
||||
configPath: diagnosticPath,
|
||||
},
|
||||
targetInstanceId: fixture.targetInstanceId,
|
||||
state: "failed",
|
||||
attemptId: "attacker-controlled",
|
||||
}));
|
||||
|
||||
const res = await request(await createApp())
|
||||
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body).toMatchObject({
|
||||
code: "workspace_repair_precondition_failed",
|
||||
reason: "source_registration_invalid",
|
||||
repairPhase: "precondition_validation",
|
||||
});
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
expect(mockStopRuntimeServicesForExecutionWorkspace).not.toHaveBeenCalled();
|
||||
expect(mockWorkspaceOperationService.createRecorder).not.toHaveBeenCalled();
|
||||
expect(mockWorkspaceOperationService.assertRuntimeControlAvailable).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(attackerDir, { recursive: true, force: true });
|
||||
removeRegisteredRepairFixture(fixture);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects repair when the execution workspace has no registered base workspace", async () => {
|
||||
const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-route-repair-no-source-"));
|
||||
try {
|
||||
const configDir = path.join(workspaceCwd, ".paperclip");
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, "config.json"), "{}\n");
|
||||
fs.writeFileSync(path.join(configDir, "seed-manifest.json"), JSON.stringify({
|
||||
version: 2,
|
||||
source: { instanceId: "source", configPath: path.join(workspaceCwd, "missing", "config.json") },
|
||||
state: "failed",
|
||||
attemptId: "previous",
|
||||
}));
|
||||
mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ cwd: workspaceCwd }));
|
||||
|
||||
const res = await request(await createApp())
|
||||
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body).toMatchObject({
|
||||
code: "workspace_repair_precondition_failed",
|
||||
reason: "source_registration_invalid",
|
||||
repairPhase: "precondition_validation",
|
||||
});
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
expect(mockStopRuntimeServicesForExecutionWorkspace).not.toHaveBeenCalled();
|
||||
expect(mockWorkspaceOperationService.assertRuntimeControlAvailable).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(workspaceCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects repair when the registered base workspace has no runnable Paperclip CLI", async () => {
|
||||
const fixture = createRegisteredRepairFixture("paperclip-route-repair-no-cli-", { withCli: false });
|
||||
try {
|
||||
const res = await request(await createApp())
|
||||
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body).toMatchObject({
|
||||
code: "workspace_repair_precondition_failed",
|
||||
reason: "source_registration_invalid",
|
||||
repairPhase: "precondition_validation",
|
||||
});
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
expect(mockStopRuntimeServicesForExecutionWorkspace).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
removeRegisteredRepairFixture(fixture);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 422 with a stable reason when the verified manifest names another instance", async () => {
|
||||
const fixture = createRegisteredRepairFixture("paperclip-route-repair-wrong-instance-");
|
||||
try {
|
||||
mockVerifiedReseed(fixture, "another-workspace-instance");
|
||||
|
||||
const res = await request(await createApp())
|
||||
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body).toMatchObject({
|
||||
code: "workspace_repair_precondition_failed",
|
||||
reason: "seed_manifest_instance_mismatch",
|
||||
repairPhase: "full_reseed",
|
||||
});
|
||||
expect(mockStartRuntimeServices).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
removeRegisteredRepairFixture(fixture);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the recorded instance pointer consistently for readiness, handoff, and repair", async () => {
|
||||
const recordedInstanceId = "recorded-repair-instance";
|
||||
const fixture = createRegisteredRepairFixture("paperclip-route-repair-success-", {
|
||||
targetInstanceId: recordedInstanceId,
|
||||
});
|
||||
try {
|
||||
const { deriveWorktreeInstanceId } = await import("../services/workspace-instance-cleanup.js");
|
||||
const {
|
||||
resolveManagedWorkspaceIdentity,
|
||||
resolveManagedWorkspaceInstanceId,
|
||||
} = await import("../services/managed-workspace-identity.js");
|
||||
expect(deriveWorktreeInstanceId(fixture.workspaceCwd)).not.toBe(recordedInstanceId);
|
||||
expect(resolveManagedWorkspaceInstanceId(fixture.workspaceCwd)).toBe(recordedInstanceId);
|
||||
expect(resolveManagedWorkspaceIdentity({
|
||||
workspaceCwd: fixture.workspaceCwd,
|
||||
executionWorkspaceId,
|
||||
companyId: "company-1",
|
||||
env: { PAPERCLIP_WORKSPACE_HANDOFF_SECRET: "test-root-secret" },
|
||||
})?.instanceId).toBe(recordedInstanceId);
|
||||
mockEnsurePersistedExecutionWorkspaceAvailable.mockResolvedValue({ cwd: fixture.workspaceCwd });
|
||||
mockStartRuntimeServices.mockResolvedValue([{
|
||||
id: "service-1",
|
||||
status: "running",
|
||||
healthStatus: "healthy",
|
||||
}]);
|
||||
mockSpawn.mockImplementation((_command: string, args: string[], options: { env?: NodeJS.ProcessEnv }) => {
|
||||
expect(args).toEqual(expect.arrayContaining([
|
||||
"worktree", "reseed", "--from-config", fixture.sourceConfigPath,
|
||||
"--seed-mode", "full", "--yes", "--backup-target",
|
||||
]));
|
||||
expect(options.env).toMatchObject({
|
||||
PAPERCLIP_SEED_EXPECTED_COMPANY_ID: "company-1",
|
||||
PAPERCLIP_WORKSPACE_BASE_CWD: fixture.baseCwd,
|
||||
PAPERCLIP_PROJECT_WORKSPACE_ID: projectWorkspaceId,
|
||||
});
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = vi.fn();
|
||||
queueMicrotask(() => {
|
||||
fs.writeFileSync(path.join(fixture.configDir, "seed-manifest.json"), JSON.stringify({
|
||||
version: 2,
|
||||
source: { instanceId: "repair-source", configPath: fixture.sourceConfigPath },
|
||||
snapshotAt: "2026-08-18T00:00:00.000Z",
|
||||
seedMode: "full",
|
||||
migrationRevision: "0142_test.sql",
|
||||
targetInstanceId: recordedInstanceId,
|
||||
phase: "complete",
|
||||
state: "verified",
|
||||
attemptId: "repair-attempt",
|
||||
startedAt: "2026-08-18T00:00:00.000Z",
|
||||
finishedAt: "2026-08-18T00:01:00.000Z",
|
||||
diagnostics: [{ phase: "complete", status: "succeeded", at: "2026-08-18T00:01:00.000Z" }],
|
||||
}));
|
||||
child.emit("exit", 0);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockStopRuntimeServicesForExecutionWorkspace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ executionWorkspaceId, workspaceCwd: fixture.workspaceCwd }),
|
||||
);
|
||||
expect(mockStartRuntimeServices).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ action: "execution_workspace.runtime_repair" }),
|
||||
);
|
||||
} finally {
|
||||
removeRegisteredRepairFixture(fixture);
|
||||
}
|
||||
});
|
||||
|
||||
it("completes database-only repair when no managed runtime service is configured", async () => {
|
||||
const fixture = createRegisteredRepairFixture("paperclip-route-repair-db-only-");
|
||||
try {
|
||||
mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({
|
||||
cwd: fixture.workspaceCwd,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
config: { desiredState: "stopped" },
|
||||
}));
|
||||
mockListConfiguredRuntimeServiceEntries.mockReturnValue([]);
|
||||
mockVerifiedReseed(fixture);
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockStopRuntimeServicesForExecutionWorkspace).toHaveBeenCalledTimes(1);
|
||||
expect(mockStartRuntimeServices).not.toHaveBeenCalled();
|
||||
expect(mockBuildWorkspaceRuntimeDesiredStatePatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: "stop" }),
|
||||
);
|
||||
} finally {
|
||||
removeRegisteredRepairFixture(fixture);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails repair at the exact seed phase and leaves managed services stopped", async () => {
|
||||
const fixture = createRegisteredRepairFixture("paperclip-route-repair-failure-");
|
||||
try {
|
||||
mockEnsurePersistedExecutionWorkspaceAvailable.mockResolvedValue({ cwd: fixture.workspaceCwd });
|
||||
mockSpawn.mockImplementation(() => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = vi.fn();
|
||||
queueMicrotask(() => {
|
||||
fs.writeFileSync(path.join(fixture.configDir, "seed-manifest.json"), JSON.stringify({
|
||||
version: 2,
|
||||
source: { instanceId: "repair-source", configPath: fixture.sourceConfigPath },
|
||||
targetInstanceId: fixture.targetInstanceId,
|
||||
state: "failed",
|
||||
phase: "restore",
|
||||
attemptId: "repair-attempt",
|
||||
}));
|
||||
child.emit("exit", 4);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
|
||||
const progress: Array<Record<string, unknown>> = [];
|
||||
mockWorkspaceOperationService.createRecorder.mockReturnValue({
|
||||
attachExecutionWorkspaceId: vi.fn(),
|
||||
recordOperation: async (input: any) => {
|
||||
await input.run(async (entry: Record<string, unknown>) => {
|
||||
progress.push(entry);
|
||||
});
|
||||
},
|
||||
});
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toEqual({ error: "Internal server error" });
|
||||
expect(progress).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ metadata: { seedFailurePhase: "restore" } }),
|
||||
]));
|
||||
expect(mockStartRuntimeServices).not.toHaveBeenCalled();
|
||||
expect(mockStopRuntimeServicesForExecutionWorkspace).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
removeRegisteredRepairFixture(fixture);
|
||||
}
|
||||
});
|
||||
|
||||
it("reconciles once when the operation's own time budget fails a start that never settles", async () => {
|
||||
const { WorkspaceOperationTimeoutError } = await import("../services/workspace-operations.js");
|
||||
// The recorder's ceiling fires outside `run`, so only the outer handler can reconcile.
|
||||
|
||||
@@ -19,6 +19,7 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({
|
||||
const mockWorkspaceOperationService = vi.hoisted(() => ({
|
||||
listForExecutionWorkspace: vi.fn(),
|
||||
createRecorder: vi.fn(),
|
||||
assertRuntimeControlAvailable: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const mockWorkspaceRuntimeLeaseService = vi.hoisted(() => ({
|
||||
@@ -47,7 +48,7 @@ vi.mock("../services/index.js", () => ({
|
||||
logActivity: mockLogActivity,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
workspaceRuntimeLeaseService: () => mockWorkspaceRuntimeLeaseService,
|
||||
LEASED_WORKSPACE_RUNTIME_ACTIONS: ["start", "stop", "restart"],
|
||||
LEASED_WORKSPACE_RUNTIME_ACTIONS: ["start", "stop", "restart", "repair"],
|
||||
}));
|
||||
|
||||
vi.mock("../services/environment-runtime.js", () => ({
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { healthRoutes } from "../routes/health.js";
|
||||
import {
|
||||
WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY,
|
||||
WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY,
|
||||
WORKSPACE_HANDOFF_KEY_ENV_KEY,
|
||||
WORKSPACE_READINESS_TOKEN_ENV_KEY,
|
||||
WORKSPACE_READINESS_TOKEN_HEADER,
|
||||
WORKSPACE_READINESS_USER_EMAIL_HEADER,
|
||||
WORKSPACE_READINESS_USER_ID_HEADER,
|
||||
} from "../auth/workspace-login-handoff.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const envKeys = [
|
||||
"PAPERCLIP_CONFIG",
|
||||
WORKSPACE_HANDOFF_KEY_ENV_KEY,
|
||||
WORKSPACE_READINESS_TOKEN_ENV_KEY,
|
||||
WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY,
|
||||
WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY,
|
||||
] as const;
|
||||
const previousEnv = new Map<string, string | undefined>();
|
||||
|
||||
function setEnv(values: Record<string, string>) {
|
||||
for (const key of envKeys) {
|
||||
if (!previousEnv.has(key)) previousEnv.set(key, process.env[key]);
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) process.env[key] = value;
|
||||
}
|
||||
|
||||
function createSeededWorkspace() {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "paperclip-health-workspace-"));
|
||||
tempDirs.push(dir);
|
||||
writeFileSync(path.join(dir, "config.json"), "{}\n", "utf8");
|
||||
writeFileSync(
|
||||
path.join(dir, "seed-manifest.json"),
|
||||
JSON.stringify({ version: 2, state: "verified", phase: "complete", seedMode: "minimal" }),
|
||||
"utf8",
|
||||
);
|
||||
return path.join(dir, "config.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Health's own queries (`instance_user_roles` / `invites` counts) and the
|
||||
* readiness probe's queries both come through `select`; a count projection
|
||||
* answers 1 and a row projection answers one row.
|
||||
*/
|
||||
function stubDb() {
|
||||
return {
|
||||
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
|
||||
select: vi.fn((projection?: Record<string, unknown>) => {
|
||||
const rows = projection && "count" in projection ? [{ count: 1 }] : [{ companyId: "company-1" }];
|
||||
const chain: Record<string, unknown> = {};
|
||||
for (const method of ["from", "where", "innerJoin", "limit", "orderBy"]) {
|
||||
chain[method] = vi.fn(() => chain);
|
||||
}
|
||||
chain.then = (resolve: (value: unknown) => unknown) => Promise.resolve(rows).then(resolve);
|
||||
return chain;
|
||||
}),
|
||||
} as unknown as Db;
|
||||
}
|
||||
|
||||
function createApp(actorType: "board" | "none") {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
(req as express.Request & { actor: { type: string } }).actor = { type: actorType };
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
"/api/health",
|
||||
healthRoutes(stubDb(), {
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
authReady: true,
|
||||
companyDeletionEnabled: true,
|
||||
}),
|
||||
);
|
||||
return app;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of previousEnv) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
previousEnv.clear();
|
||||
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/health workspace readiness", () => {
|
||||
it("exposes readiness to a board actor of a managed workspace", async () => {
|
||||
setEnv({
|
||||
PAPERCLIP_CONFIG: createSeededWorkspace(),
|
||||
[WORKSPACE_HANDOFF_KEY_ENV_KEY]: "handoff-key",
|
||||
[WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY]: "ews-1",
|
||||
[WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY]: "company-1",
|
||||
});
|
||||
const response = await request(createApp("board")).get("/api/health").expect(200);
|
||||
expect(response.body.workspace).toMatchObject({
|
||||
state: "ready",
|
||||
databaseReady: true,
|
||||
cloneDataReady: true,
|
||||
authHandoffReady: true,
|
||||
seedState: "verified",
|
||||
executionWorkspaceId: "ews-1",
|
||||
companyId: "company-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps public health redacted for an anonymous caller", async () => {
|
||||
setEnv({
|
||||
PAPERCLIP_CONFIG: createSeededWorkspace(),
|
||||
[WORKSPACE_HANDOFF_KEY_ENV_KEY]: "handoff-key",
|
||||
[WORKSPACE_READINESS_TOKEN_ENV_KEY]: "probe-token",
|
||||
[WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY]: "ews-1",
|
||||
[WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY]: "company-1",
|
||||
});
|
||||
const response = await request(createApp("none")).get("/api/health").expect(200);
|
||||
expect(response.body.status).toBe("ok");
|
||||
expect(response.body.workspace).toBeUndefined();
|
||||
expect(response.body.serverInfo).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts the derived probe token on an otherwise redacted response", async () => {
|
||||
setEnv({
|
||||
PAPERCLIP_CONFIG: createSeededWorkspace(),
|
||||
[WORKSPACE_HANDOFF_KEY_ENV_KEY]: "handoff-key",
|
||||
[WORKSPACE_READINESS_TOKEN_ENV_KEY]: "probe-token",
|
||||
[WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY]: "ews-1",
|
||||
[WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY]: "company-1",
|
||||
});
|
||||
const response = await request(createApp("none"))
|
||||
.get("/api/health")
|
||||
.set(WORKSPACE_READINESS_TOKEN_HEADER, "probe-token")
|
||||
.set(WORKSPACE_READINESS_USER_ID_HEADER, "user-1")
|
||||
.set(WORKSPACE_READINESS_USER_EMAIL_HEADER, "operator@example.com")
|
||||
.expect(200);
|
||||
expect(response.body.workspace).toMatchObject({
|
||||
state: "ready",
|
||||
instanceId: expect.any(String),
|
||||
authHandoffUserId: "user-1",
|
||||
});
|
||||
// Still redacted apart from readiness: the token buys one contract, not full detail.
|
||||
expect(response.body.serverInfo).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a wrong probe token without leaking readiness", async () => {
|
||||
setEnv({
|
||||
PAPERCLIP_CONFIG: createSeededWorkspace(),
|
||||
[WORKSPACE_HANDOFF_KEY_ENV_KEY]: "handoff-key",
|
||||
[WORKSPACE_READINESS_TOKEN_ENV_KEY]: "probe-token",
|
||||
[WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY]: "ews-1",
|
||||
[WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY]: "company-1",
|
||||
});
|
||||
const response = await request(createApp("none"))
|
||||
.get("/api/health")
|
||||
.set(WORKSPACE_READINESS_TOKEN_HEADER, "probe-token-but-wrong")
|
||||
.expect(200);
|
||||
expect(response.body.workspace).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits readiness entirely on an instance that is not a cloned workspace", async () => {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "paperclip-health-primary-"));
|
||||
tempDirs.push(dir);
|
||||
writeFileSync(path.join(dir, "config.json"), "{}\n", "utf8");
|
||||
setEnv({ PAPERCLIP_CONFIG: path.join(dir, "config.json") });
|
||||
delete process.env[WORKSPACE_HANDOFF_KEY_ENV_KEY];
|
||||
delete process.env[WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY];
|
||||
const response = await request(createApp("board")).get("/api/health").expect(200);
|
||||
expect(response.body.workspace).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { IssueWorkProduct } from "@paperclipai/shared";
|
||||
import { reconcileRuntimeServiceWorkProducts } from "../services/work-products.js";
|
||||
|
||||
function workProduct(overrides: Partial<IssueWorkProduct> = {}): IssueWorkProduct {
|
||||
return {
|
||||
id: "wp-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
issueId: "issue-1",
|
||||
executionWorkspaceId: "ews-1",
|
||||
runtimeServiceId: "runtime-1",
|
||||
type: "runtime_service",
|
||||
provider: "paperclip",
|
||||
externalId: null,
|
||||
title: "Workspace preview",
|
||||
url: "https://workspace.example.ts.net:42013/",
|
||||
status: "open",
|
||||
reviewState: "none",
|
||||
isPrimary: true,
|
||||
healthStatus: "healthy",
|
||||
summary: null,
|
||||
metadata: null,
|
||||
sourceTrust: null,
|
||||
createdByRunId: null,
|
||||
createdAt: new Date("2026-08-19T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-08-19T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("reconcileRuntimeServiceWorkProducts", () => {
|
||||
it("adopts the live runtime URL after a port change", () => {
|
||||
const [reconciled] = reconcileRuntimeServiceWorkProducts(
|
||||
[workProduct()],
|
||||
[{ id: "runtime-1", url: "https://workspace.example.ts.net:42055/", status: "running", healthStatus: "healthy" }],
|
||||
);
|
||||
expect(reconciled!.url).toBe("https://workspace.example.ts.net:42055/");
|
||||
expect(reconciled!.healthStatus).toBe("healthy");
|
||||
});
|
||||
|
||||
it("marks a stopped or unhealthy runtime unhealthy instead of advertising it", () => {
|
||||
const [stopped] = reconcileRuntimeServiceWorkProducts(
|
||||
[workProduct()],
|
||||
[{ id: "runtime-1", url: "https://workspace.example.ts.net:42013/", status: "stopped", healthStatus: "healthy" }],
|
||||
);
|
||||
expect(stopped!.healthStatus).toBe("unhealthy");
|
||||
|
||||
const [unhealthy] = reconcileRuntimeServiceWorkProducts(
|
||||
[workProduct()],
|
||||
[{ id: "runtime-1", url: "https://workspace.example.ts.net:42013/", status: "running", healthStatus: "unhealthy" }],
|
||||
);
|
||||
expect(unhealthy!.healthStatus).toBe("unhealthy");
|
||||
});
|
||||
|
||||
it("keeps the recorded URL when the runtime row is gone, but stops calling it healthy", () => {
|
||||
const [reconciled] = reconcileRuntimeServiceWorkProducts([workProduct()], []);
|
||||
expect(reconciled!.url).toBe("https://workspace.example.ts.net:42013/");
|
||||
expect(reconciled!.healthStatus).toBe("unhealthy");
|
||||
});
|
||||
|
||||
it("leaves non-runtime work products and unlinked rows untouched", () => {
|
||||
const pullRequest = workProduct({ id: "wp-pr", type: "pull_request", runtimeServiceId: null });
|
||||
const unlinked = workProduct({ id: "wp-unlinked", runtimeServiceId: null });
|
||||
const reconciled = reconcileRuntimeServiceWorkProducts([pullRequest, unlinked], []);
|
||||
expect(reconciled[0]).toBe(pullRequest);
|
||||
expect(reconciled[1]).toBe(unlinked);
|
||||
});
|
||||
|
||||
it("returns the same objects when nothing drifted", () => {
|
||||
const product = workProduct();
|
||||
const reconciled = reconcileRuntimeServiceWorkProducts(
|
||||
[product],
|
||||
[{ id: "runtime-1", url: product.url, status: "running", healthStatus: "healthy" }],
|
||||
);
|
||||
expect(reconciled[0]).toBe(product);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* HTTP-level coverage for the Better Auth exchange endpoint (PAP-17572).
|
||||
*
|
||||
* The security matrix itself lives in `workspace-login-handoff.test.ts`, which
|
||||
* exercises the pure verifier and the dependency-injected exchange. This suite
|
||||
* proves the wiring a browser actually meets: the plugin is registered on the
|
||||
* Better Auth mount, a valid ticket answers with a redirect and a session cookie,
|
||||
* a rejected ticket redirects to the labeled credential fallback without one, and
|
||||
* the exchange never trusts a forwarded host header.
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { memoryAdapter } from "better-auth/adapters/memory";
|
||||
import { toNodeHandler } from "better-auth/node";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
deriveWorkspaceHandoffKey,
|
||||
issueWorkspaceHandoffTicket,
|
||||
WORKSPACE_HANDOFF_TICKET_QUERY_PARAM,
|
||||
} from "../auth/workspace-login-handoff.js";
|
||||
import { workspaceLoginHandoffPlugin } from "../auth/workspace-login-handoff-plugin.js";
|
||||
|
||||
const ROOT_SECRET = "endpoint-test-root-secret";
|
||||
const INSTANCE_ID = "pap-17572-endpoint-1122334455";
|
||||
const EXECUTION_WORKSPACE_ID = "ews-endpoint-1";
|
||||
const COMPANY_ID = "company-endpoint-1";
|
||||
const ORIGIN = "http://workspace.localhost:42013";
|
||||
const USER_ID = "cloned-user-1";
|
||||
const USER_EMAIL = "operator@example.com";
|
||||
|
||||
const KEY = deriveWorkspaceHandoffKey({
|
||||
rootSecret: ROOT_SECRET,
|
||||
instanceId: INSTANCE_ID,
|
||||
executionWorkspaceId: EXECUTION_WORKSPACE_ID,
|
||||
});
|
||||
|
||||
type MemoryStore = Record<string, Record<string, unknown>[]>;
|
||||
|
||||
/**
|
||||
* Membership lookup goes through the app `db`, not the Better Auth adapter, and
|
||||
* asks for existence — so the stub answers with rows, not a count.
|
||||
*/
|
||||
function stubDb(activeMembershipCount: number) {
|
||||
const rows = Array.from({ length: Math.max(0, activeMembershipCount) }, (_, index) => ({
|
||||
id: `membership-${index}`,
|
||||
}));
|
||||
return {
|
||||
select: vi.fn(() => {
|
||||
const chain: Record<string, unknown> = {};
|
||||
for (const method of ["from", "where", "innerJoin", "limit", "orderBy"]) {
|
||||
chain[method] = vi.fn(() => chain);
|
||||
}
|
||||
chain.then = (resolve: (value: unknown) => unknown) => Promise.resolve(rows).then(resolve);
|
||||
return chain;
|
||||
}),
|
||||
} as unknown as Db;
|
||||
}
|
||||
|
||||
function createApp(input: {
|
||||
activeMembershipCount?: number;
|
||||
expectedOrigin?: string | null;
|
||||
expectedInstanceId?: string | null;
|
||||
expectedExecutionWorkspaceId?: string | null;
|
||||
expectedCompanyId?: string | null;
|
||||
key?: string | null;
|
||||
}) {
|
||||
const store: MemoryStore = { user: [], session: [], account: [], verification: [] };
|
||||
store.user!.push({
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
name: "Operator",
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const auth = betterAuth({
|
||||
secret: "better-auth-secret-for-endpoint-tests",
|
||||
baseURL: ORIGIN,
|
||||
trustedOrigins: [ORIGIN],
|
||||
database: memoryAdapter(store),
|
||||
emailAndPassword: { enabled: true },
|
||||
advanced: { useSecureCookies: false },
|
||||
plugins: [
|
||||
workspaceLoginHandoffPlugin({
|
||||
db: stubDb(input.activeMembershipCount ?? 1),
|
||||
resolveExpectedIdentity: () => ({
|
||||
key: input.key === undefined ? KEY : input.key,
|
||||
instanceId: input.expectedInstanceId === undefined ? INSTANCE_ID : input.expectedInstanceId,
|
||||
executionWorkspaceId:
|
||||
input.expectedExecutionWorkspaceId === undefined
|
||||
? EXECUTION_WORKSPACE_ID
|
||||
: input.expectedExecutionWorkspaceId,
|
||||
companyId: input.expectedCompanyId === undefined ? COMPANY_ID : input.expectedCompanyId,
|
||||
origin: input.expectedOrigin === undefined ? ORIGIN : input.expectedOrigin,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.all("/api/auth/*splat", (req, res, next) => {
|
||||
void Promise.resolve(toNodeHandler(auth)(req, res)).catch(next);
|
||||
});
|
||||
return { app, store };
|
||||
}
|
||||
|
||||
function mintTicket(overrides: Partial<Parameters<typeof issueWorkspaceHandoffTicket>[0]> = {}) {
|
||||
return issueWorkspaceHandoffTicket({
|
||||
key: KEY,
|
||||
userId: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
executionWorkspaceId: EXECUTION_WORKSPACE_ID,
|
||||
companyId: COMPANY_ID,
|
||||
instanceId: INSTANCE_ID,
|
||||
origin: ORIGIN,
|
||||
issuerInstanceId: "primary",
|
||||
...overrides,
|
||||
}).ticket;
|
||||
}
|
||||
|
||||
function exchange(app: express.Express, ticket: string | null) {
|
||||
const path = `/api/auth/workspace-handoff/exchange${
|
||||
ticket === null ? "" : `?${WORKSPACE_HANDOFF_TICKET_QUERY_PARAM}=${encodeURIComponent(ticket)}`
|
||||
}`;
|
||||
return request(app).get(path);
|
||||
}
|
||||
|
||||
function sessionCookies(response: request.Response): string[] {
|
||||
const raw = response.headers["set-cookie"];
|
||||
const cookies = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
||||
return cookies.filter((cookie) => cookie.includes("session_token"));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/workspace-handoff/exchange", () => {
|
||||
it("redirects to the requested board path and sets exactly one session cookie", async () => {
|
||||
const { app, store } = createApp({});
|
||||
const response = await exchange(app, mintTicket({ next: "/PAP/issues/PAP-17572" }));
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.location).toBe(`${ORIGIN}/PAP/issues/PAP-17572`);
|
||||
// The ticket must not survive into the landing URL, the cache, or the referrer.
|
||||
expect(response.headers.location).not.toContain(WORKSPACE_HANDOFF_TICKET_QUERY_PARAM);
|
||||
expect(response.headers["cache-control"]).toContain("no-store");
|
||||
expect(response.headers["referrer-policy"]).toBe("no-referrer");
|
||||
expect(sessionCookies(response)).toHaveLength(1);
|
||||
expect(store.session).toHaveLength(1);
|
||||
expect(store.session![0]!.userId).toBe(USER_ID);
|
||||
});
|
||||
|
||||
it("does not create a second session for a replayed ticket", async () => {
|
||||
const { app, store } = createApp({});
|
||||
const ticket = mintTicket();
|
||||
expect((await exchange(app, ticket)).status).toBe(302);
|
||||
|
||||
const replay = await exchange(app, ticket);
|
||||
expect(replay.status).toBe(302);
|
||||
expect(replay.headers.location).toContain("workspaceHandoffError=replayed");
|
||||
expect(sessionCookies(replay)).toHaveLength(0);
|
||||
expect(store.session).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("sends a rejected ticket to the labeled credential fallback with no session", async () => {
|
||||
const { app, store } = createApp({});
|
||||
const expired = mintTicket({ now: new Date(Date.now() - 10 * 60_000), ttlSeconds: 30 });
|
||||
const response = await exchange(app, expired);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
const location = new URL(response.headers.location as string);
|
||||
expect(location.pathname).toBe("/auth");
|
||||
expect(location.searchParams.get("workspaceHandoffError")).toBe("expired");
|
||||
expect(sessionCookies(response)).toHaveLength(0);
|
||||
expect(store.session).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a ticket for a cloned user with no active membership", async () => {
|
||||
const { app, store } = createApp({ activeMembershipCount: 0 });
|
||||
const response = await exchange(app, mintTicket());
|
||||
expect(response.headers.location).toContain("workspaceHandoffError=missing_membership");
|
||||
expect(store.session).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a ticket naming a user that did not survive the clone", async () => {
|
||||
const { app, store } = createApp({});
|
||||
const response = await exchange(app, mintTicket({ userId: "user-that-was-not-cloned" }));
|
||||
expect(response.headers.location).toContain("workspaceHandoffError=unknown_user");
|
||||
expect(store.session).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores forwarded host and proto headers when checking the audience", async () => {
|
||||
const { app, store } = createApp({});
|
||||
// A ticket minted for the attacker's host must fail even when the request
|
||||
// arrives claiming to be that host.
|
||||
const spoofedTicket = mintTicket({ origin: "https://attacker.example.com" });
|
||||
const response = await exchange(app, spoofedTicket)
|
||||
.set("host", "attacker.example.com")
|
||||
.set("x-forwarded-host", "attacker.example.com")
|
||||
.set("x-forwarded-proto", "https")
|
||||
.set("x-forwarded-for", "203.0.113.7");
|
||||
|
||||
expect(response.headers.location).toContain("workspaceHandoffError=origin_mismatch");
|
||||
expect(sessionCookies(response)).toHaveLength(0);
|
||||
expect(store.session).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a ticket bound to another workspace, instance, or company on this host", async () => {
|
||||
const { app } = createApp({});
|
||||
const otherWorkspace = await exchange(app, mintTicket({ executionWorkspaceId: "ews-sibling" }));
|
||||
expect(otherWorkspace.headers.location).toContain("workspaceHandoffError=workspace_mismatch");
|
||||
|
||||
const otherInstance = await exchange(app, mintTicket({ instanceId: "another-instance" }));
|
||||
expect(otherInstance.headers.location).toContain("workspaceHandoffError=instance_mismatch");
|
||||
|
||||
const otherCompany = await exchange(app, mintTicket({ companyId: "company-sibling" }));
|
||||
expect(otherCompany.headers.location).toContain("workspaceHandoffError=company_mismatch");
|
||||
});
|
||||
|
||||
it("rejects a missing or garbage ticket", async () => {
|
||||
const { app } = createApp({});
|
||||
expect((await exchange(app, null)).headers.location).toContain("workspaceHandoffError=malformed");
|
||||
expect((await exchange(app, "wh1.aaaa.bbbb")).headers.location).toContain(
|
||||
"workspaceHandoffError=bad_signature",
|
||||
);
|
||||
});
|
||||
|
||||
it("answers 503 rather than a redirect when the guest has no verification key", async () => {
|
||||
const { app } = createApp({ key: null });
|
||||
const response = await exchange(app, mintTicket());
|
||||
expect(response.status).toBe(503);
|
||||
expect(sessionCookies(response)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fails closed when the guest cannot resolve its own origin", async () => {
|
||||
const { app, store } = createApp({ expectedOrigin: null });
|
||||
const response = await exchange(app, mintTicket());
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.location).toContain("workspaceHandoffError=origin_mismatch");
|
||||
expect(store.session).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
issueWorkspaceLoginHandoff,
|
||||
resolveWorkspaceHandoffBoardIdentity,
|
||||
workspaceLoginHandoffFailureStatus,
|
||||
} from "../services/workspace-login-handoff-issuer.js";
|
||||
import { verifyWorkspaceHandoffTicket } from "../auth/workspace-login-handoff.js";
|
||||
import { resolveManagedWorkspaceIdentity } from "../services/managed-workspace-identity.js";
|
||||
import { WORKSPACE_HANDOFF_TICKET_QUERY_PARAM } from "../auth/workspace-login-handoff.js";
|
||||
|
||||
const INSTANCE_ID = "pap-17572-live-workspace-aa11bb22cc33";
|
||||
const EXECUTION_WORKSPACE_ID = "ews-live-1";
|
||||
const COMPANY_ID = "company-1";
|
||||
const RUNTIME_URL = "https://workspace.example.ts.net:42013/";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
let previousSecret: string | undefined;
|
||||
|
||||
function createWorkspaceCwd(instanceId: string | null) {
|
||||
const cwd = mkdtempSync(path.join(os.tmpdir(), "paperclip-handoff-issuer-"));
|
||||
tempDirs.push(cwd);
|
||||
mkdirSync(path.join(cwd, ".paperclip"), { recursive: true });
|
||||
if (instanceId) {
|
||||
writeFileSync(
|
||||
path.join(cwd, ".paperclip", ".env"),
|
||||
`PAPERCLIP_HOME=/srv/home\nPAPERCLIP_INSTANCE_ID=${instanceId}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal drizzle-shaped stub. `select({ id, url, updatedAt })` is the live
|
||||
* runtime lookup; anything else is the instance-admin lookup.
|
||||
*/
|
||||
function stubDb(input: {
|
||||
runtimeRows?: Array<{ id: string; url: string | null; updatedAt: Date }>;
|
||||
adminRows?: Array<{ id: string; email: string | null; createdAt: Date }>;
|
||||
}) {
|
||||
return {
|
||||
select: vi.fn((projection: Record<string, unknown>) => {
|
||||
const rows = "url" in projection ? input.runtimeRows ?? [] : input.adminRows ?? [];
|
||||
const chain: Record<string, unknown> = {};
|
||||
for (const method of ["from", "where", "innerJoin", "orderBy", "limit"]) {
|
||||
chain[method] = vi.fn(() => chain);
|
||||
}
|
||||
chain.then = (resolve: (value: unknown) => unknown) => Promise.resolve(rows).then(resolve);
|
||||
return chain;
|
||||
}),
|
||||
} as unknown as Db;
|
||||
}
|
||||
|
||||
function readyProbe() {
|
||||
return vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
readiness: {
|
||||
state: "ready" as const,
|
||||
databaseReady: true,
|
||||
cloneDataReady: true,
|
||||
authHandoffReady: true,
|
||||
authHandoffUserId: "user-1",
|
||||
seedState: "verified" as const,
|
||||
seedPhase: "complete",
|
||||
seedMode: "minimal" as const,
|
||||
instanceId: INSTANCE_ID,
|
||||
executionWorkspaceId: EXECUTION_WORKSPACE_ID,
|
||||
companyId: COMPANY_ID,
|
||||
failurePhase: null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
previousSecret = process.env.PAPERCLIP_WORKSPACE_HANDOFF_SECRET;
|
||||
process.env.PAPERCLIP_WORKSPACE_HANDOFF_SECRET = "issuer-test-root-secret";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousSecret === undefined) delete process.env.PAPERCLIP_WORKSPACE_HANDOFF_SECRET;
|
||||
else process.env.PAPERCLIP_WORKSPACE_HANDOFF_SECRET = previousSecret;
|
||||
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("issueWorkspaceLoginHandoff", () => {
|
||||
it("mints a ticket the target workspace accepts, bound to the live runtime origin", async () => {
|
||||
const cwd = createWorkspaceCwd(INSTANCE_ID);
|
||||
const probe = readyProbe();
|
||||
const result = await issueWorkspaceLoginHandoff({
|
||||
db: stubDb({ runtimeRows: [{ id: "runtime-1", url: RUNTIME_URL, updatedAt: new Date() }] }),
|
||||
companyId: COMPANY_ID,
|
||||
executionWorkspace: { id: EXECUTION_WORKSPACE_ID, cwd },
|
||||
actor: { userId: "user-1", userEmail: "operator@example.com", source: "session" },
|
||||
next: "/PAP/issues/PAP-17572",
|
||||
probe,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
const url = new URL(result.issuance.url);
|
||||
expect(url.origin).toBe("https://workspace.example.ts.net:42013");
|
||||
expect(url.pathname).toBe("/api/auth/workspace-handoff/exchange");
|
||||
|
||||
// The guest independently derives the same key, so the round trip proves the
|
||||
// control plane and the workspace agree without sharing a stored token.
|
||||
const identity = resolveManagedWorkspaceIdentity({
|
||||
workspaceCwd: cwd,
|
||||
executionWorkspaceId: EXECUTION_WORKSPACE_ID,
|
||||
companyId: COMPANY_ID,
|
||||
});
|
||||
const verification = verifyWorkspaceHandoffTicket({
|
||||
ticket: url.searchParams.get(WORKSPACE_HANDOFF_TICKET_QUERY_PARAM),
|
||||
key: identity!.handoffKey,
|
||||
expected: {
|
||||
instanceId: INSTANCE_ID,
|
||||
executionWorkspaceId: EXECUTION_WORKSPACE_ID,
|
||||
companyId: COMPANY_ID,
|
||||
origin: "https://workspace.example.ts.net:42013",
|
||||
},
|
||||
});
|
||||
expect(verification.ok).toBe(true);
|
||||
expect(verification.ok && verification.payload).toMatchObject({
|
||||
sub: "user-1",
|
||||
email: "operator@example.com",
|
||||
next: "/PAP/issues/PAP-17572",
|
||||
});
|
||||
// Readiness is probed against the origin the user will be sent to, not a
|
||||
// separately configured address.
|
||||
expect(probe).toHaveBeenCalledTimes(1);
|
||||
expect(probe.mock.calls[0]![0]!.healthUrl).toBe("https://workspace.example.ts.net:42013/api/health");
|
||||
expect(probe.mock.calls[0]![0]!.handoffSubject).toEqual({
|
||||
userId: "user-1",
|
||||
email: "operator@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to mint a ticket for a workspace that is not ready", async () => {
|
||||
const cwd = createWorkspaceCwd(INSTANCE_ID);
|
||||
const result = await issueWorkspaceLoginHandoff({
|
||||
db: stubDb({ runtimeRows: [{ id: "runtime-1", url: RUNTIME_URL, updatedAt: new Date() }] }),
|
||||
companyId: COMPANY_ID,
|
||||
executionWorkspace: { id: EXECUTION_WORKSPACE_ID, cwd },
|
||||
actor: { userId: "user-1", userEmail: "operator@example.com", source: "session" },
|
||||
probe: vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
reason: "not_ready" as const,
|
||||
readiness: null,
|
||||
detail: "clone_data_missing",
|
||||
})),
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
failure: { reason: "workspace_not_ready", detail: "clone_data_missing" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses when no healthy runtime row publishes a URL", async () => {
|
||||
const cwd = createWorkspaceCwd(INSTANCE_ID);
|
||||
const result = await issueWorkspaceLoginHandoff({
|
||||
db: stubDb({ runtimeRows: [] }),
|
||||
companyId: COMPANY_ID,
|
||||
executionWorkspace: { id: EXECUTION_WORKSPACE_ID, cwd },
|
||||
actor: { userId: "user-1", userEmail: "operator@example.com", source: "session" },
|
||||
probe: readyProbe(),
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, failure: { reason: "runtime_not_running" } });
|
||||
});
|
||||
|
||||
it("refuses a runtime row whose URL is not an absolute http(s) origin", async () => {
|
||||
const cwd = createWorkspaceCwd(INSTANCE_ID);
|
||||
const result = await issueWorkspaceLoginHandoff({
|
||||
db: stubDb({ runtimeRows: [{ id: "runtime-1", url: "not-a-url", updatedAt: new Date() }] }),
|
||||
companyId: COMPANY_ID,
|
||||
executionWorkspace: { id: EXECUTION_WORKSPACE_ID, cwd },
|
||||
actor: { userId: "user-1", userEmail: "operator@example.com", source: "session" },
|
||||
probe: readyProbe(),
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, failure: { reason: "runtime_url_unusable" } });
|
||||
});
|
||||
|
||||
it("fails closed when the worktree instance identity cannot be resolved", async () => {
|
||||
const result = await issueWorkspaceLoginHandoff({
|
||||
db: stubDb({ runtimeRows: [{ id: "runtime-1", url: RUNTIME_URL, updatedAt: new Date() }] }),
|
||||
companyId: COMPANY_ID,
|
||||
executionWorkspace: { id: EXECUTION_WORKSPACE_ID, cwd: null },
|
||||
actor: { userId: "user-1", userEmail: "operator@example.com", source: "session" },
|
||||
probe: readyProbe(),
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, failure: { reason: "handoff_not_configured" } });
|
||||
});
|
||||
|
||||
it("falls back to credentials when no signing secret is configured at all", async () => {
|
||||
delete process.env.PAPERCLIP_WORKSPACE_HANDOFF_SECRET;
|
||||
const previousAuthSecret = process.env.BETTER_AUTH_SECRET;
|
||||
const previousJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
delete process.env.BETTER_AUTH_SECRET;
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
try {
|
||||
const cwd = createWorkspaceCwd(INSTANCE_ID);
|
||||
const result = await issueWorkspaceLoginHandoff({
|
||||
db: stubDb({ runtimeRows: [{ id: "runtime-1", url: RUNTIME_URL, updatedAt: new Date() }] }),
|
||||
companyId: COMPANY_ID,
|
||||
executionWorkspace: { id: EXECUTION_WORKSPACE_ID, cwd },
|
||||
actor: { userId: "user-1", userEmail: "operator@example.com", source: "session" },
|
||||
probe: readyProbe(),
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, failure: { reason: "handoff_not_configured" } });
|
||||
} finally {
|
||||
if (previousAuthSecret !== undefined) process.env.BETTER_AUTH_SECRET = previousAuthSecret;
|
||||
if (previousJwtSecret !== undefined) process.env.PAPERCLIP_AGENT_JWT_SECRET = previousJwtSecret;
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses an actor with no resolvable board identity", async () => {
|
||||
const cwd = createWorkspaceCwd(INSTANCE_ID);
|
||||
const result = await issueWorkspaceLoginHandoff({
|
||||
db: stubDb({ adminRows: [] }),
|
||||
companyId: COMPANY_ID,
|
||||
executionWorkspace: { id: EXECUTION_WORKSPACE_ID, cwd },
|
||||
actor: { userId: null, userEmail: null, source: "session" },
|
||||
probe: readyProbe(),
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, failure: { reason: "no_board_identity" } });
|
||||
});
|
||||
|
||||
it("normalizes a landing target that tries to leave the workspace origin", async () => {
|
||||
const cwd = createWorkspaceCwd(INSTANCE_ID);
|
||||
const result = await issueWorkspaceLoginHandoff({
|
||||
db: stubDb({ runtimeRows: [{ id: "runtime-1", url: RUNTIME_URL, updatedAt: new Date() }] }),
|
||||
companyId: COMPANY_ID,
|
||||
executionWorkspace: { id: EXECUTION_WORKSPACE_ID, cwd },
|
||||
actor: { userId: "user-1", userEmail: "operator@example.com", source: "session" },
|
||||
next: "https://attacker.example.com/steal",
|
||||
probe: readyProbe(),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
const identity = resolveManagedWorkspaceIdentity({
|
||||
workspaceCwd: cwd,
|
||||
executionWorkspaceId: EXECUTION_WORKSPACE_ID,
|
||||
companyId: COMPANY_ID,
|
||||
});
|
||||
const verification = verifyWorkspaceHandoffTicket({
|
||||
ticket: new URL(result.issuance.url).searchParams.get(WORKSPACE_HANDOFF_TICKET_QUERY_PARAM),
|
||||
key: identity!.handoffKey,
|
||||
expected: {
|
||||
instanceId: INSTANCE_ID,
|
||||
executionWorkspaceId: EXECUTION_WORKSPACE_ID,
|
||||
companyId: COMPANY_ID,
|
||||
origin: "https://workspace.example.ts.net:42013",
|
||||
},
|
||||
});
|
||||
expect(verification.ok && verification.payload.next).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorkspaceHandoffBoardIdentity", () => {
|
||||
it("uses the caller's own session identity", async () => {
|
||||
await expect(
|
||||
resolveWorkspaceHandoffBoardIdentity(stubDb({}), {
|
||||
userId: "user-9",
|
||||
userEmail: "nine@example.com",
|
||||
source: "session",
|
||||
}),
|
||||
).resolves.toEqual({ userId: "user-9", email: "nine@example.com" });
|
||||
});
|
||||
|
||||
it("stands in the instance admin for the implicit local-trusted actor", async () => {
|
||||
await expect(
|
||||
resolveWorkspaceHandoffBoardIdentity(
|
||||
stubDb({ adminRows: [{ id: "admin-1", email: "admin@example.com", createdAt: new Date(0) }] }),
|
||||
{ userId: "local-board", userEmail: null, source: "local_implicit" },
|
||||
),
|
||||
).resolves.toEqual({ userId: "admin-1", email: "admin@example.com" });
|
||||
});
|
||||
|
||||
it("returns null when a local-trusted instance has no instance admin", async () => {
|
||||
await expect(
|
||||
resolveWorkspaceHandoffBoardIdentity(stubDb({ adminRows: [] }), {
|
||||
userId: "local-board",
|
||||
userEmail: null,
|
||||
source: "local_implicit",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspaceLoginHandoffFailureStatus", () => {
|
||||
it("separates authorization, unsupported, and conflict outcomes", () => {
|
||||
expect(workspaceLoginHandoffFailureStatus({ reason: "no_board_identity" })).toBe(403);
|
||||
expect(workspaceLoginHandoffFailureStatus({ reason: "handoff_not_configured" })).toBe(501);
|
||||
expect(workspaceLoginHandoffFailureStatus({ reason: "runtime_not_running" })).toBe(409);
|
||||
expect(
|
||||
workspaceLoginHandoffFailureStatus({ reason: "workspace_not_ready", detail: null, readiness: null }),
|
||||
).toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildWorkspaceHandoffExchangeUrl,
|
||||
deriveWorkspaceHandoffKey,
|
||||
deriveWorkspaceReadinessToken,
|
||||
issueWorkspaceHandoffTicket,
|
||||
normalizeWorkspaceHandoffOrigin,
|
||||
redactWorkspaceHandoffTicket,
|
||||
resolveWorkspaceHandoffRootSecret,
|
||||
sanitizeWorkspaceHandoffRedirectPath,
|
||||
verifyWorkspaceHandoffTicket,
|
||||
WORKSPACE_HANDOFF_TICKET_VERSION,
|
||||
} from "../auth/workspace-login-handoff.js";
|
||||
import {
|
||||
exchangeWorkspaceHandoffTicket,
|
||||
isWorkspaceHandoffRepairReason,
|
||||
workspaceHandoffFailureStatus,
|
||||
type WorkspaceHandoffExchangeDeps,
|
||||
} from "../auth/workspace-login-handoff-exchange.js";
|
||||
|
||||
const ROOT_SECRET = "root-secret-for-tests";
|
||||
const INSTANCE_ID = "pap-17572-workspace-abc123def456";
|
||||
const EXECUTION_WORKSPACE_ID = "ews-1111-2222";
|
||||
const COMPANY_ID = "company-1111";
|
||||
const ORIGIN = "https://workspace.example.ts.net:42013";
|
||||
const USER_ID = "user-1";
|
||||
const USER_EMAIL = "Operator@Example.com";
|
||||
|
||||
const KEY = deriveWorkspaceHandoffKey({
|
||||
rootSecret: ROOT_SECRET,
|
||||
instanceId: INSTANCE_ID,
|
||||
executionWorkspaceId: EXECUTION_WORKSPACE_ID,
|
||||
});
|
||||
|
||||
const EXPECTED = {
|
||||
instanceId: INSTANCE_ID,
|
||||
executionWorkspaceId: EXECUTION_WORKSPACE_ID,
|
||||
companyId: COMPANY_ID,
|
||||
origin: ORIGIN,
|
||||
};
|
||||
|
||||
function mintTicket(overrides: Partial<Parameters<typeof issueWorkspaceHandoffTicket>[0]> = {}) {
|
||||
return issueWorkspaceHandoffTicket({
|
||||
key: KEY,
|
||||
userId: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
executionWorkspaceId: EXECUTION_WORKSPACE_ID,
|
||||
companyId: COMPANY_ID,
|
||||
instanceId: INSTANCE_ID,
|
||||
origin: ORIGIN,
|
||||
issuerInstanceId: "primary",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function exchangeDeps(overrides: Partial<WorkspaceHandoffExchangeDeps> = {}) {
|
||||
const reserved = new Set<string>();
|
||||
return {
|
||||
findClonedIdentity: vi.fn(async ({ userId }: { userId: string; companyId: string }) =>
|
||||
userId === USER_ID
|
||||
? { userId, email: USER_EMAIL, hasActiveMembership: true }
|
||||
: null,
|
||||
),
|
||||
reserveNonce: vi.fn(async ({ nonce }: { nonce: string }) => {
|
||||
if (reserved.has(nonce)) return false;
|
||||
reserved.add(nonce);
|
||||
return true;
|
||||
}),
|
||||
createSession: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
} satisfies WorkspaceHandoffExchangeDeps;
|
||||
}
|
||||
|
||||
describe("workspace handoff key derivation", () => {
|
||||
it("derives a distinct key per instance and per workspace", () => {
|
||||
const otherWorkspace = deriveWorkspaceHandoffKey({
|
||||
rootSecret: ROOT_SECRET,
|
||||
instanceId: INSTANCE_ID,
|
||||
executionWorkspaceId: "ews-other",
|
||||
});
|
||||
const otherInstance = deriveWorkspaceHandoffKey({
|
||||
rootSecret: ROOT_SECRET,
|
||||
instanceId: "other-instance",
|
||||
executionWorkspaceId: EXECUTION_WORKSPACE_ID,
|
||||
});
|
||||
expect(KEY).not.toBe(otherWorkspace);
|
||||
expect(KEY).not.toBe(otherInstance);
|
||||
expect(KEY).not.toContain(ROOT_SECRET);
|
||||
});
|
||||
|
||||
it("keeps the readiness probe token separate from the signing key", () => {
|
||||
const token = deriveWorkspaceReadinessToken({
|
||||
rootSecret: ROOT_SECRET,
|
||||
instanceId: INSTANCE_ID,
|
||||
executionWorkspaceId: EXECUTION_WORKSPACE_ID,
|
||||
});
|
||||
expect(token).not.toBe(KEY);
|
||||
});
|
||||
|
||||
it("prefers a dedicated secret and otherwise derives one that is not the auth secret", () => {
|
||||
expect(
|
||||
resolveWorkspaceHandoffRootSecret({ PAPERCLIP_WORKSPACE_HANDOFF_SECRET: "dedicated" }),
|
||||
).toEqual({ secret: "dedicated", source: "dedicated" });
|
||||
|
||||
const derived = resolveWorkspaceHandoffRootSecret({ BETTER_AUTH_SECRET: "auth-secret" });
|
||||
expect(derived?.source).toBe("derived");
|
||||
expect(derived?.secret).not.toBe("auth-secret");
|
||||
|
||||
expect(resolveWorkspaceHandoffRootSecret({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyWorkspaceHandoffTicket", () => {
|
||||
it("accepts a ticket bound to this workspace", () => {
|
||||
const { ticket, payload } = mintTicket({ next: "/PAP/issues/PAP-1" });
|
||||
const result = verifyWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED });
|
||||
expect(result).toEqual({ ok: true, payload });
|
||||
});
|
||||
|
||||
it("rejects a ticket signed with another workspace's key", () => {
|
||||
const foreignKey = deriveWorkspaceHandoffKey({
|
||||
rootSecret: ROOT_SECRET,
|
||||
instanceId: INSTANCE_ID,
|
||||
executionWorkspaceId: "ews-other",
|
||||
});
|
||||
const { ticket } = mintTicket({ key: foreignKey });
|
||||
expect(verifyWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED })).toEqual({
|
||||
ok: false,
|
||||
reason: "bad_signature",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a ticket minted for another origin, workspace, or instance", () => {
|
||||
const wrongOrigin = mintTicket({ origin: "https://other.example.ts.net:42099" });
|
||||
expect(verifyWorkspaceHandoffTicket({ ticket: wrongOrigin.ticket, key: KEY, expected: EXPECTED }).ok).toBe(false);
|
||||
expect(
|
||||
verifyWorkspaceHandoffTicket({ ticket: wrongOrigin.ticket, key: KEY, expected: EXPECTED }),
|
||||
).toEqual({ ok: false, reason: "origin_mismatch" });
|
||||
|
||||
// A ticket for a sibling workspace on the same host cannot be re-signed with
|
||||
// this workspace's key, so bind-mismatch is proven with a matching key.
|
||||
const siblingWorkspaceKey = KEY;
|
||||
const wrongWorkspace = issueWorkspaceHandoffTicket({
|
||||
key: siblingWorkspaceKey,
|
||||
userId: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
executionWorkspaceId: "ews-sibling",
|
||||
companyId: COMPANY_ID,
|
||||
instanceId: INSTANCE_ID,
|
||||
origin: ORIGIN,
|
||||
issuerInstanceId: "primary",
|
||||
});
|
||||
expect(verifyWorkspaceHandoffTicket({ ticket: wrongWorkspace.ticket, key: KEY, expected: EXPECTED })).toEqual({
|
||||
ok: false,
|
||||
reason: "workspace_mismatch",
|
||||
});
|
||||
|
||||
const wrongInstance = issueWorkspaceHandoffTicket({
|
||||
key: KEY,
|
||||
userId: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
executionWorkspaceId: EXECUTION_WORKSPACE_ID,
|
||||
companyId: COMPANY_ID,
|
||||
instanceId: "some-other-instance",
|
||||
origin: ORIGIN,
|
||||
issuerInstanceId: "primary",
|
||||
});
|
||||
expect(verifyWorkspaceHandoffTicket({ ticket: wrongInstance.ticket, key: KEY, expected: EXPECTED })).toEqual({
|
||||
ok: false,
|
||||
reason: "instance_mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a ticket minted for another company on the same workspace", () => {
|
||||
// The signing key is per workspace, so a same-host sibling company shares it —
|
||||
// which is exactly why the company has to be bound and compared.
|
||||
const { ticket } = mintTicket({ companyId: "company-other" });
|
||||
expect(verifyWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED })).toEqual({
|
||||
ok: false,
|
||||
reason: "company_mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an expired ticket and one issued in the future", () => {
|
||||
const now = new Date("2026-08-19T00:00:00.000Z");
|
||||
const { ticket } = mintTicket({ now, ttlSeconds: 30 });
|
||||
expect(
|
||||
verifyWorkspaceHandoffTicket({
|
||||
ticket,
|
||||
key: KEY,
|
||||
expected: EXPECTED,
|
||||
now: new Date(now.getTime() + 121_000),
|
||||
}),
|
||||
).toEqual({ ok: false, reason: "expired" });
|
||||
|
||||
expect(
|
||||
verifyWorkspaceHandoffTicket({
|
||||
ticket,
|
||||
key: KEY,
|
||||
expected: EXPECTED,
|
||||
now: new Date(now.getTime() - 120_000),
|
||||
}),
|
||||
).toEqual({ ok: false, reason: "not_yet_valid" });
|
||||
});
|
||||
|
||||
it("fails closed when local identity cannot be resolved", () => {
|
||||
const { ticket } = mintTicket();
|
||||
expect(
|
||||
verifyWorkspaceHandoffTicket({ ticket, key: KEY, expected: { ...EXPECTED, origin: null } }),
|
||||
).toEqual({ ok: false, reason: "origin_mismatch" });
|
||||
expect(
|
||||
verifyWorkspaceHandoffTicket({ ticket, key: KEY, expected: { ...EXPECTED, instanceId: null } }),
|
||||
).toEqual({ ok: false, reason: "instance_mismatch" });
|
||||
expect(
|
||||
verifyWorkspaceHandoffTicket({ ticket, key: KEY, expected: { ...EXPECTED, executionWorkspaceId: null } }),
|
||||
).toEqual({ ok: false, reason: "workspace_mismatch" });
|
||||
expect(
|
||||
verifyWorkspaceHandoffTicket({ ticket, key: KEY, expected: { ...EXPECTED, companyId: null } }),
|
||||
).toEqual({ ok: false, reason: "company_mismatch" });
|
||||
expect(verifyWorkspaceHandoffTicket({ ticket, key: "", expected: EXPECTED })).toEqual({
|
||||
ok: false,
|
||||
reason: "not_configured",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed, truncated, and re-versioned tickets", () => {
|
||||
const { ticket } = mintTicket();
|
||||
const [version, payload, signature] = ticket.split(".");
|
||||
expect(verifyWorkspaceHandoffTicket({ ticket: "", key: KEY, expected: EXPECTED }).ok).toBe(false);
|
||||
expect(verifyWorkspaceHandoffTicket({ ticket: `${version}.${payload}`, key: KEY, expected: EXPECTED })).toEqual({
|
||||
ok: false,
|
||||
reason: "malformed",
|
||||
});
|
||||
expect(
|
||||
verifyWorkspaceHandoffTicket({ ticket: `wh0.${payload}.${signature}`, key: KEY, expected: EXPECTED }),
|
||||
).toEqual({ ok: false, reason: "unsupported_version" });
|
||||
expect(
|
||||
verifyWorkspaceHandoffTicket({ ticket: `${version}.${payload}.not+base64url`, key: KEY, expected: EXPECTED }),
|
||||
).toEqual({ ok: false, reason: "malformed" });
|
||||
// Payload tampering has to fail on the signature, never on JSON parsing.
|
||||
const tampered = Buffer.from(
|
||||
JSON.stringify({ ...JSON.parse(Buffer.from(payload!, "base64url").toString()), sub: "attacker" }),
|
||||
).toString("base64url");
|
||||
expect(
|
||||
verifyWorkspaceHandoffTicket({ ticket: `${version}.${tampered}.${signature}`, key: KEY, expected: EXPECTED }),
|
||||
).toEqual({ ok: false, reason: "bad_signature" });
|
||||
});
|
||||
|
||||
it("uses the declared envelope version", () => {
|
||||
expect(mintTicket().ticket.startsWith(`${WORKSPACE_HANDOFF_TICKET_VERSION}.`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exchangeWorkspaceHandoffTicket", () => {
|
||||
it("creates exactly one session for a valid ticket", async () => {
|
||||
const { ticket } = mintTicket({ next: "/PAP/issues/PAP-2" });
|
||||
const deps = exchangeDeps();
|
||||
const result = await exchangeWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED, deps });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.ok && result.redirectTo).toBe("/PAP/issues/PAP-2");
|
||||
expect(deps.createSession).toHaveBeenCalledTimes(1);
|
||||
expect(deps.createSession).toHaveBeenCalledWith(USER_ID);
|
||||
});
|
||||
|
||||
it("rejects a replayed ticket without creating a second session", async () => {
|
||||
const { ticket } = mintTicket();
|
||||
const deps = exchangeDeps();
|
||||
expect((await exchangeWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED, deps })).ok).toBe(true);
|
||||
const replay = await exchangeWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED, deps });
|
||||
expect(replay).toEqual({ ok: false, reason: "replayed", payload: expect.objectContaining({ sub: USER_ID }) });
|
||||
expect(deps.createSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("asks for membership in the ticket's company, not any membership", async () => {
|
||||
const { ticket } = mintTicket();
|
||||
const deps = exchangeDeps();
|
||||
expect((await exchangeWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED, deps })).ok).toBe(true);
|
||||
expect(deps.findClonedIdentity).toHaveBeenCalledWith({ userId: USER_ID, companyId: COMPANY_ID });
|
||||
});
|
||||
|
||||
it("reports a missing cloned user and a missing membership distinctly", async () => {
|
||||
const { ticket } = mintTicket();
|
||||
const missingUser = exchangeDeps({ findClonedIdentity: vi.fn(async () => null) });
|
||||
expect(
|
||||
(await exchangeWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED, deps: missingUser })),
|
||||
).toMatchObject({ ok: false, reason: "unknown_user" });
|
||||
expect(missingUser.createSession).not.toHaveBeenCalled();
|
||||
|
||||
const noMembership = exchangeDeps({
|
||||
findClonedIdentity: vi.fn(async () => ({ userId: USER_ID, email: USER_EMAIL, hasActiveMembership: false })),
|
||||
});
|
||||
expect(
|
||||
(await exchangeWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED, deps: noMembership })),
|
||||
).toMatchObject({ ok: false, reason: "missing_membership" });
|
||||
expect(noMembership.reserveNonce).not.toHaveBeenCalled();
|
||||
expect(noMembership.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a clone whose user id was reused for a different email", async () => {
|
||||
const { ticket } = mintTicket();
|
||||
const deps = exchangeDeps({
|
||||
findClonedIdentity: vi.fn(async () => ({
|
||||
userId: USER_ID,
|
||||
email: "someone-else@example.com",
|
||||
hasActiveMembership: true,
|
||||
})),
|
||||
});
|
||||
expect(
|
||||
await exchangeWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED, deps }),
|
||||
).toMatchObject({ ok: false, reason: "user_mismatch" });
|
||||
expect(deps.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches the cloned email case-insensitively", async () => {
|
||||
const { ticket } = mintTicket();
|
||||
const deps = exchangeDeps({
|
||||
findClonedIdentity: vi.fn(async () => ({
|
||||
userId: USER_ID,
|
||||
email: USER_EMAIL.toUpperCase(),
|
||||
hasActiveMembership: true,
|
||||
})),
|
||||
});
|
||||
expect((await exchangeWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED, deps })).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("burns the nonce when session creation fails so a captured ticket cannot be retried", async () => {
|
||||
const { ticket } = mintTicket();
|
||||
const deps = exchangeDeps({
|
||||
createSession: vi.fn(async () => {
|
||||
throw new Error("session store unavailable");
|
||||
}),
|
||||
});
|
||||
expect(
|
||||
await exchangeWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED, deps }),
|
||||
).toMatchObject({ ok: false, reason: "session_failed" });
|
||||
expect(deps.reserveNonce).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never consults request headers: identity comes only from `expected`", async () => {
|
||||
// A ticket minted for the spoofed host fails against the configured origin,
|
||||
// which is the whole forwarded-header defense.
|
||||
const spoofed = mintTicket({ origin: "https://attacker.example.com" });
|
||||
expect(
|
||||
await exchangeWorkspaceHandoffTicket({
|
||||
ticket: spoofed.ticket,
|
||||
key: KEY,
|
||||
expected: EXPECTED,
|
||||
deps: exchangeDeps(),
|
||||
}),
|
||||
).toEqual({ ok: false, reason: "origin_mismatch", payload: null });
|
||||
});
|
||||
|
||||
it("maps failures onto stable statuses that do not leak which check failed", () => {
|
||||
expect(workspaceHandoffFailureStatus("not_configured")).toBe(503);
|
||||
expect(workspaceHandoffFailureStatus("session_failed")).toBe(500);
|
||||
for (const reason of ["expired", "replayed", "origin_mismatch", "workspace_mismatch", "company_mismatch", "unknown_user"] as const) {
|
||||
expect(workspaceHandoffFailureStatus(reason)).toBe(401);
|
||||
}
|
||||
expect(isWorkspaceHandoffRepairReason("missing_membership")).toBe(true);
|
||||
expect(isWorkspaceHandoffRepairReason("expired")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handoff URL handling", () => {
|
||||
it("reduces a landing target to a same-origin path", () => {
|
||||
expect(sanitizeWorkspaceHandoffRedirectPath("/PAP/issues/PAP-1")).toBe("/PAP/issues/PAP-1");
|
||||
expect(sanitizeWorkspaceHandoffRedirectPath("https://attacker.example.com/")).toBe("/");
|
||||
expect(sanitizeWorkspaceHandoffRedirectPath("//attacker.example.com")).toBe("/");
|
||||
expect(sanitizeWorkspaceHandoffRedirectPath("/\\attacker.example.com")).toBe("/");
|
||||
expect(sanitizeWorkspaceHandoffRedirectPath("/ok\nSet-Cookie: x=1")).toBe("/");
|
||||
expect(sanitizeWorkspaceHandoffRedirectPath(undefined)).toBe("/");
|
||||
});
|
||||
|
||||
it("normalizes origins and refuses non-http schemes", () => {
|
||||
expect(normalizeWorkspaceHandoffOrigin("https://host:443/path")).toBe("https://host");
|
||||
expect(normalizeWorkspaceHandoffOrigin("http://host:42013/x")).toBe("http://host:42013");
|
||||
expect(normalizeWorkspaceHandoffOrigin("file:///etc/passwd")).toBeNull();
|
||||
expect(normalizeWorkspaceHandoffOrigin("not a url")).toBeNull();
|
||||
});
|
||||
|
||||
it("builds an exchange URL on the bound origin", () => {
|
||||
const { ticket } = mintTicket();
|
||||
const url = new URL(buildWorkspaceHandoffExchangeUrl({ origin: ORIGIN, ticket }));
|
||||
expect(url.origin).toBe(ORIGIN);
|
||||
expect(url.pathname).toBe("/api/auth/workspace-handoff/exchange");
|
||||
expect(url.searchParams.get("ticket")).toBe(ticket);
|
||||
});
|
||||
|
||||
it("redacts the ticket from anything log-shaped", () => {
|
||||
const { ticket } = mintTicket();
|
||||
const line = `GET /api/auth/workspace-handoff/exchange?ticket=${ticket}&next=%2F 302`;
|
||||
const redacted = redactWorkspaceHandoffTicket(line);
|
||||
expect(redacted).not.toContain(ticket);
|
||||
expect(redacted).toContain("ticket=[redacted]");
|
||||
expect(redacted).toContain("next=%2F");
|
||||
});
|
||||
});
|
||||
@@ -44,14 +44,14 @@ describe("workspace runtime control serialization", () => {
|
||||
|
||||
await expect(runExclusiveWorkspaceRuntimeControl({
|
||||
executionWorkspaceId: "workspace-1",
|
||||
action: "restart",
|
||||
run: async () => "restarted",
|
||||
action: "repair",
|
||||
run: async () => "repaired",
|
||||
})).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: {
|
||||
code: "workspace_runtime_control_in_progress",
|
||||
activeAction: "start",
|
||||
requestedAction: "restart",
|
||||
requestedAction: "repair",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -204,4 +204,71 @@ describeEmbeddedPostgres("workspace operation reconciliation", () => {
|
||||
.then((rows) => rows[0]);
|
||||
expect(unrelated?.status).toBe("running");
|
||||
});
|
||||
|
||||
it("persists bounded repair phase progress on the operation row and log", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Workspace repair diagnostics",
|
||||
issuePrefix: `R${companyId.replace(/-/g, "").slice(0, 7).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Repair diagnostics",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Repair diagnostics workspace",
|
||||
status: "active",
|
||||
cwd: "/tmp/repair-diagnostics-workspace",
|
||||
});
|
||||
|
||||
const operation = await workspaceOperationService(db)
|
||||
.createRecorder({ companyId, executionWorkspaceId })
|
||||
.recordOperation({
|
||||
phase: "workspace_repair",
|
||||
command: "workspace command repair",
|
||||
cwd: "/tmp/repair-diagnostics-workspace",
|
||||
metadata: { action: "repair" },
|
||||
run: async (reportProgress) => {
|
||||
await reportProgress({
|
||||
metadata: {
|
||||
repairPhase: "target_backup",
|
||||
repairDiagnostics: [{
|
||||
phase: "target_backup",
|
||||
status: "succeeded",
|
||||
at: "2026-08-18T00:00:00.000Z",
|
||||
}],
|
||||
databaseOnly: true,
|
||||
worktreePreserved: true,
|
||||
},
|
||||
system: "Workspace repair target_backup: succeeded.\n",
|
||||
});
|
||||
return { status: "succeeded", metadata: { backupRetained: true } };
|
||||
},
|
||||
});
|
||||
|
||||
expect(operation).toMatchObject({
|
||||
status: "succeeded",
|
||||
metadata: {
|
||||
action: "repair",
|
||||
repairPhase: "target_backup",
|
||||
databaseOnly: true,
|
||||
worktreePreserved: true,
|
||||
backupRetained: true,
|
||||
},
|
||||
});
|
||||
expect((await workspaceOperationService(db).readLog(operation.id)).content).toContain(
|
||||
"Workspace repair target_backup: succeeded.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,687 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { WorkspaceReadinessProbeResult } from "@paperclipai/shared";
|
||||
import {
|
||||
isManagedWorkspaceInstance,
|
||||
resetManagedWorkspaceInstanceCacheForTests,
|
||||
resolveWorkspaceReadiness,
|
||||
resolveWorkspaceReadinessState,
|
||||
resolveWorkspaceSeedMarkerDir,
|
||||
} from "../services/workspace-readiness.js";
|
||||
import {
|
||||
buildManagedWorkspaceGuestEnv,
|
||||
listManagedWorkspaceHandoffSubjects,
|
||||
probeManagedWorkspaceHandoffSubjects,
|
||||
probeManagedWorkspaceReadiness,
|
||||
resolveManagedWorkspaceIdentity,
|
||||
resolveWorkspaceReadinessGateMode,
|
||||
shouldBlockPublicationOnReadiness,
|
||||
waitForManagedWorkspaceReadiness,
|
||||
type ManagedWorkspaceIdentity,
|
||||
} from "../services/managed-workspace-identity.js";
|
||||
import {
|
||||
WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY,
|
||||
WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY,
|
||||
WORKSPACE_HANDOFF_KEY_ENV_KEY,
|
||||
WORKSPACE_READINESS_TOKEN_ENV_KEY,
|
||||
WORKSPACE_READINESS_TOKEN_HEADER,
|
||||
WORKSPACE_READINESS_USER_EMAIL_HEADER,
|
||||
WORKSPACE_READINESS_USER_ID_HEADER,
|
||||
} from "../auth/workspace-login-handoff.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createMarkerDir(files: Record<string, string> = {}) {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "paperclip-workspace-readiness-"));
|
||||
tempDirs.push(dir);
|
||||
const configPath = path.join(dir, "config.json");
|
||||
writeFileSync(configPath, "{}\n", "utf8");
|
||||
for (const [name, contents] of Object.entries(files)) {
|
||||
writeFileSync(path.join(dir, name), contents, "utf8");
|
||||
}
|
||||
return { dir, configPath };
|
||||
}
|
||||
|
||||
function verifiedManifest(overrides: Record<string, unknown> = {}) {
|
||||
return JSON.stringify({
|
||||
version: 2,
|
||||
state: "verified",
|
||||
phase: "complete",
|
||||
seedMode: "minimal",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/** A `db` stand-in whose clone/identity probes both find one row. */
|
||||
function readyDb() {
|
||||
return {
|
||||
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
|
||||
// Both readiness probes are `limit(1)` existence queries, so one row is enough
|
||||
// to answer either of them.
|
||||
select: vi.fn(() => {
|
||||
const result = [{ companyId: "company-1", userId: "user-1" }];
|
||||
const chain = {
|
||||
from: vi.fn(() => chain),
|
||||
innerJoin: vi.fn(() => chain),
|
||||
where: vi.fn(() => chain),
|
||||
limit: vi.fn(() => Promise.resolve(result)),
|
||||
then: (resolve: (rows: unknown) => unknown) => Promise.resolve(result).then(resolve),
|
||||
};
|
||||
return chain;
|
||||
}),
|
||||
} as unknown as Db;
|
||||
}
|
||||
|
||||
function handoffSubjectDb(rows: Array<{ userId: string; email: string | null }>) {
|
||||
return {
|
||||
select: vi.fn(() => {
|
||||
const chain = {
|
||||
from: vi.fn(() => chain),
|
||||
innerJoin: vi.fn(() => chain),
|
||||
then: (resolve: (value: unknown) => unknown) => Promise.resolve(rows).then(resolve),
|
||||
};
|
||||
return chain;
|
||||
}),
|
||||
} as unknown as Db;
|
||||
}
|
||||
|
||||
function readyGuestEnv(configPath: string, overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
|
||||
return {
|
||||
PAPERCLIP_CONFIG: configPath,
|
||||
[WORKSPACE_HANDOFF_KEY_ENV_KEY]: "key",
|
||||
[WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY]: "ews-1",
|
||||
[WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY]: "company-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
resetManagedWorkspaceInstanceCacheForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("resolveWorkspaceReadinessState", () => {
|
||||
const ready = {
|
||||
databaseReady: true,
|
||||
cloneDataReady: true,
|
||||
authHandoffReady: true,
|
||||
seedState: "verified",
|
||||
} as const;
|
||||
|
||||
it("is ready only when every signal agrees and the seed is verified", () => {
|
||||
expect(resolveWorkspaceReadinessState(ready)).toBe("ready");
|
||||
expect(resolveWorkspaceReadinessState({ ...ready, seedState: "unknown" })).toBe("validating");
|
||||
expect(resolveWorkspaceReadinessState({ ...ready, seedState: "absent" })).toBe("validating");
|
||||
});
|
||||
|
||||
it("reports an unfinished clone as provisioning, not degraded", () => {
|
||||
expect(resolveWorkspaceReadinessState({ ...ready, seedState: "pending", databaseReady: false })).toBe("provisioning");
|
||||
expect(resolveWorkspaceReadinessState({ ...ready, seedState: "running" })).toBe("provisioning");
|
||||
});
|
||||
|
||||
it("reports a regressed verified clone as degraded", () => {
|
||||
expect(resolveWorkspaceReadinessState({ ...ready, databaseReady: false })).toBe("degraded");
|
||||
expect(resolveWorkspaceReadinessState({ ...ready, cloneDataReady: false })).toBe("degraded");
|
||||
expect(resolveWorkspaceReadinessState({ ...ready, authHandoffReady: false })).toBe("degraded");
|
||||
});
|
||||
|
||||
it("reports a recorded seed failure as failed", () => {
|
||||
expect(resolveWorkspaceReadinessState({ ...ready, seedState: "failed" })).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorkspaceReadiness", () => {
|
||||
it("reports a verified, fully readable clone as ready", async () => {
|
||||
const { configPath } = createMarkerDir({ "seed-manifest.json": verifiedManifest() });
|
||||
const readiness = await resolveWorkspaceReadiness({
|
||||
db: readyDb(),
|
||||
env: readyGuestEnv(configPath),
|
||||
});
|
||||
expect(readiness).toMatchObject({
|
||||
state: "ready",
|
||||
databaseReady: true,
|
||||
cloneDataReady: true,
|
||||
authHandoffReady: true,
|
||||
authHandoffUserId: null,
|
||||
seedState: "verified",
|
||||
seedMode: "minimal",
|
||||
executionWorkspaceId: "ews-1",
|
||||
companyId: "company-1",
|
||||
failurePhase: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not claim the handoff is ready without a signing key", async () => {
|
||||
const { configPath } = createMarkerDir({ "seed-manifest.json": verifiedManifest() });
|
||||
const readiness = await resolveWorkspaceReadiness({
|
||||
db: readyDb(),
|
||||
env: readyGuestEnv(configPath, { [WORKSPACE_HANDOFF_KEY_ENV_KEY]: undefined }),
|
||||
});
|
||||
expect(readiness.authHandoffReady).toBe(false);
|
||||
expect(readiness.state).toBe("degraded");
|
||||
expect(readiness.failurePhase).toBe("auth_handoff_not_configured");
|
||||
});
|
||||
|
||||
it("fails closed when the guest has no company binding", async () => {
|
||||
const { configPath } = createMarkerDir({ "seed-manifest.json": verifiedManifest() });
|
||||
const db = readyDb();
|
||||
const readiness = await resolveWorkspaceReadiness({
|
||||
db,
|
||||
env: readyGuestEnv(configPath, {
|
||||
[WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY]: undefined,
|
||||
}),
|
||||
});
|
||||
expect(readiness).toMatchObject({
|
||||
state: "degraded",
|
||||
cloneDataReady: false,
|
||||
authHandoffReady: false,
|
||||
failurePhase: "workspace_company_not_configured",
|
||||
});
|
||||
expect(db.select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not claim handoff readiness without the execution-workspace identity", async () => {
|
||||
const { configPath } = createMarkerDir({ "seed-manifest.json": verifiedManifest() });
|
||||
const readiness = await resolveWorkspaceReadiness({
|
||||
db: readyDb(),
|
||||
env: readyGuestEnv(configPath, {
|
||||
[WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY]: undefined,
|
||||
}),
|
||||
});
|
||||
expect(readiness).toMatchObject({
|
||||
state: "degraded",
|
||||
cloneDataReady: true,
|
||||
authHandoffReady: false,
|
||||
failurePhase: "workspace_identity_not_configured",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces the failing seed phase instead of an inferred one", async () => {
|
||||
const { configPath } = createMarkerDir({
|
||||
"seed-manifest.json": verifiedManifest({ state: "failed", phase: "restore" }),
|
||||
});
|
||||
const readiness = await resolveWorkspaceReadiness({
|
||||
db: readyDb(),
|
||||
env: readyGuestEnv(configPath),
|
||||
});
|
||||
expect(readiness).toMatchObject({ state: "failed", seedState: "failed", failurePhase: "restore" });
|
||||
});
|
||||
|
||||
it("treats an unreadable manifest as a failure rather than assuming it seeded", async () => {
|
||||
const { configPath } = createMarkerDir({ "seed-manifest.json": "{ truncated" });
|
||||
const readiness = await resolveWorkspaceReadiness({
|
||||
db: readyDb(),
|
||||
env: readyGuestEnv(configPath),
|
||||
});
|
||||
expect(readiness).toMatchObject({ state: "failed", failurePhase: "seed_manifest_unreadable" });
|
||||
});
|
||||
|
||||
it("reports an interrupted seed as provisioning", async () => {
|
||||
const { configPath } = createMarkerDir({ "seed-pending": "{}" });
|
||||
const readiness = await resolveWorkspaceReadiness({
|
||||
db: readyDb(),
|
||||
env: readyGuestEnv(configPath),
|
||||
});
|
||||
expect(readiness).toMatchObject({ state: "provisioning", seedState: "pending" });
|
||||
});
|
||||
|
||||
it("reports a legacy marker clone as validating, never verified", async () => {
|
||||
const { configPath } = createMarkerDir({ "seed-complete": "" });
|
||||
const readiness = await resolveWorkspaceReadiness({
|
||||
db: readyDb(),
|
||||
env: readyGuestEnv(configPath),
|
||||
});
|
||||
expect(readiness).toMatchObject({ state: "validating", seedState: "unknown" });
|
||||
});
|
||||
|
||||
it("reports an unreachable database without throwing", async () => {
|
||||
const { configPath } = createMarkerDir({ "seed-manifest.json": verifiedManifest() });
|
||||
const db = {
|
||||
execute: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")),
|
||||
select: vi.fn(),
|
||||
} as unknown as Db;
|
||||
const readiness = await resolveWorkspaceReadiness({
|
||||
db,
|
||||
env: readyGuestEnv(configPath),
|
||||
});
|
||||
expect(readiness).toMatchObject({
|
||||
state: "degraded",
|
||||
databaseReady: false,
|
||||
failurePhase: "database_unreachable",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves the marker directory from the configured config path", () => {
|
||||
const { dir, configPath } = createMarkerDir();
|
||||
expect(resolveWorkspaceSeedMarkerDir({ PAPERCLIP_CONFIG: configPath })).toBe(dir);
|
||||
});
|
||||
|
||||
it("does not re-stat marker files on every health request", () => {
|
||||
const { configPath } = createMarkerDir({ "seed-manifest.json": verifiedManifest() });
|
||||
const env = { PAPERCLIP_CONFIG: configPath };
|
||||
let clock = 0;
|
||||
expect(isManagedWorkspaceInstance(env, () => clock)).toBe(true);
|
||||
|
||||
// Removing the marker inside the TTL keeps the cached answer; past it, the
|
||||
// filesystem is consulted again.
|
||||
rmSync(path.join(path.dirname(configPath), "seed-manifest.json"));
|
||||
clock = 1_000;
|
||||
expect(isManagedWorkspaceInstance(env, () => clock)).toBe(true);
|
||||
clock = 10_000;
|
||||
expect(isManagedWorkspaceInstance(env, () => clock)).toBe(false);
|
||||
});
|
||||
|
||||
it("only treats a process with clone evidence as a managed workspace", () => {
|
||||
const { configPath } = createMarkerDir();
|
||||
expect(isManagedWorkspaceInstance({ PAPERCLIP_CONFIG: configPath })).toBe(false);
|
||||
expect(
|
||||
isManagedWorkspaceInstance({ PAPERCLIP_CONFIG: configPath, [WORKSPACE_HANDOFF_KEY_ENV_KEY]: "key" }),
|
||||
).toBe(true);
|
||||
const seeded = createMarkerDir({ "seed-manifest.json": verifiedManifest() });
|
||||
expect(isManagedWorkspaceInstance({ PAPERCLIP_CONFIG: seeded.configPath })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeManagedWorkspaceReadiness", () => {
|
||||
const identity: ManagedWorkspaceIdentity = {
|
||||
instanceId: "instance-a",
|
||||
executionWorkspaceId: "ews-1",
|
||||
companyId: "company-1",
|
||||
handoffKey: "handoff-key",
|
||||
readinessToken: "probe-token",
|
||||
secretSource: "derived",
|
||||
};
|
||||
|
||||
function respond(body: unknown, init: { status?: number } = {}) {
|
||||
return vi.fn(async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: init.status ?? 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
const readyPayload = {
|
||||
status: "ok",
|
||||
workspace: {
|
||||
state: "ready",
|
||||
databaseReady: true,
|
||||
cloneDataReady: true,
|
||||
authHandoffReady: true,
|
||||
authHandoffUserId: null,
|
||||
seedState: "verified",
|
||||
seedPhase: "complete",
|
||||
seedMode: "minimal",
|
||||
instanceId: "instance-a",
|
||||
executionWorkspaceId: "ews-1",
|
||||
companyId: "company-1",
|
||||
failurePhase: null,
|
||||
},
|
||||
};
|
||||
|
||||
it("accepts a ready workspace and sends the derived probe token", async () => {
|
||||
const fetchImpl = respond(readyPayload);
|
||||
const result = await probeManagedWorkspaceReadiness({
|
||||
healthUrl: "http://127.0.0.1:42013/api/health",
|
||||
identity,
|
||||
fetchImpl,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
const [, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]!;
|
||||
expect((init as RequestInit & { headers: Record<string, string> }).headers[WORKSPACE_READINESS_TOKEN_HEADER])
|
||||
.toBe("probe-token");
|
||||
});
|
||||
|
||||
it("binds a caller-scoped readiness probe to the exact handoff user", async () => {
|
||||
const fetchImpl = respond({
|
||||
...readyPayload,
|
||||
workspace: { ...readyPayload.workspace, authHandoffUserId: "user-1" },
|
||||
});
|
||||
const result = await probeManagedWorkspaceReadiness({
|
||||
healthUrl: "http://127.0.0.1:42013/api/health",
|
||||
identity,
|
||||
handoffSubject: { userId: "user-1", email: "operator@example.com" },
|
||||
fetchImpl,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
const [, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]!;
|
||||
expect((init as RequestInit & { headers: Record<string, string> }).headers).toMatchObject({
|
||||
[WORKSPACE_READINESS_USER_ID_HEADER]: "user-1",
|
||||
[WORKSPACE_READINESS_USER_EMAIL_HEADER]: "operator@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a readiness response scoped to another handoff user", async () => {
|
||||
expect(
|
||||
await probeManagedWorkspaceReadiness({
|
||||
healthUrl: "http://127.0.0.1:42013/api/health",
|
||||
identity,
|
||||
handoffSubject: { userId: "user-1", email: "operator@example.com" },
|
||||
fetchImpl: respond({
|
||||
...readyPayload,
|
||||
workspace: { ...readyPayload.workspace, authHandoffUserId: "user-other" },
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({ ok: false, reason: "identity_mismatch" });
|
||||
});
|
||||
|
||||
it("proves every active control-plane board identity before publication", async () => {
|
||||
const db = handoffSubjectDb([
|
||||
{ userId: "user-1", email: "one@example.com" },
|
||||
{ userId: "user-2", email: "two@example.com" },
|
||||
]);
|
||||
await expect(listManagedWorkspaceHandoffSubjects(db, "company-1")).resolves.toEqual([
|
||||
{ userId: "user-1", email: "one@example.com" },
|
||||
{ userId: "user-2", email: "two@example.com" },
|
||||
]);
|
||||
|
||||
const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
return new Response(JSON.stringify({
|
||||
...readyPayload,
|
||||
workspace: {
|
||||
...readyPayload.workspace,
|
||||
authHandoffUserId: headers[WORKSPACE_READINESS_USER_ID_HEADER],
|
||||
},
|
||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}) as unknown as typeof fetch;
|
||||
await expect(probeManagedWorkspaceHandoffSubjects({
|
||||
db,
|
||||
healthUrl: "http://127.0.0.1:42013/api/health",
|
||||
identity,
|
||||
fetchImpl,
|
||||
})).resolves.toMatchObject({ ok: true });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("blocks publication when no current board identity can use the handoff", async () => {
|
||||
const fetchImpl = vi.fn() as unknown as typeof fetch;
|
||||
await expect(probeManagedWorkspaceHandoffSubjects({
|
||||
db: handoffSubjectDb([]),
|
||||
healthUrl: "http://127.0.0.1:42013/api/health",
|
||||
identity,
|
||||
fetchImpl,
|
||||
})).resolves.toMatchObject({ ok: false, reason: "not_ready" });
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a workspace serving another instance, workspace, or company", async () => {
|
||||
const wrongInstance = await probeManagedWorkspaceReadiness({
|
||||
healthUrl: "http://127.0.0.1:42013/api/health",
|
||||
identity,
|
||||
fetchImpl: respond({
|
||||
...readyPayload,
|
||||
workspace: { ...readyPayload.workspace, instanceId: "instance-b" },
|
||||
}),
|
||||
});
|
||||
expect(wrongInstance).toMatchObject({ ok: false, reason: "identity_mismatch" });
|
||||
|
||||
const wrongWorkspace = await probeManagedWorkspaceReadiness({
|
||||
healthUrl: "http://127.0.0.1:42013/api/health",
|
||||
identity,
|
||||
fetchImpl: respond({
|
||||
...readyPayload,
|
||||
workspace: { ...readyPayload.workspace, executionWorkspaceId: "ews-other" },
|
||||
}),
|
||||
});
|
||||
expect(wrongWorkspace).toMatchObject({ ok: false, reason: "identity_mismatch" });
|
||||
|
||||
const wrongCompany = await probeManagedWorkspaceReadiness({
|
||||
healthUrl: "http://127.0.0.1:42013/api/health",
|
||||
identity,
|
||||
fetchImpl: respond({
|
||||
...readyPayload,
|
||||
workspace: { ...readyPayload.workspace, companyId: "company-other" },
|
||||
}),
|
||||
});
|
||||
expect(wrongCompany).toMatchObject({ ok: false, reason: "identity_mismatch" });
|
||||
});
|
||||
|
||||
it("rejects a partial readiness contract with no company identity", async () => {
|
||||
const { companyId: _companyId, ...partialReadiness } = readyPayload.workspace;
|
||||
expect(
|
||||
await probeManagedWorkspaceReadiness({
|
||||
healthUrl: "http://127.0.0.1:42013/api/health",
|
||||
identity,
|
||||
fetchImpl: respond({ ...readyPayload, workspace: partialReadiness }),
|
||||
}),
|
||||
).toMatchObject({ ok: false, reason: "identity_mismatch", readiness: null });
|
||||
});
|
||||
|
||||
it("rejects a 200 response whose payload is not semantically healthy", async () => {
|
||||
expect(
|
||||
await probeManagedWorkspaceReadiness({
|
||||
healthUrl: "http://127.0.0.1:42013/api/health",
|
||||
identity,
|
||||
fetchImpl: respond({ status: "unhealthy" }),
|
||||
}),
|
||||
).toMatchObject({ ok: false, reason: "unhealthy_payload" });
|
||||
});
|
||||
|
||||
it("rejects a response with no readiness block, so a legacy guest cannot publish", async () => {
|
||||
expect(
|
||||
await probeManagedWorkspaceReadiness({
|
||||
healthUrl: "http://127.0.0.1:42013/api/health",
|
||||
identity,
|
||||
fetchImpl: respond({ status: "ok" }),
|
||||
}),
|
||||
).toMatchObject({ ok: false, reason: "readiness_missing" });
|
||||
});
|
||||
|
||||
it("rejects a workspace whose clone or handoff is not ready and keeps the failure phase", async () => {
|
||||
expect(
|
||||
await probeManagedWorkspaceReadiness({
|
||||
healthUrl: "http://127.0.0.1:42013/api/health",
|
||||
identity,
|
||||
fetchImpl: respond({
|
||||
...readyPayload,
|
||||
workspace: {
|
||||
...readyPayload.workspace,
|
||||
state: "degraded",
|
||||
cloneDataReady: false,
|
||||
failurePhase: "clone_data_missing",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({ ok: false, reason: "not_ready", detail: "clone_data_missing" });
|
||||
});
|
||||
|
||||
it("reports an unreachable guest", async () => {
|
||||
const result = await probeManagedWorkspaceReadiness({
|
||||
healthUrl: "http://127.0.0.1:42013/api/health",
|
||||
identity,
|
||||
fetchImpl: (async () => {
|
||||
throw new Error("connect ECONNREFUSED");
|
||||
}) as unknown as typeof fetch,
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, reason: "unreachable" });
|
||||
});
|
||||
|
||||
it("fails closed when any part of the workspace identity is unresolved", () => {
|
||||
// Every readiness and handoff check keys off this. Returning a partial identity
|
||||
// would silently downgrade the whole gate to the legacy transport check, so the
|
||||
// three inputs are all required.
|
||||
const complete = {
|
||||
workspaceCwd: "/srv/worktree",
|
||||
executionWorkspaceId: "ews-1",
|
||||
companyId: "company-1",
|
||||
env: { PAPERCLIP_WORKSPACE_HANDOFF_SECRET: "root" },
|
||||
};
|
||||
expect(resolveManagedWorkspaceIdentity(complete)).not.toBeNull();
|
||||
expect(resolveManagedWorkspaceIdentity({ ...complete, companyId: null })).toBeNull();
|
||||
expect(resolveManagedWorkspaceIdentity({ ...complete, executionWorkspaceId: null })).toBeNull();
|
||||
expect(resolveManagedWorkspaceIdentity({ ...complete, workspaceCwd: null })).toBeNull();
|
||||
expect(resolveManagedWorkspaceIdentity({ ...complete, env: {} })).toBeNull();
|
||||
});
|
||||
|
||||
it("derives distinct key material per company on the same workspace", () => {
|
||||
const base = {
|
||||
workspaceCwd: "/srv/worktree",
|
||||
executionWorkspaceId: "ews-1",
|
||||
env: { PAPERCLIP_WORKSPACE_HANDOFF_SECRET: "root" },
|
||||
};
|
||||
const first = resolveManagedWorkspaceIdentity({ ...base, companyId: "company-1" });
|
||||
const second = resolveManagedWorkspaceIdentity({ ...base, companyId: "company-2" });
|
||||
// The signing key is deliberately per instance+workspace, not per company —
|
||||
// the company is enforced by the signed `cid` claim instead — so the keys match
|
||||
// while the recorded company differs.
|
||||
expect(first?.handoffKey).toBe(second?.handoffKey);
|
||||
expect(first?.companyId).toBe("company-1");
|
||||
expect(second?.companyId).toBe("company-2");
|
||||
});
|
||||
|
||||
it("hands the guest only derived per-workspace material", () => {
|
||||
expect(buildManagedWorkspaceGuestEnv(identity)).toEqual({
|
||||
[WORKSPACE_HANDOFF_KEY_ENV_KEY]: "handoff-key",
|
||||
[WORKSPACE_READINESS_TOKEN_ENV_KEY]: "probe-token",
|
||||
[WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY]: "ews-1",
|
||||
PAPERCLIP_EXECUTION_WORKSPACE_COMPANY_ID: "company-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldBlockPublicationOnReadiness", () => {
|
||||
function rejection(reason: Extract<WorkspaceReadinessProbeResult, { ok: false }>["reason"]) {
|
||||
return { ok: false as const, reason, readiness: null, detail: null };
|
||||
}
|
||||
|
||||
it("blocks on any real disagreement about the clone", () => {
|
||||
for (const reason of ["unreachable", "http_error", "unhealthy_payload", "not_ready", "identity_mismatch"] as const) {
|
||||
expect(shouldBlockPublicationOnReadiness(rejection(reason), "auto")).toBe(true);
|
||||
expect(shouldBlockPublicationOnReadiness(rejection(reason), "strict")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not turn an upgrade lag into an outage by default", () => {
|
||||
expect(shouldBlockPublicationOnReadiness(rejection("readiness_missing"), "auto")).toBe(false);
|
||||
expect(shouldBlockPublicationOnReadiness(rejection("readiness_missing"), "strict")).toBe(true);
|
||||
});
|
||||
|
||||
it("reads the deployment-level mode from the environment", () => {
|
||||
expect(resolveWorkspaceReadinessGateMode({})).toBe("auto");
|
||||
expect(resolveWorkspaceReadinessGateMode({ PAPERCLIP_WORKSPACE_READINESS_GATE: "STRICT" })).toBe("strict");
|
||||
expect(resolveWorkspaceReadinessGateMode({ PAPERCLIP_WORKSPACE_READINESS_GATE: "anything-else" })).toBe("auto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("waitForManagedWorkspaceReadiness", () => {
|
||||
const identity: ManagedWorkspaceIdentity = {
|
||||
instanceId: "instance-a",
|
||||
executionWorkspaceId: "ews-1",
|
||||
companyId: "company-1",
|
||||
handoffKey: "k",
|
||||
readinessToken: "t",
|
||||
secretSource: "derived",
|
||||
};
|
||||
|
||||
const ready = {
|
||||
status: "ok",
|
||||
workspace: {
|
||||
state: "ready",
|
||||
databaseReady: true,
|
||||
cloneDataReady: true,
|
||||
authHandoffReady: true,
|
||||
authHandoffUserId: null,
|
||||
seedState: "verified",
|
||||
seedPhase: "complete",
|
||||
seedMode: "minimal",
|
||||
instanceId: "instance-a",
|
||||
executionWorkspaceId: "ews-1",
|
||||
companyId: "company-1",
|
||||
failurePhase: null,
|
||||
},
|
||||
};
|
||||
|
||||
it("absorbs a guest that becomes ready a beat after its listener", async () => {
|
||||
let attempt = 0;
|
||||
const fetchImpl = (async () => {
|
||||
attempt += 1;
|
||||
const body = attempt < 3
|
||||
? { ...ready, workspace: { ...ready.workspace, databaseReady: false, state: "validating" } }
|
||||
: ready;
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
let clock = 0;
|
||||
const result = await waitForManagedWorkspaceReadiness({
|
||||
healthUrl: "http://127.0.0.1:1/api/health",
|
||||
identity,
|
||||
fetchImpl,
|
||||
now: () => clock,
|
||||
sleep: async (ms) => {
|
||||
clock += ms;
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(attempt).toBe(3);
|
||||
});
|
||||
|
||||
it("does not poll a guest that has no readiness contract to satisfy", async () => {
|
||||
let attempt = 0;
|
||||
const fetchImpl = (async () => {
|
||||
attempt += 1;
|
||||
return new Response(JSON.stringify({ status: "ok" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await waitForManagedWorkspaceReadiness({
|
||||
healthUrl: "http://127.0.0.1:1/api/health",
|
||||
identity,
|
||||
fetchImpl,
|
||||
now: () => 0,
|
||||
sleep: async () => undefined,
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, reason: "readiness_missing" });
|
||||
expect(attempt).toBe(1);
|
||||
});
|
||||
|
||||
it("gives up immediately when another instance owns the port", async () => {
|
||||
let attempt = 0;
|
||||
const fetchImpl = (async () => {
|
||||
attempt += 1;
|
||||
return new Response(
|
||||
JSON.stringify({ ...ready, workspace: { ...ready.workspace, instanceId: "someone-else" } }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await waitForManagedWorkspaceReadiness({
|
||||
healthUrl: "http://127.0.0.1:1/api/health",
|
||||
identity,
|
||||
fetchImpl,
|
||||
now: () => 0,
|
||||
sleep: async () => undefined,
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, reason: "identity_mismatch" });
|
||||
expect(attempt).toBe(1);
|
||||
});
|
||||
|
||||
it("stops at the gate budget instead of holding a start open forever", async () => {
|
||||
let attempt = 0;
|
||||
const fetchImpl = (async () => {
|
||||
attempt += 1;
|
||||
throw new Error("connect ECONNREFUSED");
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
let clock = 0;
|
||||
const result = await waitForManagedWorkspaceReadiness({
|
||||
healthUrl: "http://127.0.0.1:1/api/health",
|
||||
identity,
|
||||
fetchImpl,
|
||||
timeoutMs: 1_000,
|
||||
now: () => clock,
|
||||
sleep: async (ms) => {
|
||||
clock += ms;
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, reason: "unreachable" });
|
||||
// 1s budget at the 250ms retry interval: four probes, then the budget is spent.
|
||||
expect(attempt).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -54,7 +54,7 @@ vi.mock("../services/index.js", () => ({
|
||||
secretService: () => mockSecretService,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
workspaceRuntimeLeaseService: () => mockWorkspaceRuntimeLeaseService,
|
||||
LEASED_WORKSPACE_RUNTIME_ACTIONS: ["start", "stop", "restart"],
|
||||
LEASED_WORKSPACE_RUNTIME_ACTIONS: ["start", "stop", "restart", "repair"],
|
||||
}));
|
||||
|
||||
vi.mock("../services/workspace-runtime.js", () => ({
|
||||
@@ -84,7 +84,7 @@ function registerWorkspaceRouteMocks() {
|
||||
secretService: () => mockSecretService,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
workspaceRuntimeLeaseService: () => mockWorkspaceRuntimeLeaseService,
|
||||
LEASED_WORKSPACE_RUNTIME_ACTIONS: ["start", "stop", "restart"],
|
||||
LEASED_WORKSPACE_RUNTIME_ACTIONS: ["start", "stop", "restart", "repair"],
|
||||
}));
|
||||
|
||||
vi.doMock("../services/workspace-runtime.js", () => ({
|
||||
|
||||
@@ -134,6 +134,17 @@ async function runPnpm(cwd: string, args: string[]) {
|
||||
await execFileAsync("pnpm", args, { cwd });
|
||||
}
|
||||
|
||||
async function writeRegisteredSourceConfig(baseCwd: string, instanceId = "source-instance") {
|
||||
const configDir = path.join(baseCwd, ".paperclip");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(path.join(configDir, "config.json"), "{}\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(configDir, ".env"),
|
||||
`PAPERCLIP_INSTANCE_ID=${instanceId}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function createTempRepo(defaultBranch = "main") {
|
||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repo-"));
|
||||
await runGit(repoRoot, ["init"]);
|
||||
@@ -456,6 +467,39 @@ describe("resolveRuntimeProvisionCommand", () => {
|
||||
|
||||
await fs.writeFile(path.join(cwd, ".paperclip", "seed-complete"), "{}\n");
|
||||
expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe("");
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(cwd, ".paperclip", "seed-manifest.json"),
|
||||
JSON.stringify({ version: 2, state: "failed" }),
|
||||
);
|
||||
expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe(
|
||||
"bash ./scripts/provision-worktree-runtime.sh",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(cwd, ".paperclip", "seed-manifest.json"),
|
||||
JSON.stringify({ version: 2, state: "verified" }),
|
||||
);
|
||||
expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe(
|
||||
"bash ./scripts/provision-worktree-runtime.sh",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(cwd, ".paperclip", "seed-manifest.json"),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
source: { instanceId: "source", configPath: "/source/config.json" },
|
||||
snapshotAt: "2026-08-18T00:00:00.000Z",
|
||||
seedMode: "full",
|
||||
migrationRevision: "0001",
|
||||
targetInstanceId: "target",
|
||||
phase: "complete",
|
||||
state: "verified",
|
||||
attemptId: "attempt",
|
||||
startedAt: "2026-08-18T00:00:00.000Z",
|
||||
finishedAt: "2026-08-18T00:01:00.000Z",
|
||||
diagnostics: [{ phase: "complete", status: "succeeded", at: "2026-08-18T00:01:00.000Z" }],
|
||||
}),
|
||||
);
|
||||
expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe("");
|
||||
} finally {
|
||||
await fs.rm(baseCwd, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1373,8 +1417,13 @@ describe("realizeExecutionWorkspace", () => {
|
||||
|
||||
it("writes an isolated repo-local Paperclip config and worktree branding when provisioning", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await writeRegisteredSourceConfig(repoRoot, "worktree-base-source");
|
||||
const previousCwd = process.cwd();
|
||||
const previousPath = process.env.PATH;
|
||||
const previousConfig = process.env.PAPERCLIP_CONFIG;
|
||||
const previousHome = process.env.PAPERCLIP_HOME;
|
||||
const previousInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
const previousWorktreesDir = process.env.PAPERCLIP_WORKTREES_DIR;
|
||||
const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-home-"));
|
||||
const isolatedWorktreeHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktrees-"));
|
||||
const isolatedBin = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-bin-"));
|
||||
@@ -1386,6 +1435,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
|
||||
process.env.PAPERCLIP_WORKTREES_DIR = isolatedWorktreeHome;
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
// Keep this server-side fixture on provision-worktree.sh's config writer path;
|
||||
// CLI/database seeding is covered by the CLI worktree tests.
|
||||
await fs.symlink(process.execPath, path.join(isolatedBin, "node"));
|
||||
@@ -1557,6 +1607,18 @@ describe("realizeExecutionWorkspace", () => {
|
||||
} else {
|
||||
process.env.PATH = previousPath;
|
||||
}
|
||||
for (const [key, value] of [
|
||||
["PAPERCLIP_CONFIG", previousConfig],
|
||||
["PAPERCLIP_HOME", previousHome],
|
||||
["PAPERCLIP_INSTANCE_ID", previousInstanceId],
|
||||
["PAPERCLIP_WORKTREES_DIR", previousWorktreesDir],
|
||||
] as const) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
@@ -1564,6 +1626,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||
"provisions worktree-local pnpm node_modules instead of reusing base-repo links",
|
||||
async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await writeRegisteredSourceConfig(repoRoot);
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.mkdir(path.join(repoRoot, "packages", "shared"), { recursive: true });
|
||||
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
|
||||
@@ -1666,6 +1729,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||
|
||||
it("provisions successfully when install is needed but there are no symlinked node_modules to move", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await writeRegisteredSourceConfig(repoRoot);
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "package.json"),
|
||||
@@ -1748,6 +1812,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.join(baseRoot, "node_modules"), { recursive: true });
|
||||
await writeRegisteredSourceConfig(baseRoot);
|
||||
await fs.mkdir(path.join(worktreeRoot, "node_modules"), { recursive: true });
|
||||
await fs.mkdir(path.join(worktreeRoot, "ui"), { recursive: true });
|
||||
await fs.mkdir(fakeBin, { recursive: true });
|
||||
@@ -1840,6 +1905,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||
|
||||
try {
|
||||
await fs.mkdir(baseRoot, { recursive: true });
|
||||
await writeRegisteredSourceConfig(baseRoot);
|
||||
await fs.mkdir(worktreeRoot, { recursive: true });
|
||||
await fs.mkdir(fakeBin, { recursive: true });
|
||||
await fs.copyFile(provisionWorktreeScriptPath, scriptPath);
|
||||
@@ -1897,6 +1963,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||
|
||||
try {
|
||||
await fs.mkdir(baseRoot, { recursive: true });
|
||||
await writeRegisteredSourceConfig(baseRoot);
|
||||
await fs.mkdir(paperclipDir, { recursive: true });
|
||||
await fs.mkdir(fakeBin, { recursive: true });
|
||||
await fs.copyFile(provisionWorktreeScriptPath, scriptPath);
|
||||
@@ -1987,6 +2054,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.join(baseRoot, "node_modules"), { recursive: true });
|
||||
await writeRegisteredSourceConfig(baseRoot);
|
||||
await fs.mkdir(worktreeRoot, { recursive: true });
|
||||
await fs.mkdir(fakeBin, { recursive: true });
|
||||
await fs.copyFile(provisionWorktreeScriptPath, scriptPath);
|
||||
@@ -2056,6 +2124,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||
"provisions worktree-local pnpm node_modules instead of reusing base-repo links",
|
||||
async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await writeRegisteredSourceConfig(repoRoot);
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.mkdir(path.join(repoRoot, "packages", "shared"), { recursive: true });
|
||||
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
|
||||
@@ -4165,8 +4234,10 @@ describe("ensureRuntimeServicesForRun", () => {
|
||||
branchName: "PAP-874-chat-speed-issues",
|
||||
worktreePath: worktreeWorkspaceRoot,
|
||||
};
|
||||
// A Paperclip dev runtime must answer `/api/health` semantically before it may
|
||||
// be published, so the fake serves the same shape a real one does.
|
||||
const serviceCommand =
|
||||
"node -e \"require('node:http').createServer((req,res)=>res.end(process.env.PAPERCLIP_HOME)).listen(Number(process.env.PORT), '127.0.0.1')\"";
|
||||
"node -e \"require('node:http').createServer((req,res)=>{if(req.url==='/api/health'){res.setHeader('content-type','application/json');res.end(JSON.stringify({status:'ok'}));return;}res.end(process.env.PAPERCLIP_HOME)}).listen(Number(process.env.PORT), '127.0.0.1')\"";
|
||||
const config = {
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
@@ -6051,7 +6122,7 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () =>
|
||||
const delayedHmrScript = [
|
||||
"const http=require('node:http');",
|
||||
"const port=Number(process.env.PORT);",
|
||||
"http.createServer((_req,res)=>res.end('ok')).listen(port,'127.0.0.1');",
|
||||
"http.createServer((req,res)=>{if(req.url==='/api/health'){res.setHeader('content-type','application/json');res.end(JSON.stringify({status:'ok'}));return;}res.end('ok')}).listen(port,'127.0.0.1');",
|
||||
"setTimeout(()=>http.createServer((_req,res)=>res.end('hmr')).listen(port+10000,'127.0.0.1'),750);",
|
||||
"setInterval(()=>{},1000);",
|
||||
].join("");
|
||||
@@ -7192,7 +7263,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
||||
const executionWorkspaceId = randomUUID();
|
||||
const stoppedServiceId = randomUUID();
|
||||
const serviceCommand =
|
||||
"node -e \"const http=require('node:http'); const stale=process.env.STALE_HEALTH==='1'; http.createServer((req,res)=>{ if (req.url==='/api/health' && stale) { res.statusCode=503; res.end('database_unreachable'); return; } res.end('ok'); }).listen(Number(process.env.PORT), '127.0.0.1')\"";
|
||||
"node -e \"const http=require('node:http'); const stale=process.env.STALE_HEALTH==='1'; http.createServer((req,res)=>{ if (req.url==='/api/health') { if (stale) { res.statusCode=503; res.end('database_unreachable'); return; } res.setHeader('content-type','application/json'); res.end(JSON.stringify({status:'ok'})); return; } res.end('ok'); }).listen(Number(process.env.PORT), '127.0.0.1')\"";
|
||||
const scopeType = "agent";
|
||||
const scopeId = agentId;
|
||||
const reuseKey = createHash("sha256")
|
||||
|
||||
Reference in new issue
Block a user