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