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")
+60
View File
@@ -12,6 +12,16 @@ import {
} from "@paperclipai/db";
import type { Config } from "../config.js";
import { resolvePaperclipInstanceId } from "../home-paths.js";
import {
workspaceLoginHandoffPlugin,
type WorkspaceHandoffExpectedIdentity,
} from "./workspace-login-handoff-plugin.js";
import {
normalizeWorkspaceHandoffOrigin,
resolveWorkspaceHandoffLocalCompanyId,
resolveWorkspaceHandoffLocalKey,
resolveWorkspaceHandoffLocalWorkspaceId,
} from "./workspace-login-handoff.js";
export type BetterAuthSessionUser = {
id: string;
@@ -144,6 +154,34 @@ export function deriveAuthTrustedOrigins(config: Config, opts?: { listenPort?: n
return Array.from(trustedOrigins);
}
/**
* Identity a managed workspace instance compares an inbound handoff ticket
* against. Every field comes from persisted configuration or injected runtime
* identity — never from request headers — so a spoofed `X-Forwarded-Host` or
* Tailscale identity header cannot retarget a ticket. Returns null when this
* process was not started as a managed workspace, which leaves the exchange
* endpoint unregistered.
*/
export function resolveWorkspaceHandoffIdentity(
config: Config,
env: NodeJS.ProcessEnv = process.env,
): WorkspaceHandoffExpectedIdentity | null {
const key = resolveWorkspaceHandoffLocalKey(env);
if (!key) return null;
const configuredOrigin =
normalizeWorkspaceHandoffOrigin(env.PAPERCLIP_PUBLIC_URL)
?? (config.authBaseUrlMode === "explicit"
? normalizeWorkspaceHandoffOrigin(config.authPublicBaseUrl)
: null);
return {
key,
instanceId: resolvePaperclipInstanceId(),
executionWorkspaceId: resolveWorkspaceHandoffLocalWorkspaceId(env),
companyId: resolveWorkspaceHandoffLocalCompanyId(env),
origin: configuredOrigin,
};
}
export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins: string[]): BetterAuthInstance {
const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined;
const publicUrl = process.env.PAPERCLIP_PUBLIC_URL?.trim() || baseUrl;
@@ -186,6 +224,28 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins:
override: process.env.PAPERCLIP_AUTH_RATE_LIMIT_ENABLED,
}),
advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies }),
// Registered only for a managed workspace instance: the plugin is what makes
// `Open workspace` password-independent, and a control-plane instance that
// was never handed a workspace key must not expose the exchange at all.
...(resolveWorkspaceHandoffIdentity(config)
? {
plugins: [
workspaceLoginHandoffPlugin({
db,
// Re-resolved per exchange so a hot restart cannot keep validating
// against an origin the control plane has since republished.
resolveExpectedIdentity: () =>
resolveWorkspaceHandoffIdentity(config) ?? {
key: null,
instanceId: null,
executionWorkspaceId: null,
companyId: null,
origin: null,
},
}),
],
}
: {}),
};
if (!baseUrl) {
@@ -0,0 +1,127 @@
/**
* Ticket exchange, isolated from Better Auth and Express so the whole
* security matrix (replay, expiry, wrong user/origin/workspace/instance,
* missing membership, spoofed forwarded headers) is unit-testable.
*
* Every dependency is injected. The caller supplies expected identity from
* *configuration*; this module never reads request headers, which is what makes
* forwarded-header spoofing structurally impossible rather than merely checked.
*/
import {
sanitizeWorkspaceHandoffRedirectPath,
verifyWorkspaceHandoffTicket,
type WorkspaceHandoffTicketPayload,
type WorkspaceHandoffVerificationFailure,
} from "./workspace-login-handoff.js";
export type WorkspaceHandoffClonedIdentity = {
userId: string;
email: string | null;
name?: string | null;
/**
* Whether the cloned user has an active membership **in the ticket's company**.
* An unscoped "has some membership" answer would let the exchange hand out a
* session with no access to the board the caller was opening.
*/
hasActiveMembership: boolean;
};
export type WorkspaceHandoffExchangeDeps = {
/** Resolve the cloned user by the id the ticket names, scoped to its company. */
findClonedIdentity: (
input: { userId: string; companyId: string },
) => Promise<WorkspaceHandoffClonedIdentity | null>;
/**
* Record the nonce, returning false when it was already recorded.
* Must be atomic: this is the only defense against a same-window replay.
*/
reserveNonce: (input: { nonce: string; expiresAt: Date }) => Promise<boolean>;
/** Create an instance-scoped session for the verified cloned user. */
createSession: (userId: string) => Promise<void>;
};
export type WorkspaceHandoffExchangeFailureReason =
| WorkspaceHandoffVerificationFailure
| "replayed"
| "unknown_user"
| "user_mismatch"
| "missing_membership"
| "session_failed";
export type WorkspaceHandoffExchangeResult =
| { ok: true; redirectTo: string; payload: WorkspaceHandoffTicketPayload }
| { ok: false; reason: WorkspaceHandoffExchangeFailureReason; payload: WorkspaceHandoffTicketPayload | null };
/**
* HTTP status for a failed exchange.
*
* `not_configured` is the only server-fault case (the guest was started without
* a key); everything else is a rejected credential and answers 401 so an
* attacker cannot distinguish "wrong workspace" from "expired" by status alone.
*/
export function workspaceHandoffFailureStatus(reason: WorkspaceHandoffExchangeFailureReason): number {
if (reason === "not_configured") return 503;
if (reason === "session_failed") return 500;
return 401;
}
/**
* Whether a failure means the workspace itself needs repair rather than the
* caller needing a fresh ticket. The UI turns these into an explicit
* "clone is incomplete" state instead of a generic sign-in error.
*/
export function isWorkspaceHandoffRepairReason(reason: WorkspaceHandoffExchangeFailureReason): boolean {
return reason === "unknown_user" || reason === "missing_membership" || reason === "user_mismatch";
}
export async function exchangeWorkspaceHandoffTicket(input: {
ticket: string | null | undefined;
key: string | null | undefined;
expected: {
instanceId: string | null;
executionWorkspaceId: string | null;
companyId: string | null;
origin: string | null;
};
deps: WorkspaceHandoffExchangeDeps;
now?: Date;
}): Promise<WorkspaceHandoffExchangeResult> {
const verification = verifyWorkspaceHandoffTicket({
ticket: input.ticket,
key: input.key,
expected: input.expected,
now: input.now,
});
if (!verification.ok) return { ok: false, reason: verification.reason, payload: null };
const payload = verification.payload;
const identity = await input.deps.findClonedIdentity({ userId: payload.sub, companyId: payload.cid });
if (!identity) return { ok: false, reason: "unknown_user", payload };
// The email is bound into the signature, so a mismatch means the clone drifted
// from the source instance (a reused id after a reseed), not a forged ticket.
if ((identity.email ?? "").trim().toLowerCase() !== payload.email.trim().toLowerCase()) {
return { ok: false, reason: "user_mismatch", payload };
}
if (!identity.hasActiveMembership) return { ok: false, reason: "missing_membership", payload };
// Reserve last: an identity failure must stay retryable after a repair, while a
// ticket that got far enough to mint a session is burned even if that mint fails.
const reserved = await input.deps.reserveNonce({
nonce: payload.jti,
expiresAt: new Date(payload.exp * 1000),
});
if (!reserved) return { ok: false, reason: "replayed", payload };
try {
await input.deps.createSession(identity.userId);
} catch {
return { ok: false, reason: "session_failed", payload };
}
return {
ok: true,
redirectTo: sanitizeWorkspaceHandoffRedirectPath(payload.next),
payload,
};
}
@@ -0,0 +1,222 @@
/**
* Better Auth plugin that exchanges a signed workspace login handoff ticket for
* an instance-scoped session (PAP-17572).
*
* Implemented as a plugin endpoint rather than a hand-rolled Express route so
* session creation and cookie signing go through Better Auth's own supported
* path (`internalAdapter.createSession` + `setSessionCookie`) instead of this
* repository reimplementing cookie signing.
*
* The endpoint is only registered when the process was handed a per-workspace
* verification key, so a normal control-plane instance exposes no extra surface.
*/
import { and, eq } from "drizzle-orm";
import { setSessionCookie } from "better-auth/cookies";
import { createAuthEndpoint } from "better-auth/api";
import type { Session, User } from "better-auth/types";
import type { Db } from "@paperclipai/db";
import { companyMemberships } from "@paperclipai/db";
import { logger } from "../middleware/logger.js";
import {
exchangeWorkspaceHandoffTicket,
isWorkspaceHandoffRepairReason,
type WorkspaceHandoffExchangeFailureReason,
} from "./workspace-login-handoff-exchange.js";
import {
WORKSPACE_HANDOFF_EXCHANGE_PATH,
WORKSPACE_HANDOFF_TICKET_QUERY_PARAM,
workspaceHandoffKeyFingerprint,
} from "./workspace-login-handoff.js";
/**
* Identity the guest expects, resolved from persisted configuration.
*
* Deliberately a callback: it is re-read per exchange so a hot-restarted guest
* cannot keep validating against a stale origin, and it keeps request headers
* out of the comparison entirely.
*/
export type WorkspaceHandoffExpectedIdentity = {
key: string | null;
instanceId: string | null;
executionWorkspaceId: string | null;
companyId: string | null;
origin: string | null;
};
/**
* The slice of Better Auth's endpoint context this exchange uses.
*
* Declared structurally rather than importing the library's deeply generic
* `GenericEndpointContext`, whose inference depends on the plugin's own option
* types. Narrowing here keeps the handler body fully type-checked; the single
* cast at the top of the handler is the only unchecked step.
*/
type WorkspaceHandoffEndpointContext = {
query?: Record<string, unknown>;
setHeader: (name: string, value: string) => void;
redirect: (url: string) => unknown;
error: (status: string, body?: Record<string, unknown>) => unknown;
context: {
internalAdapter: {
findUserById: (userId: string) => Promise<User | null>;
createSession: (userId: string) => Promise<Session | null>;
findVerificationValue: (identifier: string) => Promise<{ identifier: string } | null>;
reserveVerificationValue: (data: {
identifier: string;
value: string;
expiresAt: Date;
}) => Promise<boolean>;
};
};
};
/** Where a rejected ticket lands the browser: the labeled snapshot-local fallback. */
function buildFallbackRedirect(input: {
baseUrl: string | undefined;
next: string;
reason: WorkspaceHandoffExchangeFailureReason;
}): string {
const target = new URL("/auth", input.baseUrl ?? "http://localhost");
target.searchParams.set("next", input.next);
target.searchParams.set("workspaceHandoffError", input.reason);
return input.baseUrl ? target.toString() : `${target.pathname}${target.search}`;
}
export function workspaceLoginHandoffPlugin(deps: {
db: Db;
resolveExpectedIdentity: () => WorkspaceHandoffExpectedIdentity;
}) {
return {
id: "paperclip-workspace-login-handoff",
endpoints: {
exchangeWorkspaceLoginHandoff: createAuthEndpoint(
WORKSPACE_HANDOFF_EXCHANGE_PATH,
{ method: "GET", requireHeaders: true },
async (endpointContext) => {
const ctx = endpointContext as unknown as WorkspaceHandoffEndpointContext;
const expected = deps.resolveExpectedIdentity();
const rawTicket = ctx.query?.[WORKSPACE_HANDOFF_TICKET_QUERY_PARAM];
const ticket = typeof rawTicket === "string" ? rawTicket : null;
// `no-store` keeps the ticket-bearing response out of shared caches and
// `no-referrer` stops the ticket URL from leaking to the landing page.
ctx.setHeader("Cache-Control", "no-store");
ctx.setHeader("Referrer-Policy", "no-referrer");
const result = await exchangeWorkspaceHandoffTicket({
ticket,
key: expected.key,
expected: {
instanceId: expected.instanceId,
executionWorkspaceId: expected.executionWorkspaceId,
companyId: expected.companyId,
origin: expected.origin,
},
deps: {
findClonedIdentity: async ({ userId, companyId }) => {
const user = await ctx.context.internalAdapter.findUserById(userId);
if (!user) return null;
// Membership lives in Paperclip's own schema, so it is read
// through the app's `db` handle rather than the Better Auth
// adapter. Scoped to the ticket's company: an unscoped check
// would accept a clone where this user belongs to some *other*
// company and hand them a session with no access to the board
// they opened. Existence, not a count.
const activeMemberships = await deps.db
.select({ id: companyMemberships.id })
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userId),
eq(companyMemberships.status, "active"),
),
)
.limit(1)
.then((rows) => rows.length);
return {
userId: user.id,
email: user.email ?? null,
name: user.name ?? null,
hasActiveMembership: activeMemberships > 0,
};
},
reserveNonce: async ({ nonce, expiresAt }) => {
const identifier = `workspace-login-handoff:${nonce}`;
// Two layers, because each covers a case the other misses. The
// lookup catches an ordinary sequential replay on any storage
// backend; the reservation is a primary-key insert, so it is the
// one that survives two exchanges racing inside the TTL. Relying
// on the reservation alone would silently depend on the
// configured adapter enforcing id uniqueness.
if (await ctx.context.internalAdapter.findVerificationValue(identifier)) return false;
return await ctx.context.internalAdapter.reserveVerificationValue({
identifier,
value: "consumed",
// Keep the record slightly past the ticket's own expiry so a
// replay attempted at the very edge of the window still loses.
expiresAt: new Date(expiresAt.getTime() + 60_000),
});
},
createSession: async (userId) => {
const user = await ctx.context.internalAdapter.findUserById(userId);
if (!user) throw new Error("cloned user disappeared between verification and session creation");
const session = await ctx.context.internalAdapter.createSession(userId);
if (!session) throw new Error("Better Auth did not return a session");
await setSessionCookie(ctx as never, { session, user });
},
},
});
if (!result.ok) {
// Audit with the nonce and reason but never the ticket itself.
logger.warn(
{
reason: result.reason,
nonce: result.payload?.jti ?? null,
executionWorkspaceId: expected.executionWorkspaceId,
instanceId: expected.instanceId,
keyFingerprint: expected.key ? workspaceHandoffKeyFingerprint(expected.key) : null,
needsRepair: isWorkspaceHandoffRepairReason(result.reason),
},
"workspace login handoff rejected",
);
if (result.reason === "not_configured" || result.reason === "session_failed") {
throw ctx.error(result.reason === "not_configured" ? "SERVICE_UNAVAILABLE" : "INTERNAL_SERVER_ERROR", {
message: "Workspace login handoff is unavailable",
code: result.reason,
});
}
throw ctx.redirect(
buildFallbackRedirect({
baseUrl: expected.origin ?? undefined,
next: result.payload?.next ?? "/",
reason: result.reason,
}),
);
}
logger.info(
{
nonce: result.payload.jti,
userId: result.payload.sub,
executionWorkspaceId: expected.executionWorkspaceId,
instanceId: expected.instanceId,
issuerInstanceId: result.payload.iss,
},
"workspace login handoff accepted",
);
// An HTTP redirect (not a client-side navigation) is what keeps the
// ticket URL out of the browser's session history.
throw ctx.redirect(
expected.origin
? new URL(result.redirectTo, expected.origin).toString()
: result.redirectTo,
);
},
),
},
};
}
+406
View File
@@ -0,0 +1,406 @@
/**
* Signed workspace login handoff (PAP-17572).
*
* A board user opening a managed workspace must not have to know which cloned
* password is current. The authenticated main control plane mints a short-lived,
* single-use ticket; the isolated workspace verifies it against its own copy of
* the signing key and creates its own instance-scoped session.
*
* Threat model notes that drive the shape below:
*
* - **Nothing browser-supplied is trusted.** The audience is the origin the
* control plane published for that runtime and the workspace compares it
* against its *configured* public origin. `Host`, `X-Forwarded-Host`,
* `X-Forwarded-Proto` and Tailscale identity headers are never consulted, so
* a spoofed forwarded header cannot retarget or authorize a ticket.
* - **Keys are per workspace.** The control plane keeps the root secret and
* hands each guest only `HMAC(root, instanceId|executionWorkspaceId)`. A
* compromised workspace therefore cannot mint a ticket for a sibling
* workspace, and the guest never sees material that would let it do so.
* - **Replay is bounded twice.** A short expiry limits the window and the
* workspace records the nonce on first use, so a captured ticket cannot be
* exchanged a second time even inside that window.
*/
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
/** Ticket envelope version. Bumped whenever the signed payload shape changes. */
export const WORKSPACE_HANDOFF_TICKET_VERSION = "wh1";
/**
* Default ticket lifetime. Long enough for a click plus a cold guest response,
* short enough that a ticket leaked through a proxy log is worthless.
*/
export const WORKSPACE_HANDOFF_TICKET_TTL_SECONDS = 90;
/** Tolerated clock skew between the control plane and the isolated workspace. */
export const WORKSPACE_HANDOFF_CLOCK_SKEW_SECONDS = 30;
/** Path the guest exposes for the exchange, relative to the Better Auth mount. */
export const WORKSPACE_HANDOFF_EXCHANGE_PATH = "/workspace-handoff/exchange";
/** Query parameter carrying the ticket. Redacted from request logs by name. */
export const WORKSPACE_HANDOFF_TICKET_QUERY_PARAM = "ticket";
const HANDOFF_KEY_ENV = "PAPERCLIP_WORKSPACE_HANDOFF_KEY";
const HANDOFF_ROOT_SECRET_ENV = "PAPERCLIP_WORKSPACE_HANDOFF_SECRET";
const READINESS_TOKEN_ENV = "PAPERCLIP_WORKSPACE_READINESS_TOKEN";
const EXECUTION_WORKSPACE_ID_ENV = "PAPERCLIP_EXECUTION_WORKSPACE_ID";
const EXECUTION_WORKSPACE_COMPANY_ID_ENV = "PAPERCLIP_EXECUTION_WORKSPACE_COMPANY_ID";
export const WORKSPACE_HANDOFF_KEY_ENV_KEY = HANDOFF_KEY_ENV;
export const WORKSPACE_READINESS_TOKEN_ENV_KEY = READINESS_TOKEN_ENV;
export const WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY = EXECUTION_WORKSPACE_ID_ENV;
export const WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY = EXECUTION_WORKSPACE_COMPANY_ID_ENV;
export const WORKSPACE_READINESS_TOKEN_HEADER = "x-paperclip-workspace-readiness-token";
export const WORKSPACE_READINESS_USER_ID_HEADER = "x-paperclip-workspace-readiness-user-id";
export const WORKSPACE_READINESS_USER_EMAIL_HEADER = "x-paperclip-workspace-readiness-user-email";
export type WorkspaceHandoffTicketPayload = {
/** Envelope version. */
v: typeof WORKSPACE_HANDOFF_TICKET_VERSION;
/** Single-use nonce; the guest records it on first exchange. */
jti: string;
/** Cloned user id the guest must sign in. */
sub: string;
/** Expected email of that cloned user, compared case-insensitively. */
email: string;
/** Execution workspace the ticket is scoped to. */
ws: string;
/**
* Company whose board this workspace represents.
*
* Bound because "the cloned user has *some* active membership" is not the
* question worth asking: a multi-company clone can satisfy it while leaving the
* signed-in user with no access to the company they were opening, which lands
* them on an empty board with no explanation.
*/
cid: string;
/** Isolated instance id the ticket is scoped to. */
iid: string;
/** Origin the control plane published for this runtime. */
aud: string;
/** Issuing instance id, recorded for audit. */
iss: string;
/** Seconds since epoch. */
iat: number;
exp: number;
/** Relative path to land on after the exchange. */
next: string;
};
export type WorkspaceHandoffVerificationFailure =
| "not_configured"
| "malformed"
| "unsupported_version"
| "bad_signature"
| "expired"
| "not_yet_valid"
| "origin_mismatch"
| "instance_mismatch"
| "workspace_mismatch"
| "company_mismatch";
export type WorkspaceHandoffVerificationResult =
| { ok: true; payload: WorkspaceHandoffTicketPayload }
| { ok: false; reason: WorkspaceHandoffVerificationFailure };
function base64UrlEncode(value: Buffer): string {
return value.toString("base64url");
}
function base64UrlDecode(value: string): Buffer | null {
// `Buffer.from` silently drops invalid characters, so round-trip to prove the
// input really was canonical base64url before trusting the bytes.
if (!/^[A-Za-z0-9_-]+$/.test(value)) return null;
const decoded = Buffer.from(value, "base64url");
if (decoded.length === 0) return null;
return base64UrlEncode(decoded) === value ? decoded : null;
}
function sign(key: string, message: string): Buffer {
return createHmac("sha256", key).update(message).digest();
}
function constantTimeMatches(expected: Buffer, provided: Buffer): boolean {
if (expected.length !== provided.length) return false;
return timingSafeEqual(expected, provided);
}
/**
* Normalize an origin for comparison: scheme + host + explicit non-default port.
* Returns null when the input is not an absolute http(s) URL, so an unparseable
* or relative value can never accidentally compare equal.
*/
export function normalizeWorkspaceHandoffOrigin(value: string | null | undefined): string | null {
const trimmed = value?.trim();
if (!trimmed) return null;
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return null;
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
return parsed.origin;
}
/**
* Reduce a caller-supplied landing target to a safe same-origin path.
*
* Anything absolute, protocol-relative, or backslash-escaped collapses to `/`.
* A handoff must never double as an open redirect.
*/
export function sanitizeWorkspaceHandoffRedirectPath(value: string | null | undefined): string {
const trimmed = value?.trim();
if (!trimmed) return "/";
if (!trimmed.startsWith("/")) return "/";
// `//host` and `/\host` are both browser-recognized protocol-relative forms.
if (trimmed.startsWith("//") || trimmed.startsWith("/\\")) return "/";
// Control characters and raw whitespace can split a `Location` header.
if (/[\u0000-\u0020\u007f]/.test(trimmed)) return "/";
return trimmed;
}
/**
* Derive the per-workspace signing key handed to one guest instance.
*
* Domain-separated from every other use of the root secret and bound to both
* identities, so a key only ever validates tickets for the exact workspace it
* was issued to.
*/
export function deriveWorkspaceHandoffKey(input: {
rootSecret: string;
instanceId: string;
executionWorkspaceId: string;
}): string {
return createHmac("sha256", input.rootSecret)
.update(`paperclip.workspace-login-handoff.${WORKSPACE_HANDOFF_TICKET_VERSION}\n`)
.update(`${input.instanceId}\n`)
.update(`${input.executionWorkspaceId}`)
.digest("hex");
}
/**
* Derive the bearer token the control plane presents to read a guest's
* protected readiness. Separate derivation from {@link deriveWorkspaceHandoffKey}
* so a probe token that leaks through an HTTP client log cannot sign tickets.
*/
export function deriveWorkspaceReadinessToken(input: {
rootSecret: string;
instanceId: string;
executionWorkspaceId: string;
}): string {
return createHmac("sha256", input.rootSecret)
.update(`paperclip.workspace-readiness-probe.${WORKSPACE_HANDOFF_TICKET_VERSION}\n`)
.update(`${input.instanceId}\n`)
.update(`${input.executionWorkspaceId}`)
.digest("hex");
}
/**
* Root secret for handoff key derivation, on the control-plane side only.
*
* A dedicated `PAPERCLIP_WORKSPACE_HANDOFF_SECRET` is preferred. When it is
* absent we derive dedicated key material from the instance's existing signing
* secret rather than disabling the feature: the alternative is that every
* already-deployed instance silently falls back to "remember the cloned
* password", which is the defect this work exists to remove. The derivation is
* one-way and domain-separated, so the handoff key is never equal to — and
* cannot be used to recover — the secret it came from.
*/
export function resolveWorkspaceHandoffRootSecret(
env: NodeJS.ProcessEnv = process.env,
): { secret: string; source: "dedicated" | "derived" } | null {
const dedicated = env[HANDOFF_ROOT_SECRET_ENV]?.trim();
if (dedicated) return { secret: dedicated, source: "dedicated" };
const fallback = env.BETTER_AUTH_SECRET?.trim() || env.PAPERCLIP_AGENT_JWT_SECRET?.trim();
if (!fallback) return null;
return {
secret: createHmac("sha256", fallback)
.update(`paperclip.workspace-login-handoff.root.${WORKSPACE_HANDOFF_TICKET_VERSION}`)
.digest("hex"),
source: "derived",
};
}
/** The verification key injected into this (guest) process, when present. */
export function resolveWorkspaceHandoffLocalKey(env: NodeJS.ProcessEnv = process.env): string | null {
return env[HANDOFF_KEY_ENV]?.trim() || null;
}
/** The readiness-probe token injected into this (guest) process, when present. */
export function resolveWorkspaceReadinessLocalToken(env: NodeJS.ProcessEnv = process.env): string | null {
return env[READINESS_TOKEN_ENV]?.trim() || null;
}
/** The execution workspace this (guest) process was provisioned for. */
export function resolveWorkspaceHandoffLocalWorkspaceId(env: NodeJS.ProcessEnv = process.env): string | null {
return env[EXECUTION_WORKSPACE_ID_ENV]?.trim() || null;
}
/** The company whose board this (guest) workspace represents. */
export function resolveWorkspaceHandoffLocalCompanyId(env: NodeJS.ProcessEnv = process.env): string | null {
return env[EXECUTION_WORKSPACE_COMPANY_ID_ENV]?.trim() || null;
}
export function issueWorkspaceHandoffTicket(input: {
key: string;
userId: string;
email: string;
executionWorkspaceId: string;
companyId: string;
instanceId: string;
origin: string;
issuerInstanceId: string;
next?: string | null;
ttlSeconds?: number;
now?: Date;
nonce?: string;
}): { ticket: string; payload: WorkspaceHandoffTicketPayload } {
const origin = normalizeWorkspaceHandoffOrigin(input.origin);
if (!origin) {
throw new Error("Workspace login handoff needs an absolute http(s) target origin");
}
const issuedAtMs = (input.now ?? new Date()).getTime();
const issuedAt = Math.floor(issuedAtMs / 1000);
const ttlSeconds = Math.max(1, Math.floor(input.ttlSeconds ?? WORKSPACE_HANDOFF_TICKET_TTL_SECONDS));
const payload: WorkspaceHandoffTicketPayload = {
v: WORKSPACE_HANDOFF_TICKET_VERSION,
jti: input.nonce ?? randomBytes(18).toString("base64url"),
sub: input.userId,
email: input.email,
ws: input.executionWorkspaceId,
cid: input.companyId,
iid: input.instanceId,
aud: origin,
iss: input.issuerInstanceId,
iat: issuedAt,
exp: issuedAt + ttlSeconds,
next: sanitizeWorkspaceHandoffRedirectPath(input.next),
};
const encodedPayload = base64UrlEncode(Buffer.from(JSON.stringify(payload), "utf8"));
const signedMessage = `${WORKSPACE_HANDOFF_TICKET_VERSION}.${encodedPayload}`;
const signature = base64UrlEncode(sign(input.key, signedMessage));
return { ticket: `${signedMessage}.${signature}`, payload };
}
/** Absolute exchange URL for a minted ticket. */
export function buildWorkspaceHandoffExchangeUrl(input: { origin: string; ticket: string }): string {
const origin = normalizeWorkspaceHandoffOrigin(input.origin);
if (!origin) throw new Error("Workspace login handoff needs an absolute http(s) target origin");
const url = new URL(`/api/auth${WORKSPACE_HANDOFF_EXCHANGE_PATH}`, origin);
url.searchParams.set(WORKSPACE_HANDOFF_TICKET_QUERY_PARAM, input.ticket);
return url.toString();
}
function isPayloadShape(value: unknown): value is WorkspaceHandoffTicketPayload {
if (!value || typeof value !== "object") return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.jti === "string" && candidate.jti.length > 0
&& typeof candidate.sub === "string" && candidate.sub.length > 0
&& typeof candidate.email === "string" && candidate.email.length > 0
&& typeof candidate.ws === "string" && candidate.ws.length > 0
&& typeof candidate.cid === "string" && candidate.cid.length > 0
&& typeof candidate.iid === "string" && candidate.iid.length > 0
&& typeof candidate.aud === "string" && candidate.aud.length > 0
&& typeof candidate.iss === "string"
&& Number.isFinite(candidate.iat)
&& Number.isFinite(candidate.exp)
&& typeof candidate.next === "string"
);
}
/**
* Verify a ticket against this workspace's own key and identity.
*
* `expected` must come from persisted configuration. Passing header-derived
* values would reintroduce exactly the spoofing path this design closes.
*/
export function verifyWorkspaceHandoffTicket(input: {
ticket: string | null | undefined;
key: string | null | undefined;
expected: {
instanceId: string | null;
executionWorkspaceId: string | null;
companyId: string | null;
origin: string | null;
};
now?: Date;
clockSkewSeconds?: number;
}): WorkspaceHandoffVerificationResult {
const key = input.key?.trim();
if (!key) return { ok: false, reason: "not_configured" };
const expectedOrigin = normalizeWorkspaceHandoffOrigin(input.expected.origin);
const expectedInstanceId = input.expected.instanceId?.trim();
const expectedWorkspaceId = input.expected.executionWorkspaceId?.trim();
const expectedCompanyId = input.expected.companyId?.trim();
// Fail closed on unresolved local identity: without it the checks below would
// degrade into "any validly signed ticket wins".
if (!expectedOrigin) return { ok: false, reason: "origin_mismatch" };
if (!expectedInstanceId) return { ok: false, reason: "instance_mismatch" };
if (!expectedWorkspaceId) return { ok: false, reason: "workspace_mismatch" };
if (!expectedCompanyId) return { ok: false, reason: "company_mismatch" };
const ticket = input.ticket?.trim();
if (!ticket) return { ok: false, reason: "malformed" };
const segments = ticket.split(".");
if (segments.length !== 3) return { ok: false, reason: "malformed" };
const [version, encodedPayload, encodedSignature] = segments as [string, string, string];
if (version !== WORKSPACE_HANDOFF_TICKET_VERSION) return { ok: false, reason: "unsupported_version" };
const providedSignature = base64UrlDecode(encodedSignature);
if (!providedSignature) return { ok: false, reason: "malformed" };
const expectedSignature = sign(key, `${version}.${encodedPayload}`);
// Signature first: never parse attacker-controlled JSON we have not authenticated.
if (!constantTimeMatches(expectedSignature, providedSignature)) return { ok: false, reason: "bad_signature" };
const decodedPayload = base64UrlDecode(encodedPayload);
if (!decodedPayload) return { ok: false, reason: "malformed" };
let parsed: unknown;
try {
parsed = JSON.parse(decodedPayload.toString("utf8"));
} catch {
return { ok: false, reason: "malformed" };
}
if (!isPayloadShape(parsed)) return { ok: false, reason: "malformed" };
if (parsed.v !== WORKSPACE_HANDOFF_TICKET_VERSION) return { ok: false, reason: "unsupported_version" };
const nowSeconds = Math.floor((input.now ?? new Date()).getTime() / 1000);
const skew = Math.max(0, Math.floor(input.clockSkewSeconds ?? WORKSPACE_HANDOFF_CLOCK_SKEW_SECONDS));
if (parsed.exp + skew < nowSeconds) return { ok: false, reason: "expired" };
if (parsed.iat - skew > nowSeconds) return { ok: false, reason: "not_yet_valid" };
if (parsed.exp <= parsed.iat) return { ok: false, reason: "malformed" };
if (normalizeWorkspaceHandoffOrigin(parsed.aud) !== expectedOrigin) {
return { ok: false, reason: "origin_mismatch" };
}
if (parsed.iid !== expectedInstanceId) return { ok: false, reason: "instance_mismatch" };
if (parsed.ws !== expectedWorkspaceId) return { ok: false, reason: "workspace_mismatch" };
if (parsed.cid !== expectedCompanyId) return { ok: false, reason: "company_mismatch" };
return {
ok: true,
payload: { ...parsed, next: sanitizeWorkspaceHandoffRedirectPath(parsed.next) },
};
}
/** Verification-key identifier safe to log: proves which key, reveals no key bytes. */
export function workspaceHandoffKeyFingerprint(key: string): string {
return createHmac("sha256", "paperclip.workspace-login-handoff.fingerprint").update(key).digest("hex").slice(0, 12);
}
/**
* Strip the ticket from a URL or URL-ish string before it reaches a log sink.
* Applied by the request logger and by the guest's own audit records.
*/
export function redactWorkspaceHandoffTicket(value: string): string {
if (!value.includes(WORKSPACE_HANDOFF_TICKET_QUERY_PARAM)) return value;
return value.replace(
new RegExp(`([?&]${WORKSPACE_HANDOFF_TICKET_QUERY_PARAM}=)[^&#\\s]+`, "gi"),
"$1[redacted]",
);
}
+5
View File
@@ -1,6 +1,7 @@
import { existsSync, lstatSync, readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { hasVerifiedWorktreeSeedManifest } from "./worktree-seed-manifest.js";
function parseEnvFile(contents: string): Record<string, string> {
const entries: Record<string, string> = {};
@@ -58,6 +59,10 @@ export function resolveWorktreeEnvFilePath(rootDir: string): string {
export function isWorktreeSeedPending(rootDir: string): boolean {
const markerDir = path.resolve(rootDir, ".paperclip");
const manifestPath = path.resolve(markerDir, "seed-manifest.json");
if (existsSync(manifestPath)) {
return !hasVerifiedWorktreeSeedManifest(manifestPath);
}
return existsSync(path.resolve(markerDir, "seed-pending"))
&& !existsSync(path.resolve(markerDir, "seed-complete"));
}
+8 -1
View File
@@ -90,6 +90,7 @@ export function errorHandler(
? err.details as Record<string, unknown>
: null;
const redactedSkillPolicyDenial = isRedactedSkillPolicyDenial(details);
const workspaceRepairPreconditionFailure = details?.code === "workspace_repair_precondition_failed";
const structuredConnectionError = new Set([
"user_authorization_required",
"grant_revoked",
@@ -113,13 +114,19 @@ export function errorHandler(
error: err.message,
...(typeof details?.code === "string" ? { code: details.code } : {}),
...(redactedSkillPolicyDenial && typeof details?.reason === "string" ? { reason: details.reason } : {}),
...(workspaceRepairPreconditionFailure && typeof details?.reason === "string" ? { reason: details.reason } : {}),
...(workspaceRepairPreconditionFailure && typeof details?.repairPhase === "string"
? { repairPhase: details.repairPhase }
: {}),
...(typeof details?.remediation === "string" || (structuredConnectionError && details?.remediation && typeof details.remediation === "object")
? { remediation: details.remediation }
: {}),
...(structuredConnectionError && details?.connection ? { connection: details.connection } : {}),
...(structuredConnectionError && details?.subject ? { subject: details.subject } : {}),
...(structuredConnectionError && typeof details?.grantId === "string" ? { grantId: details.grantId } : {}),
...(!redactedSkillPolicyDenial && err.details ? { details: err.details } : {}),
...(!redactedSkillPolicyDenial && !workspaceRepairPreconditionFailure && err.details
? { details: err.details }
: {}),
});
return;
}
+5 -2
View File
@@ -3,6 +3,7 @@ import { pinoHttp } from "pino-http";
import { HTTP_LOG_REDACT_PATHS } from "./http-log-redaction.js";
import { shouldSilenceHttpSuccessLog } from "./http-log-policy.js";
import { redactSensitive } from "./redact-sensitive.js";
import { redactWorkspaceHandoffTicket } from "../auth/workspace-login-handoff.js";
const sharedOpts = {
translateTime: "SYS:HH:MM:ss",
@@ -29,12 +30,14 @@ export const httpLogger = pinoHttp({
return "info";
},
customSuccessMessage(req, res) {
return `${req.method} ${req.url} ${res.statusCode}`;
// A workspace login handoff ticket is a bearer credential that rides in the
// query string, so the request line has to be redacted before it is logged.
return `${req.method} ${redactWorkspaceHandoffTicket(req.url ?? "")} ${res.statusCode}`;
},
customErrorMessage(req, res, err) {
const ctx = (res as any).__errorContext;
const errMsg = ctx?.error?.message || err?.message || (res as any).err?.message || "unknown error";
return `${req.method} ${req.url} ${res.statusCode} — ${errMsg}`;
return `${req.method} ${redactWorkspaceHandoffTicket(req.url ?? "")} ${res.statusCode} — ${errMsg}`;
},
customProps(req, res) {
if (res.statusCode >= 400) {
@@ -45,6 +45,10 @@ const SENSITIVE_KEYS = new Set<string>([
"browsercode",
"authorization_code",
"authorizationcode",
// The workspace login handoff ticket (PAP-17572). It is a signed bearer
// credential carried as a query parameter, so it must never reach a log line
// even though the exchange itself answers 302.
"ticket",
]);
const MAX_DEPTH = 6;
+434 -16
View File
@@ -1,3 +1,6 @@
import { spawn } from "node:child_process";
import { accessSync, constants as fsConstants, existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { and, eq } from "drizzle-orm";
import { Router, type Request, type Response } from "express";
import type { Db } from "@paperclipai/db";
@@ -11,6 +14,10 @@ import {
workspaceRuntimeControlTargetSchema,
} from "@paperclipai/shared";
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
import {
resolveCanonicalWorktreeSeedSource,
type CanonicalWorktreeSeedSource,
} from "@paperclipai/shared/worktree-seed-source";
import { validate } from "../middleware/validate.js";
import {
accessService,
@@ -45,9 +52,25 @@ import { appendWithCap } from "../adapters/utils.js";
import { environmentRuntimeService } from "../services/environment-runtime.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { runExclusiveWorkspaceRuntimeControl } from "../services/workspace-operations.js";
import { resolveManagedWorkspaceInstanceId } from "../services/managed-workspace-identity.js";
import { isVerifiedWorktreeSeedManifest } from "../worktree-seed-manifest.js";
import {
issueWorkspaceLoginHandoff,
workspaceLoginHandoffFailureStatus,
} from "../services/workspace-login-handoff-issuer.js";
import { conflict, unprocessable } from "../errors.js";
const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024;
function isReadableFile(filePath: string) {
try {
accessSync(filePath, fsConstants.R_OK);
return true;
} catch {
return false;
}
}
export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: PluginWorkerManager } = {}) {
const router = Router();
const svc = executionWorkspaceService(db);
@@ -139,6 +162,87 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
res.json(readiness);
});
/**
* Mint a single-use workspace login handoff for the calling board user.
*
* Board-only: the ticket carries a user identity, so an agent key — which has
* no user to sign in — must never be able to mint one. Nothing in the request
* body influences what the ticket is bound to; only the landing path is taken
* from the caller, and it is reduced to a same-origin path before signing.
*/
router.post("/execution-workspaces/:id/login-handoff", async (req, res) => {
const id = req.params.id as string;
assertBoard(req);
const workspace = await getAccessibleResource(req, res, svc.getById(id), "Execution workspace not found");
if (!workspace) return;
// Opening a workspace board is a runtime-control-grade action: it hands the
// caller an authenticated session inside the cloned instance.
if (!(await assertRuntimeManageAllowed(req, res, workspace.companyId))) return;
const requestedNext = typeof (req.body as { next?: unknown } | null)?.next === "string"
? (req.body as { next: string }).next
: null;
const result = await issueWorkspaceLoginHandoff({
db,
companyId: workspace.companyId,
executionWorkspace: { id: workspace.id, cwd: workspace.cwd },
actor: {
userId: req.actor.userId ?? null,
userEmail: req.actor.userEmail ?? null,
source: req.actor.source ?? null,
},
next: requestedNext,
});
if (!result.ok) {
logger.warn(
{
executionWorkspaceId: workspace.id,
reason: result.failure.reason,
detail: "detail" in result.failure ? result.failure.detail : null,
},
"workspace login handoff was not issued",
);
res.status(workspaceLoginHandoffFailureStatus(result.failure)).json({
error: "workspace_login_handoff_unavailable",
// The UI turns this into the labeled snapshot-local credential fallback
// or a repair prompt, so the machine reason has to survive the boundary.
reason: result.failure.reason,
mode: "credentials",
...("detail" in result.failure ? { detail: result.failure.detail } : {}),
...("readiness" in result.failure ? { readiness: result.failure.readiness } : {}),
});
return;
}
await logActivity(db, {
companyId: workspace.companyId,
// Auditing issuance is a requirement of the handoff design: the nonce ties
// this record to the guest's own accept/reject record. The ticket itself is
// never recorded.
action: "workspace_login_handoff_issued",
entityType: "execution_workspace",
entityId: workspace.id,
actorType: "user",
actorId: result.issuance.userId,
details: {
nonce: result.issuance.nonce,
instanceId: result.issuance.instanceId,
origin: result.issuance.origin,
expiresAt: result.issuance.expiresAt,
},
}).catch((error) => {
logger.warn({ err: error, executionWorkspaceId: workspace.id }, "failed to audit workspace login handoff");
});
res.setHeader("Cache-Control", "no-store");
res.status(201).json({
url: result.issuance.url,
expiresAt: result.issuance.expiresAt,
mode: "handoff",
});
});
router.get("/execution-workspaces/:id/workspace-operations", async (req, res) => {
const id = req.params.id as string;
const workspace = await getAccessibleResource(req, res, svc.getById(id), "Execution workspace not found");
@@ -151,7 +255,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
async function handleExecutionWorkspaceRuntimeCommand(req: Request, res: Response) {
const id = req.params.id as string;
const action = String(req.params.action ?? "").trim().toLowerCase();
if (action !== "start" && action !== "stop" && action !== "restart" && action !== "run") {
if (action !== "start" && action !== "stop" && action !== "restart" && action !== "repair" && action !== "run") {
res.status(404).json({ error: "Workspace command action not found" });
return;
}
@@ -166,14 +270,6 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
sourceIssueId: existing.sourceIssueId,
});
// Recover any managed runtime-control operation this workspace was stranded with, then
// refuse only if one is genuinely still live. Authorization above still gates the caller,
// so recovery never widens who may control the workspace.
await workspaceOperationsSvc.assertRuntimeControlAvailable({
executionWorkspaceId: existing.id,
action,
});
const workspaceCwd = existing.cwd;
if (!workspaceCwd) {
res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can run workspace commands" });
@@ -195,6 +291,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
and(
eq(projectWorkspaces.id, existing.projectWorkspaceId),
eq(projectWorkspaces.companyId, existing.companyId),
eq(projectWorkspaces.projectId, existing.projectId),
),
)
.then((rows) => rows[0] ?? null)
@@ -221,6 +318,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
const configuredServices = effectiveRuntimeConfig
? listConfiguredRuntimeServiceEntries({ workspaceRuntime: effectiveRuntimeConfig })
: [];
const repairRestartsRuntimeServices = action === "repair" && configuredServices.length > 0;
const workspaceCommand = effectiveRuntimeConfig
? findWorkspaceCommandDefinition(effectiveRuntimeConfig, target.workspaceCommandId ?? null)
: null;
@@ -267,6 +365,78 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
return;
}
let repairSeedSource: CanonicalWorktreeSeedSource | null = null;
let repairPreviousAttemptId: string | null = null;
let repairCliArgs: string[] | null = null;
if (action === "repair") {
const manifestPath = path.join(workspaceCwd, ".paperclip", "seed-manifest.json");
let manifest: {
attemptId?: unknown;
source?: { configPath?: unknown; instanceId?: unknown };
targetInstanceId?: unknown;
};
try {
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
} catch {
throw unprocessable("Workspace seed manifest is malformed; repair source identity cannot be trusted.", {
code: "workspace_repair_precondition_failed",
reason: "seed_manifest_malformed",
repairPhase: "precondition_validation",
});
}
try {
if (!projectWorkspace?.cwd) {
throw new Error("Workspace repair requires a registered base project workspace.");
}
const expectedTargetInstanceId = resolveManagedWorkspaceInstanceId(workspaceCwd);
if (!expectedTargetInstanceId) {
throw new Error("Workspace repair cannot resolve the registered target instance.");
}
repairSeedSource = resolveCanonicalWorktreeSeedSource({
registeredBaseWorkspaceCwd: projectWorkspace.cwd,
targetConfigPath: path.join(workspaceCwd, ".paperclip", "config.json"),
expectedTargetInstanceId,
manifestSource: manifest.source,
manifestTargetInstanceId: manifest.targetInstanceId,
});
repairPreviousAttemptId = typeof manifest.attemptId === "string" ? manifest.attemptId : null;
const baseWorkspaceCwd = repairSeedSource.baseWorkspaceCwd;
if (!baseWorkspaceCwd) {
throw new Error("Workspace repair source is not bound to a registered base project workspace.");
}
const cliRunner = path.join(baseWorkspaceCwd, "cli", "node_modules", "tsx", "dist", "cli.mjs");
const cliEntry = path.join(baseWorkspaceCwd, "cli", "src", "index.ts");
const cliDist = path.join(baseWorkspaceCwd, "cli", "dist", "index.js");
repairCliArgs = isReadableFile(cliRunner) && isReadableFile(cliEntry)
? [cliRunner, cliEntry]
: isReadableFile(cliDist)
? [cliDist]
: null;
if (!repairCliArgs) {
throw new Error("Workspace repair cannot find a runnable Paperclip CLI in the base workspace.");
}
} catch (error) {
throw unprocessable(
error instanceof Error ? error.message : "Workspace repair source validation failed.",
{
code: "workspace_repair_precondition_failed",
reason: "source_registration_invalid",
repairPhase: "precondition_validation",
},
);
}
}
// This check can reconcile a stale operation row, so repair source validation must
// precede it. Invalid manifest diagnostics must fail before any operation or service
// mutation, not merely before the reseed child process is spawned.
await workspaceOperationsSvc.assertRuntimeControlAvailable({
executionWorkspaceId: existing.id,
action,
});
const actor = getActorInfo(req);
const recorder = workspaceOperationsSvc.createRecorder({
companyId: existing.companyId,
@@ -327,7 +497,11 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
}
const recordRuntimeControlOperation = () => recorder.recordOperation({
phase: action === "stop" ? "workspace_teardown" : "workspace_provision",
phase: action === "stop"
? "workspace_teardown"
: action === "repair"
? "workspace_repair"
: "workspace_provision",
command: workspaceCommand?.command ?? `workspace command ${action}`,
cwd: existing.cwd,
metadata: {
@@ -339,7 +513,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
runtimeServiceId: selectedRuntimeServiceId,
serviceIndex: selectedServiceIndex,
},
run: async () => {
run: async (reportProgress) => {
const ensureWorkspaceAvailable = async () =>
await ensurePersistedExecutionWorkspaceAvailable({
base: {
@@ -429,7 +603,242 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
else stderr = appendWithCap(stderr, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS);
};
if (action === "stop" || action === "restart") {
if (action === "repair") {
type RepairPhase =
| "managed_stop"
| "precondition_validation"
| "target_backup"
| "full_reseed"
| "managed_restart"
| "readiness_validation";
const repairDiagnostics: Array<{
phase: RepairPhase;
status: "started" | "succeeded" | "failed";
at: string;
}> = [];
let repairPhase: RepairPhase = "managed_stop";
const repairPreconditionError = (
status: 409 | 422,
reason:
| "seed_manifest_malformed"
| "seed_manifest_instance_mismatch"
| "source_instance_unavailable"
| "paperclip_cli_unavailable",
message: string,
) => {
const details = {
code: "workspace_repair_precondition_failed",
reason,
repairPhase,
};
return status === 409
? conflict(message, details)
: unprocessable(message, details);
};
const reportRepairPhase = async (
phase: RepairPhase,
status: "started" | "succeeded" | "failed",
) => {
repairPhase = phase;
repairDiagnostics.push({ phase, status, at: new Date().toISOString() });
if (repairDiagnostics.length > 32) repairDiagnostics.splice(0, repairDiagnostics.length - 32);
await reportProgress({
metadata: {
repairPhase,
repairDiagnostics: [...repairDiagnostics],
databaseOnly: true,
worktreePreserved: true,
},
system: `Workspace repair ${phase}: ${status}.\n`,
});
};
try {
await reportRepairPhase("managed_stop", "started");
await stopRuntimeServicesForExecutionWorkspace({
db,
executionWorkspaceId: existing.id,
workspaceCwd,
});
await reportRepairPhase("managed_stop", "succeeded");
if (!repairSeedSource?.baseWorkspaceCwd || !repairCliArgs) {
throw new Error("Workspace repair source preflight did not complete.");
}
const manifestPath = path.join(workspaceCwd, ".paperclip", "seed-manifest.json");
const sourceConfigPath = repairSeedSource.configPath;
const baseWorkspaceCwd = repairSeedSource.baseWorkspaceCwd;
await reportRepairPhase("target_backup", "started");
const child = spawn(process.execPath, [
...repairCliArgs,
"worktree",
"reseed",
"--from-config",
sourceConfigPath,
"--to",
workspaceCwd,
"--seed-mode",
"full",
"--yes",
"--backup-target",
], {
cwd: baseWorkspaceCwd,
env: {
...process.env,
PAPERCLIP_SEED_EXPECTED_COMPANY_ID: existing.companyId,
PAPERCLIP_WORKSPACE_BASE_CWD: baseWorkspaceCwd,
PAPERCLIP_PROJECT_WORKSPACE_ID: existing.projectWorkspaceId ?? "",
},
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout?.on("data", (chunk: Buffer) => {
void onLog("stdout", chunk.toString("utf8"));
});
child.stderr?.on("data", (chunk: Buffer) => {
void onLog("stderr", chunk.toString("utf8"));
});
let repairCommandTimedOut = false;
let repairCommandForceTimeout: NodeJS.Timeout | null = null;
const repairCommandTimeout = setTimeout(() => {
repairCommandTimedOut = true;
child.kill("SIGTERM");
repairCommandForceTimeout = setTimeout(() => child.kill("SIGKILL"), 5_000);
repairCommandForceTimeout.unref?.();
}, 20 * 60_000);
repairCommandTimeout.unref?.();
let reseedObserved = false;
const manifestPoll = setInterval(() => {
if (reseedObserved || !existsSync(manifestPath)) return;
try {
const current = JSON.parse(readFileSync(manifestPath, "utf8")) as {
attemptId?: unknown;
state?: unknown;
};
if (
typeof current.attemptId === "string"
&& current.attemptId !== repairPreviousAttemptId
&& (current.state === "pending" || current.state === "running" || current.state === "failed")
) {
reseedObserved = true;
}
} catch {
// The atomic manifest writer makes this unlikely; the terminal
// read below remains authoritative.
}
}, 100);
manifestPoll.unref?.();
const exitCode = await new Promise<number>((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code) => resolve(code ?? 1));
}).finally(() => {
clearInterval(manifestPoll);
clearTimeout(repairCommandTimeout);
if (repairCommandForceTimeout) clearTimeout(repairCommandForceTimeout);
});
if (reseedObserved) {
await reportRepairPhase("target_backup", "succeeded");
await reportRepairPhase("full_reseed", "started");
}
if (exitCode !== 0) {
let seedFailurePhase: string | null = null;
try {
const failedManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
phase?: unknown;
state?: unknown;
};
seedFailurePhase = failedManifest.state === "failed" && typeof failedManifest.phase === "string"
? failedManifest.phase
: null;
} catch {
// The outer repair phase remains exact when no seed phase was persisted.
}
await reportProgress({
metadata: { seedFailurePhase },
system: seedFailurePhase ? `Workspace seed failed during ${seedFailurePhase}.\n` : null,
});
throw new Error(
repairCommandTimedOut
? `Workspace database repair command timed out during ${repairPhase}.`
: `Workspace database repair command failed during ${repairPhase}.`,
);
}
if (!reseedObserved) {
await reportRepairPhase("target_backup", "succeeded");
await reportRepairPhase("full_reseed", "started");
}
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
if (!isVerifiedWorktreeSeedManifest(manifest)) {
throw new Error("Workspace reseed returned without a verified terminal manifest.");
}
const expectedInstanceId = resolveManagedWorkspaceInstanceId(workspaceCwd);
if (manifest.targetInstanceId !== expectedInstanceId) {
throw repairPreconditionError(
422,
"seed_manifest_instance_mismatch",
"Verified seed manifest belongs to a different workspace instance.",
);
}
resolveCanonicalWorktreeSeedSource({
registeredBaseWorkspaceCwd: baseWorkspaceCwd,
targetConfigPath: path.join(workspaceCwd, ".paperclip", "config.json"),
expectedTargetInstanceId: repairSeedSource.targetInstanceId,
manifestSource: manifest.source as { configPath?: unknown; instanceId?: unknown } | undefined,
manifestTargetInstanceId: manifest.targetInstanceId,
});
await reportRepairPhase("full_reseed", "succeeded");
await reportRepairPhase("managed_restart", "started");
let startedServices: Awaited<ReturnType<typeof startRuntimeServicesForWorkspaceControl>> = [];
if (repairRestartsRuntimeServices) {
const availableWorkspace = await ensureWorkspaceAvailable();
if (!availableWorkspace) {
throw new Error("Execution workspace needs a local path before Paperclip can restart it.");
}
startedServices = await startRuntimeServicesForWorkspaceControl({
db,
actor: {
id: actor.agentId ?? null,
name: actor.actorType === "user" ? "Board" : "Agent",
companyId: existing.companyId,
},
issue: existing.sourceIssueId
? { id: existing.sourceIssueId, identifier: null, title: existing.name }
: null,
workspace: availableWorkspace,
executionWorkspaceId: existing.id,
config: {
workspaceRuntime: effectiveRuntimeConfig,
runtimeProvisionCommand:
existing.config?.runtimeProvisionCommand
?? projectPolicy?.workspaceStrategy?.runtimeProvisionCommand
?? null,
},
adapterEnv: {},
onLog,
recorder,
});
}
runtimeServiceCount = startedServices.length;
await reportRepairPhase("managed_restart", "succeeded");
await reportRepairPhase("readiness_validation", "started");
if (
repairRestartsRuntimeServices
&& (
startedServices.length === 0
|| startedServices.some((service) => service.status !== "running" || service.healthStatus !== "healthy")
)
) {
throw new Error("Managed restart did not produce healthy runtime services.");
}
await reportRepairPhase("readiness_validation", "succeeded");
} catch (error) {
await reportRepairPhase(repairPhase, "failed");
throw error;
}
} else if (action === "stop" || action === "restart") {
await stopRuntimeServicesForExecutionWorkspace({
db,
executionWorkspaceId: existing.id,
@@ -481,7 +890,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
throw error;
}
runtimeServiceCount = startedServices.length;
} else {
} else if (action !== "repair") {
runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (existing.runtimeServices?.length ?? 1) - 1) : 0;
}
@@ -504,7 +913,9 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
config: { workspaceRuntime: effectiveRuntimeConfig },
currentDesiredState,
currentServiceStates: existing.config?.serviceStates ?? null,
action,
action: action === "repair"
? repairRestartsRuntimeServices ? "start" : "stop"
: action,
serviceIndex: selectedServiceIndex,
});
const metadata = mergeExecutionWorkspaceConfig(existing.metadata as Record<string, unknown> | null, {
@@ -522,12 +933,17 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
? "Stopped execution workspace runtime services.\n"
: action === "restart"
? "Restarted execution workspace runtime services.\n"
: "Started execution workspace runtime services.\n",
: action === "repair"
? repairRestartsRuntimeServices
? "Repaired the isolated workspace database and restarted healthy runtime services.\n"
: "Repaired the isolated workspace database; no managed runtime services were configured to restart.\n"
: "Started execution workspace runtime services.\n",
metadata: {
runtimeServiceCount,
workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null,
runtimeServiceId: selectedRuntimeServiceId,
serviceIndex: selectedServiceIndex,
runtimeRestarted: action === "repair" ? repairRestartsRuntimeServices : undefined,
},
};
},
@@ -565,7 +981,9 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
// The operation is already terminal here. This also catches the recorder's own time
// budget expiring on a start that never settles, which is the one failure the inner
// handler cannot see — reconcile there too so no listener or desired state is left over.
if (action === "start" || action === "restart") await reconcileFailedRuntimeStart(error);
if (action === "start" || action === "restart" || action === "repair") {
await reconcileFailedRuntimeStart(error);
}
throw error;
}
},
+57 -4
View File
@@ -19,6 +19,13 @@ import {
type InspectDatabaseBackupHealthOptions,
} from "../services/database-backup-health.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { isManagedWorkspaceInstance, resolveWorkspaceReadiness } from "../services/workspace-readiness.js";
import {
resolveWorkspaceReadinessLocalToken,
WORKSPACE_READINESS_TOKEN_HEADER,
WORKSPACE_READINESS_USER_EMAIL_HEADER,
WORKSPACE_READINESS_USER_ID_HEADER,
} from "../auth/workspace-login-handoff.js";
import { serverVersion } from "../version.js";
function shouldExposeFullHealthDetails(
@@ -29,17 +36,33 @@ function shouldExposeFullHealthDetails(
return actorType === "board" || actorType === "agent";
}
function hasDevServerStatusToken(providedToken: string | undefined) {
const expectedToken = process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN?.trim();
function matchesSharedToken(expectedToken: string | undefined | null, providedToken: string | undefined) {
const expectedValue = expectedToken?.trim();
const token = providedToken?.trim();
if (!expectedToken || !token) return false;
if (!expectedValue || !token) return false;
const expected = Buffer.from(expectedToken);
const expected = Buffer.from(expectedValue);
const provided = Buffer.from(token);
if (expected.length !== provided.length) return false;
return timingSafeEqual(expected, provided);
}
function hasDevServerStatusToken(providedToken: string | undefined) {
return matchesSharedToken(process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN, providedToken);
}
/**
* Whether the caller may read this instance's workspace readiness.
*
* A managed workspace runs in `authenticated` mode, so its own control plane has
* no board session against it. The runtime injects a derived probe token into the
* guest and presents it here — the same shared-secret shape the dev-server
* supervisor already uses, and never a browser-supplied identity header.
*/
function hasWorkspaceReadinessToken(providedToken: string | undefined) {
return matchesSharedToken(resolveWorkspaceReadinessLocalToken(), providedToken);
}
function redactedDatabaseBackupWarning(warning: DatabaseBackupHealthWarning): DatabaseBackupHealthWarning {
const messages: Record<DatabaseBackupHealthWarning["code"], string> = {
database_backup_check_failed: "Database backup health check failed.",
@@ -147,6 +170,17 @@ export function healthRoutes(
const commit = serverInfo.git.available ? serverInfo.git.fullSha : null;
const exposeDevServerDetails =
exposeFullDetails || hasDevServerStatusToken(req.get("x-paperclip-dev-server-status-token"));
// Workspace readiness names the instance and execution workspace that
// answered, so it rides the protected responses only. Public health stays
// redacted: an anonymous caller still learns liveness and nothing else.
const exposeWorkspaceReadiness =
isManagedWorkspaceInstance()
&& (exposeFullDetails || hasWorkspaceReadinessToken(req.get(WORKSPACE_READINESS_TOKEN_HEADER)));
const requestedHandoffUserId = req.get(WORKSPACE_READINESS_USER_ID_HEADER)?.trim();
const requestedHandoffUserEmail = req.get(WORKSPACE_READINESS_USER_EMAIL_HEADER)?.trim();
const handoffSubject = requestedHandoffUserId && requestedHandoffUserEmail
? { userId: requestedHandoffUserId, email: requestedHandoffUserEmail }
: null;
if (!db) {
res.json(
@@ -173,6 +207,12 @@ export function healthRoutes(
await db.execute(sql`SELECT 1`);
} catch (error) {
logger.warn({ err: error }, "Health check database probe failed");
// Carry readiness on the unhealthy response too: the seed phase recorded on
// disk is exactly what tells an operator whether this is a half-finished
// restore or a database that died after being verified.
const workspace = exposeWorkspaceReadiness
? await resolveWorkspaceReadiness({ db, handoffSubject }).catch(() => null)
: null;
res.status(503).json({
status: "unhealthy",
version: serverVersion,
@@ -180,6 +220,7 @@ export function healthRoutes(
commit,
error: "database_unreachable",
...(exposeFullDetails ? { serverInfo } : {}),
...(workspace ? { workspace } : {}),
...(cloud ? { cloud } : {}),
});
return;
@@ -236,6 +277,13 @@ export function healthRoutes(
});
}
const workspaceReadiness = exposeWorkspaceReadiness
? await resolveWorkspaceReadiness({ db, handoffSubject }).catch((error) => {
logger.warn({ err: error }, "workspace readiness probe failed");
return null;
})
: null;
const databaseBackup = opts.databaseBackupHealth
? inspectDatabaseBackupHealth(opts.databaseBackupHealth)
: undefined;
@@ -254,6 +302,10 @@ export function healthRoutes(
...(redactedDatabaseBackup ? { databaseBackup: redactedDatabaseBackup } : {}),
...(redactedWarnings ? { warnings: redactedWarnings } : {}),
...(devServer ? { devServer } : {}),
// Token-authorized probe on an otherwise redacted response: the control
// plane needs readiness without a board session, and nothing else about
// this instance becomes visible.
...(workspaceReadiness ? { workspace: workspaceReadiness } : {}),
...(cloud ? { cloud } : {}),
});
return;
@@ -276,6 +328,7 @@ export function healthRoutes(
...(databaseBackup ? { databaseBackup } : {}),
...(warnings ? { warnings } : {}),
...(devServer ? { devServer } : {}),
...(workspaceReadiness ? { workspace: workspaceReadiness } : {}),
...(cloud ? { cloud } : {}),
});
});
+31
View File
@@ -810,6 +810,7 @@ const BOARD_ONLY_OPERATIONS = new Set([
"PATCH /api/companies/{companyId}/members/{memberId}/permissions",
"GET /api/companies/{companyId}/user-directory",
"POST /api/execution-workspaces/{id}/reconcile-branch",
"POST /api/execution-workspaces/{id}/login-handoff",
"GET /api/board-api-keys",
"POST /api/board-api-keys",
"DELETE /api/board-api-keys/{keyId}",
@@ -5329,6 +5330,36 @@ registry.registerPath({
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 422: r.unprocessable },
});
registry.registerPath({
method: "post",
path: "/api/execution-workspaces/{id}/login-handoff",
tags: ["execution-workspaces"],
summary: "Issue a single-use workspace login handoff",
description:
"Mints a short-lived, single-use ticket the isolated workspace exchanges for its own "
+ "instance-scoped session, so opening a managed workspace does not depend on a cloned "
+ "password. Board actors only. The response `url` must be navigated to, not stored: the "
+ "workspace answers it with a redirect so the ticket never enters browser history. A refusal "
+ "carries a machine `reason` and, where the control plane probed it, the workspace's own "
+ "readiness.",
request: {
params: z.object({ id: z.string() }),
body: jsonBody(
z.object({
next: z.string().optional().describe("Same-origin path to land on; anything else collapses to `/`."),
}),
),
},
responses: {
201: r.ok(),
401: r.unauthorized,
403: r.forbidden,
404: r.notFound,
409: r.conflict,
501: r.serverError,
},
});
registry.registerPath({
method: "post",
path: "/api/execution-workspaces/{id}/runtime-services/{action}",
@@ -0,0 +1,386 @@
/**
* Control-plane half of the workspace readiness/handoff contract (PAP-17572).
*
* Resolves the identity and derived key material for one managed workspace,
* injects them into the guest process, and probes the guest's *protected*
* readiness so `running / healthy` can never be published for a workspace whose
* clone, identity, or login handoff disagrees with what was expected.
*
* Identity always comes from the worktree's own recorded instance pointer, never
* from a branch name or basename heuristic, and an unresolvable identity fails
* closed (no key injected, no readiness claimed).
*/
import path from "node:path";
import { and, eq } from "drizzle-orm";
import { authUsers, companyMemberships, type Db } from "@paperclipai/db";
import type { WorkspaceReadiness, WorkspaceReadinessProbeResult } from "@paperclipai/shared";
import {
deriveWorkspaceHandoffKey,
deriveWorkspaceReadinessToken,
resolveWorkspaceHandoffRootSecret,
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";
import { logger } from "../middleware/logger.js";
import {
deriveWorktreeInstanceId,
readWorktreeInstanceId,
} from "./workspace-instance-cleanup.js";
/**
* Per-probe budget. Matched to the semantic transport probe it replaces, so
* upgrading a runtime health check to the readiness contract cannot make a
* managed start or a reuse decision slower than it was.
*/
export const WORKSPACE_READINESS_PROBE_TIMEOUT_MS = 2_000;
export type ManagedWorkspaceIdentity = {
instanceId: string;
executionWorkspaceId: string;
companyId: string;
handoffKey: string;
readinessToken: string;
/** Whether the root secret was configured explicitly or derived. */
secretSource: "dedicated" | "derived";
};
/**
* Resolve the isolated instance id a worktree actually runs as.
*
* The worktree's `.paperclip/.env` pointer is authoritative because it is what
* the guest process itself loads. The path-derived id is only a fallback for a
* worktree provisioned before the pointer existed, and the two agree by
* construction for anything Paperclip provisioned.
*/
export function resolveManagedWorkspaceInstanceId(workspaceCwd: string): string | null {
const recorded = readWorktreeInstanceId(workspaceCwd);
if (recorded) return recorded;
const derived = deriveWorktreeInstanceId(workspaceCwd);
return derived || null;
}
export function resolveManagedWorkspaceIdentity(input: {
workspaceCwd: string | null | undefined;
executionWorkspaceId: string | null | undefined;
companyId: string | null | undefined;
env?: NodeJS.ProcessEnv;
}): ManagedWorkspaceIdentity | null {
const workspaceCwd = input.workspaceCwd?.trim();
const executionWorkspaceId = input.executionWorkspaceId?.trim();
const companyId = input.companyId?.trim();
if (!workspaceCwd || !executionWorkspaceId || !companyId) return null;
const root = resolveWorkspaceHandoffRootSecret(input.env ?? process.env);
if (!root) return null;
const instanceId = resolveManagedWorkspaceInstanceId(path.resolve(workspaceCwd));
if (!instanceId) return null;
return {
instanceId,
executionWorkspaceId,
companyId,
handoffKey: deriveWorkspaceHandoffKey({ rootSecret: root.secret, instanceId, executionWorkspaceId }),
readinessToken: deriveWorkspaceReadinessToken({ rootSecret: root.secret, instanceId, executionWorkspaceId }),
secretSource: root.source,
};
}
/**
* Env injected into a managed workspace's guest process.
*
* Only the *derived* per-workspace values cross the boundary: the guest never
* receives the root secret, so a compromised workspace cannot mint a ticket for
* a sibling workspace.
*/
export function buildManagedWorkspaceGuestEnv(identity: ManagedWorkspaceIdentity): Record<string, string> {
return {
[WORKSPACE_HANDOFF_KEY_ENV_KEY]: identity.handoffKey,
[WORKSPACE_READINESS_TOKEN_ENV_KEY]: identity.readinessToken,
[WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY]: identity.executionWorkspaceId,
[WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY]: identity.companyId,
};
}
function isWorkspaceReadinessShape(value: unknown): value is WorkspaceReadiness {
if (!value || typeof value !== "object") return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.state === "string"
&& typeof candidate.databaseReady === "boolean"
&& typeof candidate.cloneDataReady === "boolean"
&& typeof candidate.authHandoffReady === "boolean"
&& (typeof candidate.authHandoffUserId === "string" || candidate.authHandoffUserId === null)
&& typeof candidate.seedState === "string"
&& (typeof candidate.companyId === "string" || candidate.companyId === null)
);
}
/**
* Read a managed workspace's protected readiness over loopback.
*
* Failure modes are distinguished so callers can tell "not reachable" from
* "reachable but serving somebody else's clone" — the second is what made a
* relocated port able to masquerade as healthy.
*/
export async function probeManagedWorkspaceReadiness(input: {
healthUrl: string;
identity: ManagedWorkspaceIdentity;
fetchImpl?: typeof fetch;
timeoutMs?: number;
handoffSubject?: { userId: string; email: string } | null;
}): Promise<WorkspaceReadinessProbeResult> {
const fetchImpl = input.fetchImpl ?? fetch;
let response: Response;
try {
response = await fetchImpl(input.healthUrl, {
headers: {
[WORKSPACE_READINESS_TOKEN_HEADER]: input.identity.readinessToken,
...(input.handoffSubject
? {
[WORKSPACE_READINESS_USER_ID_HEADER]: input.handoffSubject.userId,
[WORKSPACE_READINESS_USER_EMAIL_HEADER]: input.handoffSubject.email,
}
: {}),
},
signal: AbortSignal.timeout(input.timeoutMs ?? WORKSPACE_READINESS_PROBE_TIMEOUT_MS),
});
} catch (error) {
return {
ok: false,
reason: "unreachable",
readiness: null,
detail: error instanceof Error ? error.message : String(error),
};
}
const payload = await response.json().catch(() => null) as
| { status?: unknown; workspace?: unknown }
| null;
if (!response.ok) {
const readiness = isWorkspaceReadinessShape(payload?.workspace) ? payload.workspace : null;
return { ok: false, reason: "http_error", readiness, detail: `HTTP ${response.status}` };
}
if (payload?.status !== "ok") {
return { ok: false, reason: "unhealthy_payload", readiness: null, detail: String(payload?.status ?? "missing") };
}
if (
payload.workspace
&& typeof payload.workspace === "object"
&& !("companyId" in payload.workspace)
) {
// A guest that implements readiness but predates the company binding is not
// a legacy transport-only guest. Treat the partial contract as an identity
// disagreement so auto compatibility mode cannot publish an unopenable clone.
return {
ok: false,
reason: "identity_mismatch",
readiness: null,
detail: `expected company ${input.identity.companyId}, got missing`,
};
}
if (
input.handoffSubject
&& payload.workspace
&& typeof payload.workspace === "object"
&& !("authHandoffUserId" in payload.workspace)
) {
return {
ok: false,
reason: "identity_mismatch",
readiness: null,
detail: `expected handoff user ${input.handoffSubject.userId}, got missing`,
};
}
if (!isWorkspaceReadinessShape(payload.workspace)) {
// Either the token was rejected or the guest predates this contract. Both
// mean the control plane cannot prove user readiness, so neither may publish.
return { ok: false, reason: "readiness_missing", readiness: null, detail: null };
}
const readiness = payload.workspace;
if (
readiness.instanceId !== input.identity.instanceId
|| readiness.executionWorkspaceId !== input.identity.executionWorkspaceId
|| readiness.companyId !== input.identity.companyId
) {
return {
ok: false,
reason: "identity_mismatch",
readiness,
detail: `expected ${input.identity.instanceId}/${input.identity.executionWorkspaceId}/${input.identity.companyId}, got ${readiness.instanceId}/${readiness.executionWorkspaceId}/${readiness.companyId}`,
};
}
if (!readiness.databaseReady || !readiness.cloneDataReady || !readiness.authHandoffReady) {
return { ok: false, reason: "not_ready", readiness, detail: readiness.failurePhase };
}
if (input.handoffSubject && readiness.authHandoffUserId !== input.handoffSubject.userId) {
return {
ok: false,
reason: "identity_mismatch",
readiness,
detail: `expected handoff user ${input.handoffSubject.userId}, got ${readiness.authHandoffUserId}`,
};
}
return { ok: true, readiness };
}
/** Current board identities that must survive the clone for it to publish ready. */
export async function listManagedWorkspaceHandoffSubjects(
db: Db,
companyId: string,
): Promise<Array<{ userId: string; email: string }>> {
const rows = await db
.select({ userId: authUsers.id, email: authUsers.email })
.from(authUsers)
.innerJoin(
companyMemberships,
and(
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, authUsers.id),
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.status, "active"),
),
);
return rows.flatMap((row) => {
const userId = row.userId?.trim();
const email = row.email?.trim();
return userId && email ? [{ userId, email }] : [];
});
}
/** Prove that every current board member can use the advertised handoff. */
export async function probeManagedWorkspaceHandoffSubjects(input: {
db: Db;
healthUrl: string;
identity: ManagedWorkspaceIdentity;
fetchImpl?: typeof fetch;
}): Promise<WorkspaceReadinessProbeResult> {
const subjects = await listManagedWorkspaceHandoffSubjects(input.db, input.identity.companyId);
if (subjects.length === 0) {
return {
ok: false,
reason: "not_ready",
readiness: null,
detail: "no active board user is eligible for workspace login handoff",
};
}
const results = await Promise.all(subjects.map((handoffSubject) => probeManagedWorkspaceReadiness({
healthUrl: input.healthUrl,
identity: input.identity,
fetchImpl: input.fetchImpl,
handoffSubject,
})));
return results.find((result) => !result.ok) ?? results[0]!;
}
/**
* How strictly a managed start is gated on the guest's protected readiness.
*
* - `auto` (default): a guest that *reports* readiness must satisfy it, and a
* guest serving a different instance/workspace is always rejected. A guest
* that reports no readiness block at all — an older checkout, which a managed
* worktree can legitimately be — falls back to the semantic transport check
* and is logged. Failing that case closed would turn an upgrade lag into an
* outage of every existing workspace, which is a worse reliability outcome
* than the defect this gate exists to fix. Such a workspace is still not
* silently "usable": it has no handoff plugin, so `Open workspace` reports the
* snapshot-local credential fallback rather than pretending to be ready.
* - `strict`: a missing readiness block also blocks publication. Intended for
* deployments that have finished rolling the new guest build everywhere.
*/
export type WorkspaceReadinessGateMode = "auto" | "strict";
export function resolveWorkspaceReadinessGateMode(
env: NodeJS.ProcessEnv = process.env,
): WorkspaceReadinessGateMode {
return env.PAPERCLIP_WORKSPACE_READINESS_GATE?.trim().toLowerCase() === "strict" ? "strict" : "auto";
}
/**
* Whether a rejected probe must stop the runtime from being published.
*
* Everything except "this guest does not implement the contract" is a real
* disagreement about the clone and always blocks.
*/
export function shouldBlockPublicationOnReadiness(
result: Extract<WorkspaceReadinessProbeResult, { ok: false }>,
mode: WorkspaceReadinessGateMode = resolveWorkspaceReadinessGateMode(),
): boolean {
if (result.reason === "readiness_missing") return mode === "strict";
return true;
}
/**
* How long a freshly started workspace may take to satisfy readiness.
*
* Deliberately short. The transport readiness probe has already required a
* `status: ok` response from this guest by the time the gate runs, so the only
* thing left to absorb is a one-off blip — not a cold start. A longer budget
* would add latency to every managed start to no benefit.
*/
export const WORKSPACE_READINESS_GATE_TIMEOUT_MS = 3_000;
const WORKSPACE_READINESS_GATE_INTERVAL_MS = 250;
/**
* Poll readiness until it passes or the gate budget runs out.
*
* `identity_mismatch` and `readiness_missing` are terminal and return
* immediately: another instance owning the port, or a guest that does not
* implement the contract, are not conditions that polling can change.
*/
export async function waitForManagedWorkspaceReadiness(input: {
healthUrl: string;
identity: ManagedWorkspaceIdentity;
fetchImpl?: typeof fetch;
timeoutMs?: number;
now?: () => number;
sleep?: (ms: number) => Promise<void>;
}): Promise<WorkspaceReadinessProbeResult> {
const now = input.now ?? Date.now;
const sleep = input.sleep ?? ((ms: number) => new Promise<void>((resolve) => { setTimeout(resolve, ms); }));
const deadline = now() + (input.timeoutMs ?? WORKSPACE_READINESS_GATE_TIMEOUT_MS);
let last: WorkspaceReadinessProbeResult = {
ok: false,
reason: "unreachable",
readiness: null,
detail: "readiness gate did not run",
};
for (;;) {
last = await probeManagedWorkspaceReadiness({
healthUrl: input.healthUrl,
identity: input.identity,
fetchImpl: input.fetchImpl,
});
if (last.ok || last.reason === "identity_mismatch" || last.reason === "readiness_missing") return last;
if (now() >= deadline) return last;
await sleep(WORKSPACE_READINESS_GATE_INTERVAL_MS);
}
}
/** Log a rejected probe once, with the machine reason kept intact for operators. */
export function logManagedWorkspaceReadinessRejection(input: {
executionWorkspaceId: string;
healthUrl: string;
result: Extract<WorkspaceReadinessProbeResult, { ok: false }>;
}) {
logger.warn(
{
executionWorkspaceId: input.executionWorkspaceId,
healthUrl: input.healthUrl,
reason: input.result.reason,
detail: input.result.detail,
seedState: input.result.readiness?.seedState ?? null,
failurePhase: input.result.readiness?.failurePhase ?? null,
},
"managed workspace readiness probe rejected the runtime",
);
}
+55 -3
View File
@@ -1,6 +1,6 @@
import { and, desc, eq } from "drizzle-orm";
import { and, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { issueWorkProducts } from "@paperclipai/db";
import { issueWorkProducts, workspaceRuntimeServices } from "@paperclipai/db";
import type { IssueWorkProduct } from "@paperclipai/shared";
import { insertRowsInChunks } from "./batch-insert.js";
import type { ImportIssueWorkProductRow } from "./import-write-types.js";
@@ -33,6 +33,44 @@ function toIssueWorkProduct(row: IssueWorkProductRow): IssueWorkProduct {
};
}
/**
* Refresh runtime-service work products from the live runtime rows they point at
* (PAP-17572).
*
* A runtime URL is only valid for as long as the process holds that port. A
* managed restart can relocate it, which used to leave a user-facing preview link
* that answers with somebody else's service or nothing at all. The runtime row is
* the authoritative publication record, so it wins over the stored copy.
*
* Read-path only and deliberately non-destructive: a work product whose runtime
* row is gone keeps its recorded URL and is reported unhealthy rather than
* silently blanked, so the history of what was published survives.
*/
export function reconcileRuntimeServiceWorkProducts(
products: IssueWorkProduct[],
liveRuntimeServices: Array<{
id: string;
url: string | null;
status: string;
healthStatus: string;
}>,
): IssueWorkProduct[] {
if (products.length === 0) return products;
const liveById = new Map(liveRuntimeServices.map((service) => [service.id, service]));
return products.map((product) => {
if (product.type !== "runtime_service" || !product.runtimeServiceId) return product;
const live = liveById.get(product.runtimeServiceId);
if (!live) {
return product.healthStatus === "unhealthy" ? product : { ...product, healthStatus: "unhealthy" };
}
const isServing = live.status === "running" && live.healthStatus === "healthy";
const url = live.url ?? product.url;
const healthStatus: IssueWorkProduct["healthStatus"] = isServing ? "healthy" : "unhealthy";
if (product.url === url && product.healthStatus === healthStatus) return product;
return { ...product, url, healthStatus };
});
}
export function workProductService(db: Db) {
return {
listForIssue: async (issueId: string) => {
@@ -41,7 +79,21 @@ export function workProductService(db: Db) {
.from(issueWorkProducts)
.where(eq(issueWorkProducts.issueId, issueId))
.orderBy(desc(issueWorkProducts.isPrimary), desc(issueWorkProducts.updatedAt));
return rows.map(toIssueWorkProduct);
const products = rows.map(toIssueWorkProduct);
const runtimeServiceIds = products
.map((product) => (product.type === "runtime_service" ? product.runtimeServiceId : null))
.filter((value): value is string => Boolean(value));
if (runtimeServiceIds.length === 0) return products;
const liveRuntimeServices = await db
.select({
id: workspaceRuntimeServices.id,
url: workspaceRuntimeServices.url,
status: workspaceRuntimeServices.status,
healthStatus: workspaceRuntimeServices.healthStatus,
})
.from(workspaceRuntimeServices)
.where(inArray(workspaceRuntimeServices.id, [...new Set(runtimeServiceIds)]));
return reconcileRuntimeServiceWorkProducts(products, liveRuntimeServices);
},
getById: async (id: string) => {
@@ -174,10 +174,12 @@ describe("WorkspaceGitOperationScheduler", () => {
await vi.waitFor(() => expect(scheduler.snapshot().totals.singleFlightJoins).toBe(1));
expect(calls).toBe(1);
gate.resolve();
await expect(Promise.all([first, joined])).resolves.toEqual([
expect.objectContaining({ stdout: "shared", singleFlightJoined: false }),
expect.objectContaining({ stdout: "shared", singleFlightJoined: true }),
const results = await Promise.all([first, joined]);
expect(results).toEqual([
expect.objectContaining({ stdout: "shared" }),
expect.objectContaining({ stdout: "shared" }),
]);
expect(results.map((result) => result.singleFlightJoined).sort()).toEqual([false, true]);
shouldFail = true;
await expect(scheduler.run(scanInput(workspace, "failure"))).rejects.toMatchObject({
@@ -1,5 +1,6 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -27,6 +28,27 @@ export function deriveWorktreeInstanceId(workspacePath: string): string {
return `${prefix}-${pathHash}`;
}
/**
* The instance id a seeded worktree actually runs as, read from the pointer the
* guest process itself loads.
*
* Synchronous and null-on-anything-unexpected by design: callers use it to
* decide whether they can prove workspace identity at all, and an unsafe or
* unreadable pointer must fail closed rather than fall back to a guess.
*/
export function readWorktreeInstanceId(workspacePath: string): string | null {
const envPath = path.join(path.resolve(workspacePath), ".paperclip", ".env");
let contents: string;
try {
contents = readFileSync(envPath, "utf8");
} catch {
return null;
}
const instanceId = parseEnvContents(contents).PAPERCLIP_INSTANCE_ID?.trim();
if (!instanceId || !INSTANCE_ID_RE.test(instanceId)) return null;
return instanceId;
}
export type WorktreeInstancePointer = {
envPath: string;
envContents: string;
@@ -0,0 +1,215 @@
/**
* Issues signed workspace login handoff tickets (PAP-17572).
*
* Runs on the authenticated main control plane. Everything a ticket is bound to
* is resolved here from server-side state — the caller's authenticated identity,
* the live runtime row's published URL, and the worktree's recorded instance
* pointer — so a client cannot influence the audience, workspace, or instance a
* ticket is valid for.
*
* A ticket is only minted for a workspace that currently passes the protected
* readiness probe. Handing out a ticket for a half-restored clone would turn a
* diagnosable "clone is incomplete" state back into the opaque
* "invalid email or password" that started this incident.
*/
import { and, desc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { authUsers, instanceUserRoles, workspaceRuntimeServices } from "@paperclipai/db";
import type { WorkspaceReadiness } from "@paperclipai/shared";
import {
buildWorkspaceHandoffExchangeUrl,
issueWorkspaceHandoffTicket,
normalizeWorkspaceHandoffOrigin,
sanitizeWorkspaceHandoffRedirectPath,
WORKSPACE_HANDOFF_TICKET_TTL_SECONDS,
} from "../auth/workspace-login-handoff.js";
import { resolvePaperclipInstanceId } from "../home-paths.js";
import {
probeManagedWorkspaceReadiness,
resolveManagedWorkspaceIdentity,
} from "./managed-workspace-identity.js";
export type WorkspaceLoginHandoffActor = {
userId: string | null | undefined;
userEmail: string | null | undefined;
source: string | null | undefined;
};
export type WorkspaceLoginHandoffFailure =
| { reason: "no_board_identity" }
| { reason: "handoff_not_configured" }
| { reason: "runtime_not_running" }
| { reason: "runtime_url_unusable"; detail: string }
| { reason: "workspace_not_ready"; detail: string | null; readiness: WorkspaceReadiness | null };
export type WorkspaceLoginHandoffIssuance = {
url: string;
expiresAt: string;
/** The live runtime origin the ticket was bound to. */
origin: string;
nonce: string;
userId: string;
instanceId: string;
};
export type WorkspaceLoginHandoffResult =
| { ok: true; issuance: WorkspaceLoginHandoffIssuance }
| { ok: false; failure: WorkspaceLoginHandoffFailure };
/**
* Resolve the board user a ticket will be minted for.
*
* In `authenticated` mode this is the caller's own session identity. The
* `local_implicit` actor of a `local_trusted` instance has no auth row, so the
* instance's oldest instance admin stands in: that mode already grants
* unrestricted board access to this instance and its clones, so this widens
* nothing, and it keeps local development password-independent too.
*/
export async function resolveWorkspaceHandoffBoardIdentity(
db: Db,
actor: WorkspaceLoginHandoffActor,
): Promise<{ userId: string; email: string } | null> {
const userId = actor.userId?.trim();
const email = actor.userEmail?.trim();
if (userId && email && actor.source !== "local_implicit") {
return { userId, email };
}
if (actor.source !== "local_implicit") return null;
const admin = await db
.select({ id: authUsers.id, email: authUsers.email, createdAt: authUsers.createdAt })
.from(authUsers)
.innerJoin(
instanceUserRoles,
and(eq(instanceUserRoles.userId, authUsers.id), eq(instanceUserRoles.role, "instance_admin")),
)
.orderBy(authUsers.createdAt, authUsers.id)
.limit(1)
.then((rows) => rows[0] ?? null);
if (!admin?.email) return null;
return { userId: admin.id, email: admin.email };
}
/**
* The live runtime row a workspace is currently served from.
*
* Reading the row (rather than a stored work-product URL) is what keeps the
* handoff target correct after a port change: the row is the same source the
* runtime publication path writes.
*/
export async function resolveLiveWorkspaceRuntimeOrigin(
db: Db,
input: { companyId: string; executionWorkspaceId: string },
): Promise<{ url: string; runtimeServiceId: string } | null> {
const row = await db
.select({
id: workspaceRuntimeServices.id,
url: workspaceRuntimeServices.url,
updatedAt: workspaceRuntimeServices.updatedAt,
})
.from(workspaceRuntimeServices)
.where(
and(
eq(workspaceRuntimeServices.companyId, input.companyId),
eq(workspaceRuntimeServices.executionWorkspaceId, input.executionWorkspaceId),
eq(workspaceRuntimeServices.status, "running"),
eq(workspaceRuntimeServices.healthStatus, "healthy"),
),
)
.orderBy(desc(workspaceRuntimeServices.updatedAt))
.then((rows) => rows.find((candidate) => Boolean(candidate.url)) ?? null);
if (!row?.url) return null;
return { url: row.url, runtimeServiceId: row.id };
}
export async function issueWorkspaceLoginHandoff(input: {
db: Db;
companyId: string;
executionWorkspace: { id: string; cwd: string | null };
actor: WorkspaceLoginHandoffActor;
next?: string | null;
ttlSeconds?: number;
now?: Date;
probe?: typeof probeManagedWorkspaceReadiness;
}): Promise<WorkspaceLoginHandoffResult> {
const identity = resolveManagedWorkspaceIdentity({
workspaceCwd: input.executionWorkspace.cwd,
executionWorkspaceId: input.executionWorkspace.id,
companyId: input.companyId,
});
if (!identity) return { ok: false, failure: { reason: "handoff_not_configured" } };
const boardIdentity = await resolveWorkspaceHandoffBoardIdentity(input.db, input.actor);
if (!boardIdentity) return { ok: false, failure: { reason: "no_board_identity" } };
const live = await resolveLiveWorkspaceRuntimeOrigin(input.db, {
companyId: input.companyId,
executionWorkspaceId: input.executionWorkspace.id,
});
if (!live) return { ok: false, failure: { reason: "runtime_not_running" } };
const origin = normalizeWorkspaceHandoffOrigin(live.url);
if (!origin) {
return { ok: false, failure: { reason: "runtime_url_unusable", detail: live.url } };
}
const probe = input.probe ?? probeManagedWorkspaceReadiness;
const readinessResult = await probe({
// Probe the origin the user will actually be sent to, so a stale row or a
// reassigned port is caught before a ticket exists rather than after.
healthUrl: new URL("/api/health", origin).toString(),
identity,
handoffSubject: boardIdentity,
});
if (!readinessResult.ok) {
return {
ok: false,
failure: {
reason: "workspace_not_ready",
detail: readinessResult.detail ?? readinessResult.reason,
readiness: readinessResult.readiness,
},
};
}
const { ticket, payload } = issueWorkspaceHandoffTicket({
key: identity.handoffKey,
userId: boardIdentity.userId,
email: boardIdentity.email,
executionWorkspaceId: identity.executionWorkspaceId,
companyId: identity.companyId,
instanceId: identity.instanceId,
origin,
issuerInstanceId: resolvePaperclipInstanceId(),
next: sanitizeWorkspaceHandoffRedirectPath(input.next),
ttlSeconds: input.ttlSeconds ?? WORKSPACE_HANDOFF_TICKET_TTL_SECONDS,
now: input.now,
});
return {
ok: true,
issuance: {
url: buildWorkspaceHandoffExchangeUrl({ origin, ticket }),
expiresAt: new Date(payload.exp * 1000).toISOString(),
origin,
nonce: payload.jti,
userId: boardIdentity.userId,
instanceId: identity.instanceId,
},
};
}
/** HTTP status for a failed issuance. */
export function workspaceLoginHandoffFailureStatus(failure: WorkspaceLoginHandoffFailure): number {
switch (failure.reason) {
case "no_board_identity":
return 403;
case "handoff_not_configured":
return 501;
case "runtime_not_running":
case "runtime_url_unusable":
case "workspace_not_ready":
return 409;
}
}
+43 -16
View File
@@ -14,7 +14,7 @@ type WorkspaceOperationRow = typeof workspaceOperations.$inferSelect;
* Managed runtime control actions. Every one of these mutates the runtime rows and
* local listeners of a single execution workspace, so only one may be live at a time.
*/
const RUNTIME_CONTROL_ACTIONS = new Set(["start", "stop", "restart", "run"]);
const RUNTIME_CONTROL_ACTIONS = new Set(["start", "stop", "restart", "repair", "run"]);
/**
* Identity of this server process. A `running` runtime-control operation stamped with a
@@ -234,7 +234,10 @@ export interface WorkspaceOperationRecorder {
* timeout error is rethrown, so a hung provider can never leave an active operation.
*/
timeoutMs?: number | null;
run: () => Promise<{
run: (reportProgress: (input: {
metadata?: Record<string, unknown> | null;
system?: string | null;
}) => Promise<void>) => Promise<{
status?: WorkspaceOperationStatus;
exitCode?: number | null;
stdout?: string | null;
@@ -484,7 +487,7 @@ export function workspaceOperationService(db: Db) {
// Managed runtime controls get an ownership stamp so bounded recovery can tell a
// slow-but-live operation from one abandoned by a dead request or server process.
const runtimeControlAction = readRuntimeControlAction(recordInput.metadata);
const insertedMetadata = runtimeControlAction
let currentMetadata = runtimeControlAction
? {
...(recordInput.metadata ?? {}),
runtimeControlOwner: {
@@ -515,7 +518,7 @@ export function workspaceOperationService(db: Db) {
logStore: handle.store,
logRef: handle.logRef,
metadata: redactCurrentUserValue(
insertedMetadata,
currentMetadata,
currentUserRedactionOptions,
) as Record<string, unknown> | null,
startedAt,
@@ -532,17 +535,21 @@ export function workspaceOperationService(db: Db) {
if (runtimeControlAction) {
heartbeatTimer = setInterval(() => {
const heartbeatAt = new Date();
currentMetadata = combineMetadata(currentMetadata, {
runtimeControlOwner: {
ownerId: RUNTIME_CONTROL_OWNER_ID,
pid: process.pid,
action: runtimeControlAction,
heartbeatAt: heartbeatAt.toISOString(),
} satisfies RuntimeControlOwnerStamp,
});
void db
.update(workspaceOperations)
.set({
metadata: combineMetadata(insertedMetadata, {
runtimeControlOwner: {
ownerId: RUNTIME_CONTROL_OWNER_ID,
pid: process.pid,
action: runtimeControlAction,
heartbeatAt: heartbeatAt.toISOString(),
} satisfies RuntimeControlOwnerStamp,
}),
metadata: redactCurrentUserValue(
currentMetadata,
currentUserRedactionOptions,
) as Record<string, unknown> | null,
updatedAt: heartbeatAt,
})
.where(and(eq(workspaceOperations.id, id), eq(workspaceOperations.status, "running")))
@@ -551,9 +558,29 @@ export function workspaceOperationService(db: Db) {
heartbeatTimer.unref?.();
}
const reportProgress = async (progress: {
metadata?: Record<string, unknown> | null;
system?: string | null;
}) => {
await append("system", progress.system ?? null);
currentMetadata = combineMetadata(currentMetadata, progress.metadata);
await db
.update(workspaceOperations)
.set({
metadata: redactCurrentUserValue(
currentMetadata,
currentUserRedactionOptions,
) as Record<string, unknown> | null,
stdoutExcerpt: stdoutExcerpt || null,
stderrExcerpt: stderrExcerpt || null,
updatedAt: new Date(),
})
.where(and(eq(workspaceOperations.id, id), eq(workspaceOperations.status, "running")));
};
const timeoutMs = recordInput.timeoutMs ?? defaultRuntimeControlTimeoutMs(runtimeControlAction);
const settle = async () => {
if (!timeoutMs || timeoutMs <= 0) return await recordInput.run();
if (!timeoutMs || timeoutMs <= 0) return await recordInput.run(reportProgress);
const timeout = new Promise<never>((_resolve, reject) => {
timeoutTimer = setTimeout(
() => reject(new WorkspaceOperationTimeoutError(timeoutMs, runtimeControlAction)),
@@ -561,7 +588,7 @@ export function workspaceOperationService(db: Db) {
);
timeoutTimer.unref?.();
});
return await Promise.race([recordInput.run(), timeout]);
return await Promise.race([recordInput.run(reportProgress), timeout]);
};
try {
@@ -583,7 +610,7 @@ export function workspaceOperationService(db: Db) {
logSha256: finalized.sha256,
logCompressed: finalized.compressed,
metadata: redactCurrentUserValue(
combineMetadata(insertedMetadata, result.metadata),
combineMetadata(currentMetadata, result.metadata),
currentUserRedactionOptions,
) as Record<string, unknown> | null,
finishedAt,
@@ -613,7 +640,7 @@ export function workspaceOperationService(db: Db) {
...(runtimeControlAction
? {
metadata: redactCurrentUserValue(
combineMetadata(insertedMetadata, {
combineMetadata(currentMetadata, {
failureReason: error instanceof WorkspaceOperationTimeoutError
? "runtime_control_timeout"
: "runtime_control_error",
+296
View File
@@ -0,0 +1,296 @@
/**
* Protected workspace readiness (PAP-17572).
*
* `/api/health` answering 200 only proves a listener exists. This module answers
* the question the control plane and the UI actually need — "can a board user
* open this clone and see their data?" — from four independent signals: the
* isolated database, the versioned seed manifest, representative cloned rows,
* and the login-handoff configuration.
*
* Everything here is read-only and deliberately small: structural validation of
* a clone happens once, during provisioning or repair, not on every probe.
*
* `repairing` is intentionally not derivable here. A guest process cannot know
* that the control plane is running a managed repair against it; the UI layers
* that state on from the live workspace-operation row.
*/
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { authUsers, companies, companyMemberships, issues } from "@paperclipai/db";
import type {
WorkspaceReadiness,
WorkspaceReadinessState,
WorkspaceSeedReadinessState,
} from "@paperclipai/shared";
import {
resolveWorkspaceHandoffLocalCompanyId,
resolveWorkspaceHandoffLocalKey,
resolveWorkspaceHandoffLocalWorkspaceId,
} from "../auth/workspace-login-handoff.js";
import { resolvePaperclipInstanceId } from "../home-paths.js";
import { logger } from "../middleware/logger.js";
const WORKSPACE_SEED_MANIFEST_BASENAME = "seed-manifest.json";
const LEGACY_SEED_PENDING_BASENAME = "seed-pending";
const LEGACY_SEED_COMPLETE_BASENAME = "seed-complete";
type SeedManifestSummary = {
state: WorkspaceSeedReadinessState;
phase: string | null;
mode: "minimal" | "full" | null;
failurePhase: string | null;
};
/**
* Directory holding this instance's seed markers.
*
* `PAPERCLIP_CONFIG` is the authoritative pointer a seeded worktree is started
* with; the cwd fallback covers a guest launched without it.
*/
export function resolveWorkspaceSeedMarkerDir(env: NodeJS.ProcessEnv = process.env): string {
const configPath = env.PAPERCLIP_CONFIG?.trim();
if (configPath) return path.dirname(path.resolve(configPath));
return path.resolve(process.cwd(), ".paperclip");
}
function readSeedManifestSummary(markerDir: string): SeedManifestSummary {
const manifestPath = path.join(markerDir, WORKSPACE_SEED_MANIFEST_BASENAME);
if (existsSync(manifestPath)) {
let manifest: Record<string, unknown>;
try {
manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
} catch {
// A manifest we cannot parse is evidence of an interrupted write, which is
// a failure — never a silent "assume seeded".
return { state: "failed", phase: null, mode: null, failurePhase: "seed_manifest_unreadable" };
}
const rawState = typeof manifest.state === "string" ? manifest.state : null;
const state: WorkspaceSeedReadinessState =
rawState === "pending" || rawState === "running" || rawState === "verified" || rawState === "failed"
? rawState
: "unknown";
const phase = typeof manifest.phase === "string" ? manifest.phase : null;
const mode = manifest.seedMode === "minimal" || manifest.seedMode === "full" ? manifest.seedMode : null;
return {
state,
phase,
mode,
failurePhase: state === "failed" ? phase ?? "unknown" : null,
};
}
// Legacy markers predate the versioned manifest. `seed-complete`, or the
// absence of both markers on an already-running instance, means an adopted
// legacy clone whose completeness this probe cannot prove.
if (existsSync(path.join(markerDir, LEGACY_SEED_PENDING_BASENAME))) {
return { state: "pending", phase: "pending", mode: null, failurePhase: null };
}
if (existsSync(path.join(markerDir, LEGACY_SEED_COMPLETE_BASENAME))) {
return { state: "unknown", phase: "legacy_complete_marker", mode: null, failurePhase: null };
}
return { state: "absent", phase: null, mode: null, failurePhase: null };
}
/**
* Collapse the individual signals into one state.
*
* `degraded` is reserved for "the clone exists and was verified, but a signal
* regressed", which is the case an operator can fix with one bounded repair.
* A clone that never finished reports `provisioning`/`validating`/`failed`
* instead, so the UI never offers a repair for work that is still in progress.
*/
export function resolveWorkspaceReadinessState(input: {
databaseReady: boolean;
cloneDataReady: boolean;
authHandoffReady: boolean;
seedState: WorkspaceSeedReadinessState;
}): WorkspaceReadinessState {
if (input.seedState === "failed") return "failed";
if (input.seedState === "pending") return "provisioning";
if (input.seedState === "running") return "provisioning";
if (!input.databaseReady) return input.seedState === "verified" ? "degraded" : "provisioning";
if (!input.cloneDataReady || !input.authHandoffReady) {
return input.seedState === "verified" ? "degraded" : "validating";
}
// Cloned data and handoff both check out. A legacy or absent manifest is not
// proof of a verified seed, so surface it as validating rather than ready.
if (input.seedState !== "verified") return "validating";
return "ready";
}
/**
* Whether this process is a cloned workspace instance at all.
*
* The primary control plane has no seed manifest and no injected workspace
* identity, and reporting a hollow readiness block for it would invite consumers
* to treat "no clone" as "broken clone". Detection is by evidence on disk or
* injected identity, never by a branch-name or path heuristic.
*/
let managedWorkspaceInstanceCache: { markerDir: string; value: boolean; checkedAtMs: number } | null = null;
/**
* How long the marker-file answer is reused. `/api/health` is polled by the dev
* runner, the control plane's readiness probe and the UI, so three `existsSync`
* calls per request would be pure overhead on an instance whose answer is
* effectively static. Short enough that a manifest appearing mid-provision is
* picked up promptly.
*/
const MANAGED_WORKSPACE_DETECTION_TTL_MS = 5_000;
export function isManagedWorkspaceInstance(
env: NodeJS.ProcessEnv = process.env,
now: () => number = Date.now,
): boolean {
// Injected identity is authoritative and free to read, so it short-circuits
// ahead of any filesystem work.
if (resolveWorkspaceHandoffLocalKey(env)) return true;
if (resolveWorkspaceHandoffLocalWorkspaceId(env)) return true;
const markerDir = resolveWorkspaceSeedMarkerDir(env);
const cached = managedWorkspaceInstanceCache;
if (
cached
&& cached.markerDir === markerDir
&& now() - cached.checkedAtMs < MANAGED_WORKSPACE_DETECTION_TTL_MS
) {
return cached.value;
}
const value = existsSync(path.join(markerDir, WORKSPACE_SEED_MANIFEST_BASENAME))
|| existsSync(path.join(markerDir, LEGACY_SEED_PENDING_BASENAME))
|| existsSync(path.join(markerDir, LEGACY_SEED_COMPLETE_BASENAME));
managedWorkspaceInstanceCache = { markerDir, value, checkedAtMs: now() };
return value;
}
/** Test-only seam so a suite can change marker files without waiting out the TTL. */
export function resetManagedWorkspaceInstanceCacheForTests(): void {
managedWorkspaceInstanceCache = null;
}
export type WorkspaceReadinessDeps = {
db: Db | null | undefined;
env?: NodeJS.ProcessEnv;
handoffSubject?: { userId: string; email: string } | null;
};
/**
* Assemble this instance's readiness. Never throws: a probe that cannot read the
* database must report `databaseReady: false`, not fail the health endpoint.
*/
export async function resolveWorkspaceReadiness(deps: WorkspaceReadinessDeps): Promise<WorkspaceReadiness> {
const env = deps.env ?? process.env;
const seed = readSeedManifestSummary(resolveWorkspaceSeedMarkerDir(env));
const instanceId = resolvePaperclipInstanceId();
const executionWorkspaceId = resolveWorkspaceHandoffLocalWorkspaceId(env);
// The company this workspace's board represents. Both product probes below are
// scoped to it: "some company in the clone has issues" and "some user has some
// membership" can both be true while the company the operator is opening is
// missing or has no members, which is a workspace that must not report ready.
const companyId = resolveWorkspaceHandoffLocalCompanyId(env);
const handoffKeyPresent = Boolean(resolveWorkspaceHandoffLocalKey(env));
const handoffUserId = deps.handoffSubject?.userId.trim() || null;
const handoffUserEmail = deps.handoffSubject?.email.trim().toLowerCase() || null;
let databaseReady = false;
let cloneDataReady = false;
let clonedAdminPresent = false;
let probeFailurePhase: string | null = null;
if (deps.db) {
try {
await deps.db.execute(sql`SELECT 1`);
databaseReady = true;
} catch (error) {
logger.warn({ err: error }, "workspace readiness database probe failed");
probeFailurePhase = "database_unreachable";
}
if (databaseReady && companyId) {
try {
// One representative cloned company/issue pair proves the restore carried
// product rows, not just an empty migrated schema.
const clonedRows = await deps.db
.select({ companyId: companies.id })
.from(companies)
.innerJoin(issues, eq(issues.companyId, companies.id))
.where(eq(companies.id, companyId))
.limit(1)
.then((rows) => rows.length);
cloneDataReady = clonedRows > 0;
if (!cloneDataReady) probeFailurePhase ??= "clone_data_missing";
} catch (error) {
logger.warn({ err: error }, "workspace readiness clone-data probe failed");
probeFailurePhase ??= "clone_data_unreadable";
}
try {
// The handoff can only sign in a user who survived the clone with an
// active membership *in this workspace's company*, so readiness asserts
// that identity exists here rather than discovering it at click time.
// Existence, not a count: this runs on every protected health request and
// counting the whole join would scale with instance size for an answer
// that needs one row.
const eligibleUsers = await deps.db
.select({ userId: authUsers.id })
.from(authUsers)
.innerJoin(
companyMemberships,
and(
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, authUsers.id),
eq(companyMemberships.status, "active"),
eq(companyMemberships.companyId, companyId),
...(handoffUserId ? [eq(authUsers.id, handoffUserId)] : []),
...(handoffUserEmail ? [sql`lower(${authUsers.email}) = ${handoffUserEmail}`] : []),
),
)
.limit(1)
.then((rows) => rows.length);
clonedAdminPresent = eligibleUsers > 0;
if (!clonedAdminPresent) probeFailurePhase ??= "cloned_membership_missing";
} catch (error) {
logger.warn({ err: error }, "workspace readiness identity probe failed");
probeFailurePhase ??= "cloned_identity_unreadable";
}
}
} else {
probeFailurePhase = "database_not_configured";
}
const authHandoffReady = Boolean(
handoffKeyPresent
&& executionWorkspaceId
&& companyId
&& clonedAdminPresent,
);
if (!companyId) probeFailurePhase ??= "workspace_company_not_configured";
if (!executionWorkspaceId) probeFailurePhase ??= "workspace_identity_not_configured";
if (!handoffKeyPresent) probeFailurePhase ??= "auth_handoff_not_configured";
const state = resolveWorkspaceReadinessState({
databaseReady,
cloneDataReady,
authHandoffReady,
seedState: seed.state,
});
return {
state,
databaseReady,
cloneDataReady,
authHandoffReady,
authHandoffUserId: handoffUserId,
seedState: seed.state,
seedPhase: seed.phase,
seedMode: seed.mode,
instanceId,
executionWorkspaceId,
companyId,
// A seed-recorded failure phase is more specific than anything this probe
// can infer, so it wins.
failurePhase: seed.failurePhase ?? (state === "ready" ? null : probeFailurePhase),
};
}
@@ -6,7 +6,10 @@ import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { BrokerClient, BrokerListenerRequest } from "./runtime-exposure/broker-client.js";
import { diagnoseRuntimeListenerBinds } from "./runtime-exposure/loopback-listener.js";
import {
diagnoseRuntimeListenerBinds,
readListenerBindFacts,
} from "./runtime-exposure/loopback-listener.js";
import {
resetRuntimeServicesForTests,
setWorkspaceRuntimeExposureDepsForTests,
@@ -35,7 +38,9 @@ afterEach(async () => {
});
function serviceCommand() {
return `node -e 'const http=require("http");const p=Number(process.env.PORT);for(const q of [p,p+10000])http.createServer((_,r)=>{r.statusCode=200;r.end("ok")}).listen(q,"127.0.0.1");setInterval(()=>{},1000)'`;
// Answers `/api/health` the way a real Paperclip dev runtime does: managed
// publication requires semantic health, not just a 200 (PAP-17572).
return `node -e 'const http=require("http");const p=Number(process.env.PORT);for(const q of [p,p+10000])http.createServer((rq,r)=>{if(rq.url==="/api/health"){r.setHeader("content-type","application/json");r.end(JSON.stringify({status:"ok"}));return}r.statusCode=200;r.end("ok")}).listen(q,"127.0.0.1");setInterval(()=>{},1000)'`;
}
/**
@@ -70,8 +75,11 @@ const host = mode === "custom" ? (valueOf("--bind-host") ?? "127.0.0.1")
: mode === "lan" ? "0.0.0.0"
: "127.0.0.1";
const p = Number(process.env.PORT);
// Even a pre-exposure checkout answered /api/health semantically; these guests
// model bind behaviour, not health behaviour.
const health = (rq, r) => { if (rq.url === "/api/health") { r.setHeader("content-type", "application/json"); r.end(JSON.stringify({ status: "ok" })); return true; } return false; };
for (const q of [p, p + 10000]) {
http.createServer((_, r) => { r.statusCode = 200; r.end("ok"); }).listen(q, host);
http.createServer((rq, r) => { if (health(rq, r)) return; r.statusCode = 200; r.end("ok"); }).listen(q, host);
}
setInterval(() => {}, 1000);
`;
@@ -89,7 +97,8 @@ const argv = process.argv.slice(2);
const at = argv.indexOf("--bind-host");
const host = at >= 0 ? argv[at + 1] : "127.0.0.1";
const p = Number(process.env.PORT);
http.createServer((_, r) => { r.statusCode = 200; r.end("ok"); }).listen(p, host);
const health = (rq, r) => { if (rq.url === "/api/health") { r.setHeader("content-type", "application/json"); r.end(JSON.stringify({ status: "ok" })); return true; } return false; };
http.createServer((rq, r) => { if (health(rq, r)) return; r.statusCode = 200; r.end("ok"); }).listen(p, host);
// No host argument: Vite's own HMR listener lands on the wildcard.
http.createServer((_, r) => { r.statusCode = 426; r.end(); }).listen(p + 10000);
setInterval(() => {}, 1000);
@@ -99,8 +108,9 @@ setInterval(() => {}, 1000);
const ALWAYS_WILDCARD_GUEST = `
import http from "node:http";
const p = Number(process.env.PORT);
const health = (rq, r) => { if (rq.url === "/api/health") { r.setHeader("content-type", "application/json"); r.end(JSON.stringify({ status: "ok" })); return true; } return false; };
for (const q of [p, p + 10000]) {
http.createServer((_, r) => { r.statusCode = 200; r.end("ok"); }).listen(q, "0.0.0.0");
http.createServer((rq, r) => { if (health(rq, r)) return; r.statusCode = 200; r.end("ok"); }).listen(q, "0.0.0.0");
}
setInterval(() => {}, 1000);
`;
@@ -155,6 +165,9 @@ function createBroker() {
* "make exposure fixture honor occupied ports").
*/
async function isLoopbackPortFree(port: number): Promise<boolean> {
const facts = await readListenerBindFacts(port);
if (facts?.present) return false;
return await new Promise<boolean>((resolve) => {
const probe = net.createServer();
probe.unref();
@@ -28,7 +28,7 @@ const TERMINAL_HEARTBEAT_RUN_STATUSES = new Set([
]);
/** Runtime control actions that mutate the workspace and therefore need the lease. */
export const LEASED_WORKSPACE_RUNTIME_ACTIONS: readonly string[] = ["start", "stop", "restart"];
export const LEASED_WORKSPACE_RUNTIME_ACTIONS: readonly string[] = ["start", "stop", "restart", "repair"];
/**
* Upper bound on how long a lease survives without the owner touching it. Recovery
+162 -13
View File
@@ -36,6 +36,16 @@ import { and, desc, eq, gte, inArray, isNull, lte, ne, or } from "drizzle-orm";
import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js";
import { conflict } from "../errors.js";
import { resolveHomeAwarePath } from "../home-paths.js";
import { hasVerifiedWorktreeSeedManifest } from "../worktree-seed-manifest.js";
import {
buildManagedWorkspaceGuestEnv,
logManagedWorkspaceReadinessRejection,
probeManagedWorkspaceHandoffSubjects,
probeManagedWorkspaceReadiness,
resolveManagedWorkspaceIdentity,
shouldBlockPublicationOnReadiness,
waitForManagedWorkspaceReadiness,
} from "./managed-workspace-identity.js";
import {
createLocalServiceKey,
findLocalServiceRegistryRecordByRuntimeServiceId,
@@ -4728,14 +4738,74 @@ function resolveRuntimeServiceHealthUrl(
return url;
}
type RuntimeServiceHealthProbeInput = {
db?: Db;
serviceName?: string | null;
command?: string | null;
provider?: string | null;
port?: number | null;
/**
* Workspace identity, when the caller knows it. Supplying all three upgrades
* the probe from "the port answered with status ok" to the full protected
* readiness contract, which is what stops a relocated port or a half-restored
* clone from masquerading as healthy (PAP-17572).
*/
cwd?: string | null;
executionWorkspaceId?: string | null;
companyId?: string | null;
};
/**
* Whether a managed workspace runtime satisfies the *user* readiness contract.
*
* Returns null when this service is not an identity-resolvable managed workspace
* runtime, so non-workspace services keep their existing behavior.
*
* For a workspace runtime this *replaces* the semantic transport check rather
* than adding to it. The probe reads the same `/api/health` response the legacy
* check read, so every legacy verdict is already implied: reaching
* `readiness_missing` means the response was `200` with `status: ok` (exactly
* what the legacy check asserted), and every other rejection means it was not.
* Stacking a second request on top would double the latency of every reuse
* decision for no extra information.
*/
async function probeManagedWorkspaceRuntimeReadiness(
healthUrl: string,
input: RuntimeServiceHealthProbeInput,
): Promise<boolean | null> {
if (!isPaperclipDevRuntimeService(input)) return null;
const identity = resolveManagedWorkspaceIdentity({
workspaceCwd: input.cwd ?? null,
executionWorkspaceId: input.executionWorkspaceId ?? null,
companyId: input.companyId ?? null,
});
if (!identity) return null;
const result = await probeManagedWorkspaceReadiness({ healthUrl, identity });
const verified = !result.ok
? result
: input.db
? await probeManagedWorkspaceHandoffSubjects({ db: input.db, healthUrl, identity })
: {
ok: false as const,
reason: "not_ready" as const,
readiness: result.readiness,
detail: "control-plane database is unavailable for board identity verification",
};
if (verified.ok) return true;
logManagedWorkspaceReadinessRejection({
executionWorkspaceId: identity.executionWorkspaceId,
healthUrl,
result: verified,
});
// A guest that does not implement the readiness contract yet is not evidence of
// an unhealthy clone; it is only evidence that the contract cannot be checked.
return !shouldBlockPublicationOnReadiness(verified);
}
async function isRuntimeServiceUrlHealthy(
url: string | null,
input?: {
serviceName?: string | null;
command?: string | null;
provider?: string | null;
port?: number | null;
},
input?: RuntimeServiceHealthProbeInput,
) {
const localProbeUrl = input?.provider === "local_process" && input.port && isPaperclipDevRuntimeService(input)
? `http://127.0.0.1:${input.port}`
@@ -4744,6 +4814,10 @@ async function isRuntimeServiceUrlHealthy(
if (!probeUrl) return true;
const healthUrl = resolveRuntimeServiceHealthUrl(probeUrl, input);
if (!healthUrl) return false;
const readiness = await probeManagedWorkspaceRuntimeReadiness(healthUrl, input ?? {});
if (readiness !== null) return readiness;
try {
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(2_000) });
if (!response.ok) return false;
@@ -5015,6 +5089,7 @@ export function resolveRuntimeProvisionCommand(input: {
if (input.workspace.strategy !== "git_worktree") return "";
const stateDir = path.join(input.workspace.cwd, ".paperclip");
const manifestPath = path.join(stateDir, "seed-manifest.json");
const pendingMarker = path.join(stateDir, "seed-pending");
const completeMarker = path.join(stateDir, "seed-complete");
const provisionScript = path.join(
@@ -5022,11 +5097,11 @@ export function resolveRuntimeProvisionCommand(input: {
"scripts",
"provision-worktree-runtime.sh",
);
if (
!existsSync(pendingMarker)
|| existsSync(completeMarker)
|| !existsSync(provisionScript)
) {
let needsSeed = existsSync(pendingMarker) && !existsSync(completeMarker);
if (existsSync(manifestPath)) {
needsSeed = !hasVerifiedWorktreeSeedManifest(manifestPath);
}
if (!needsSeed || !existsSync(provisionScript)) {
return "";
}
@@ -5371,6 +5446,21 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P
env[portEnvKey] = String(port);
}
// Per-workspace handoff key, readiness token, and workspace id. Injected for
// the Paperclip dev runtime whether or not it is HTTPS-exposed, because the
// password-independent login handoff and the protected readiness probe are
// both needed for a plain-HTTP loopback workspace too (PAP-17572).
const managedWorkspaceIdentity = isPaperclipDevRuntimeService({ serviceName, command })
? resolveManagedWorkspaceIdentity({
workspaceCwd: input.workspace.cwd,
executionWorkspaceId: input.executionWorkspaceId ?? null,
companyId: input.agent.companyId,
})
: null;
if (managedWorkspaceIdentity) {
Object.assign(env, buildManagedWorkspaceGuestEnv(managedWorkspaceIdentity));
}
if (exposureConfig) {
// Paperclip dev-runtime-specific hardening. Other managed processes are
// still rejected by the broker unless /proc proves loopback-only listeners.
@@ -5426,7 +5516,14 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P
});
if (adoptedRecord) {
const adoptedUrl = adoptedRecord.url ?? backendUrl;
if (!(await isRuntimeServiceUrlHealthy(adoptedUrl, { serviceName, command }))) {
if (!(await isRuntimeServiceUrlHealthy(adoptedUrl, {
db: input.db,
serviceName,
command,
cwd: input.workspace.cwd,
executionWorkspaceId: input.executionWorkspaceId ?? null,
companyId: input.agent.companyId,
}))) {
await terminateLocalService(adoptedRecord);
await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey);
} else {
@@ -5698,6 +5795,45 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P
);
}
}
// Transport readiness only proves a listener answered. A managed workspace
// must additionally satisfy the protected readiness contract — own database,
// cloned rows, login handoff, and matching instance/workspace identity —
// before it may be published as running/healthy (PAP-17572).
if (managedWorkspaceIdentity) {
const publishHealthUrl = resolveRuntimeServiceHealthUrl(
record.port ? `http://127.0.0.1:${record.port}` : rewriteUrlHostToLoopback(record.url ?? backendUrl),
{ serviceName, command },
);
if (!publishHealthUrl) {
throw new Error("Managed workspace readiness gate could not resolve a health URL");
}
let gate = await waitForManagedWorkspaceReadiness({
healthUrl: publishHealthUrl,
identity: managedWorkspaceIdentity,
});
if (gate.ok) {
if (!record.db) {
throw new Error("Managed workspace readiness gate could not resolve the control-plane database");
}
gate = await probeManagedWorkspaceHandoffSubjects({
db: record.db,
healthUrl: publishHealthUrl,
identity: managedWorkspaceIdentity,
});
}
if (!gate.ok) {
logManagedWorkspaceReadinessRejection({
executionWorkspaceId: managedWorkspaceIdentity.executionWorkspaceId,
healthUrl: publishHealthUrl,
result: gate,
});
if (shouldBlockPublicationOnReadiness(gate)) {
throw new Error(
`Workspace is not ready to publish (${gate.reason}${gate.detail ? `: ${gate.detail}` : ""})`,
);
}
}
}
record.status = "running";
record.healthStatus = "healthy";
record.lastUsedAt = new Date().toISOString();
@@ -5952,10 +6088,14 @@ async function findHealthyRunningRuntimeService(reuseKey: string | null) {
const existing = existingId ? runtimeServicesById.get(existingId) : null;
if (!existing || existing.status !== "running") return null;
const healthInput = {
db: existing.db,
serviceName: existing.serviceName,
command: existing.command,
provider: existing.provider,
port: existing.port,
cwd: existing.cwd,
executionWorkspaceId: existing.executionWorkspaceId,
companyId: existing.companyId,
};
let healthy = await isRuntimeServiceUrlHealthy(existing.url, healthInput);
if (!healthy) {
@@ -7231,6 +7371,9 @@ export async function refreshPersistedRuntimeServiceHealth(input: {
port: workspaceRuntimeServices.port,
url: workspaceRuntimeServices.url,
healthStatus: workspaceRuntimeServices.healthStatus,
cwd: workspaceRuntimeServices.cwd,
executionWorkspaceId: workspaceRuntimeServices.executionWorkspaceId,
companyId: workspaceRuntimeServices.companyId,
})
.from(workspaceRuntimeServices)
.where(and(
@@ -7241,7 +7384,9 @@ export async function refreshPersistedRuntimeServiceHealth(input: {
));
const results = await Promise.all(rows.map(async (row) => ({
row,
healthStatus: await isRuntimeServiceUrlHealthy(row.url, row) ? "healthy" as const : "unhealthy" as const,
healthStatus: await isRuntimeServiceUrlHealthy(row.url, { ...row, db: input.db })
? "healthy" as const
: "unhealthy" as const,
})));
await Promise.all(results.map(async ({ row, healthStatus }) => {
const liveRecord = runtimeServicesById.get(row.id);
@@ -7442,10 +7587,14 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) {
backfillDecision.action === "reprovision"
|| !exposureHealthMatches
|| !(await isRuntimeServiceUrlHealthy(adoptedUrl, {
db,
serviceName: row.serviceName,
command: row.command,
provider: "local_process",
port: adoptedRecord.port ?? row.port,
cwd: row.cwd,
executionWorkspaceId: row.executionWorkspaceId ?? null,
companyId: row.companyId,
}))
) {
if (backfillDecision.action === "reprovision") backfilled += 1;
+54
View File
@@ -0,0 +1,54 @@
import { existsSync, readFileSync } from "node:fs";
const WORKTREE_SEED_MODES = new Set(["minimal", "full"]);
type SeedDiagnostic = {
phase?: unknown;
status?: unknown;
at?: unknown;
};
/**
* A manifest is readiness evidence only when the complete validation phase was
* durably recorded. A bare `state: verified` is deliberately insufficient so
* truncated or hand-edited files cannot start a partially restored workspace.
*/
export function isVerifiedWorktreeSeedManifest(value: unknown): boolean {
if (!value || typeof value !== "object") return false;
const manifest = value as Record<string, unknown>;
const source = manifest.source as Record<string, unknown> | null;
const diagnostics = manifest.diagnostics as SeedDiagnostic[] | null;
return manifest.version === 2
&& manifest.state === "verified"
&& manifest.phase === "complete"
&& typeof source?.instanceId === "string"
&& source.instanceId.length > 0
&& typeof source.configPath === "string"
&& source.configPath.length > 0
&& WORKTREE_SEED_MODES.has(String(manifest.seedMode ?? ""))
&& typeof manifest.snapshotAt === "string"
&& manifest.snapshotAt.length > 0
&& typeof manifest.migrationRevision === "string"
&& manifest.migrationRevision.length > 0
&& typeof manifest.targetInstanceId === "string"
&& manifest.targetInstanceId.length > 0
&& typeof manifest.attemptId === "string"
&& manifest.attemptId.length > 0
&& typeof manifest.startedAt === "string"
&& typeof manifest.finishedAt === "string"
&& Array.isArray(diagnostics)
&& diagnostics.some((diagnostic) => (
diagnostic?.phase === "complete"
&& diagnostic.status === "succeeded"
&& typeof diagnostic.at === "string"
));
}
export function hasVerifiedWorktreeSeedManifest(manifestPath: string): boolean {
if (!existsSync(manifestPath)) return false;
try {
return isVerifiedWorktreeSeedManifest(JSON.parse(readFileSync(manifestPath, "utf8")));
} catch {
return false;
}
}