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:
DottaandPaperclip authored and GitHub committed 2026-08-19 02:37:02 -05:00
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", () => ({
+74 -3
View File
@@ -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")