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:
parent
6aaef2998f
commit
a2bf936f9a
|
|
@ -139,6 +139,9 @@ describe("secrets CLI helpers", () => {
|
|||
delete process.env.AWS_DEFAULT_REGION;
|
||||
delete process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID;
|
||||
delete process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID;
|
||||
delete process.env.AWS_ACCESS_KEY_ID;
|
||||
delete process.env.AWS_SECRET_ACCESS_KEY;
|
||||
delete process.env.AWS_SESSION_TOKEN;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
|
|||
|
|
@ -8,13 +8,17 @@ import { eq } from "drizzle-orm";
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
authAccounts,
|
||||
authUsers,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
executionWorkspaces,
|
||||
inspectMigrations,
|
||||
issueComments,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
instanceUserRoles,
|
||||
projects,
|
||||
routines,
|
||||
routineTriggers,
|
||||
|
|
@ -27,6 +31,7 @@ import {
|
|||
markWorktreeSeedPending,
|
||||
pauseSeededScheduledRoutines,
|
||||
quarantineSeededWorktreeExecutionState,
|
||||
readWorktreeSeedManifest,
|
||||
readSourceAttachmentBody,
|
||||
rebindWorkspaceCwd,
|
||||
resolveSourceConfigPath,
|
||||
|
|
@ -35,6 +40,7 @@ import {
|
|||
resolveGitWorktreeAddArgs,
|
||||
resolvePnpmInstallInvocation,
|
||||
resolveCurrentWorktreeEndpoint,
|
||||
resolveWorktreeSeedMigrationRevision,
|
||||
resolveWorktreeSeedBackupEngine,
|
||||
resolveWorktreeMakeTargetPath,
|
||||
worktreeRepairCommand,
|
||||
|
|
@ -64,6 +70,88 @@ const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|||
const itEmbeddedPostgres = embeddedPostgresSupport.supported ? it : it.skip;
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
function mockVerifiedSeedResult() {
|
||||
return {
|
||||
backupSummary: "snapshot.sql",
|
||||
snapshotAt: "2026-08-18T00:00:00.000Z",
|
||||
migrationRevision: "0142_test.sql",
|
||||
pausedScheduledRoutines: 0,
|
||||
executionQuarantine: {
|
||||
disabledTimerHeartbeats: 0,
|
||||
resetRunningAgents: 0,
|
||||
quarantinedInProgressIssues: 0,
|
||||
unassignedTodoIssues: 0,
|
||||
unassignedReviewIssues: 0,
|
||||
stoppedProjectWorkspaceRuntimes: 0,
|
||||
stoppedExecutionWorkspaceRuntimes: 0,
|
||||
stoppedRuntimeServices: 0,
|
||||
},
|
||||
reboundWorkspaces: [],
|
||||
validation: {
|
||||
authUserCount: 1,
|
||||
credentialAccountCount: 1,
|
||||
instanceAdminCount: 1,
|
||||
activeMembershipCount: 1,
|
||||
companyCount: 1,
|
||||
issueCount: 1,
|
||||
representativeCompanyId: "00000000-0000-4000-8000-000000000001",
|
||||
representativeIssueId: "00000000-0000-4000-8000-000000000002",
|
||||
migrationRevision: "0142_test.sql",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function seedValidWorktreeSource(connectionString: string) {
|
||||
const db = createDb(connectionString);
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const now = new Date();
|
||||
await db.insert(authUsers).values({
|
||||
id: "user-existing",
|
||||
email: "existing@paperclip.ing",
|
||||
name: "Existing User",
|
||||
emailVerified: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.insert(authAccounts).values({
|
||||
id: "credential-existing",
|
||||
accountId: "existing@paperclip.ing",
|
||||
providerId: "credential",
|
||||
userId: "user-existing",
|
||||
password: "fixture-password-hash",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.insert(instanceUserRoles).values({
|
||||
userId: "user-existing",
|
||||
role: "instance_admin",
|
||||
});
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Seed Source",
|
||||
issuePrefix: "SEED",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: "user-existing",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Representative seed issue",
|
||||
status: "backlog",
|
||||
priority: "medium",
|
||||
issueNumber: 1,
|
||||
identifier: "SEED-1",
|
||||
});
|
||||
await db.$client.end({ timeout: 5 });
|
||||
return { companyId, issueId };
|
||||
}
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres worktree CLI tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
|
|
@ -400,7 +488,34 @@ describe("worktree helpers", () => {
|
|||
expect(full.nullifyColumns).toEqual({});
|
||||
});
|
||||
|
||||
it("ensure-seeded seeds once and fast-exits on the seed-complete marker", async () => {
|
||||
it("rejects a source migration journal that is ahead of the code journal", () => {
|
||||
expect(() => resolveWorktreeSeedMigrationRevision({
|
||||
status: "upToDate",
|
||||
tableCount: 1,
|
||||
availableMigrations: ["0001_initial.sql", "0002_current.sql"],
|
||||
appliedMigrations: ["0001_initial.sql", "0002_current.sql"],
|
||||
journalEntryCount: 3,
|
||||
}, "sourcePrefix")).toThrow("Migration journal is ahead of this Paperclip checkout");
|
||||
});
|
||||
|
||||
it("accepts a source migration journal that is multiple revisions behind", () => {
|
||||
expect(resolveWorktreeSeedMigrationRevision({
|
||||
status: "needsMigrations",
|
||||
tableCount: 1,
|
||||
availableMigrations: [
|
||||
"0001_initial.sql",
|
||||
"0002_applied.sql",
|
||||
"0003_pending.sql",
|
||||
"0004_pending.sql",
|
||||
],
|
||||
appliedMigrations: ["0001_initial.sql", "0002_applied.sql"],
|
||||
pendingMigrations: ["0003_pending.sql", "0004_pending.sql"],
|
||||
journalEntryCount: 2,
|
||||
reason: "pending-migrations",
|
||||
}, "sourcePrefix")).toBe("0002_applied.sql");
|
||||
});
|
||||
|
||||
it("ensure-seeded seeds once and fast-exits on the verified manifest", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-"));
|
||||
try {
|
||||
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
||||
|
|
@ -421,6 +536,7 @@ describe("worktree helpers", () => {
|
|||
fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true });
|
||||
fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`);
|
||||
fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n");
|
||||
fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`);
|
||||
fs.writeFileSync(
|
||||
path.join(targetRoot, ".paperclip", ".env"),
|
||||
|
|
@ -429,7 +545,7 @@ describe("worktree helpers", () => {
|
|||
markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath });
|
||||
|
||||
const seedDatabase = vi.fn().mockResolvedValue({
|
||||
backupSummary: "snapshot.sql",
|
||||
...mockVerifiedSeedResult(),
|
||||
pausedScheduledRoutines: 2,
|
||||
executionQuarantine: {
|
||||
disabledTimerHeartbeats: 1,
|
||||
|
|
@ -441,15 +557,14 @@ describe("worktree helpers", () => {
|
|||
stoppedExecutionWorkspaceRuntimes: 0,
|
||||
stoppedRuntimeServices: 0,
|
||||
},
|
||||
reboundWorkspaces: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
ensureWorktreeSeeded({ config: targetConfigPath }, { seedDatabase }),
|
||||
ensureWorktreeSeeded({ config: targetConfigPath, fromConfig: sourceConfigPath }, { seedDatabase }),
|
||||
).resolves.toMatchObject({ seeded: true, reason: "seeded" });
|
||||
await expect(
|
||||
ensureWorktreeSeeded({ config: targetConfigPath }, { seedDatabase }),
|
||||
).resolves.toEqual({ seeded: false, reason: "complete_marker" });
|
||||
).resolves.toEqual({ seeded: false, reason: "verified_manifest" });
|
||||
|
||||
expect(seedDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(seedDatabase).toHaveBeenCalledWith(expect.objectContaining({
|
||||
|
|
@ -458,12 +573,127 @@ describe("worktree helpers", () => {
|
|||
instanceId: "ensure-seeded-test",
|
||||
}));
|
||||
expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-pending"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-complete"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-complete"))).toBe(false);
|
||||
expect(readWorktreeSeedManifest(targetConfigPath)).toMatchObject({
|
||||
version: 2,
|
||||
state: "verified",
|
||||
phase: "complete",
|
||||
migrationRevision: "0142_test.sql",
|
||||
targetInstanceId: "ensure-seeded-test",
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("managed ensure-seeded derives a valid source from the registered base workspace", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-managed-seed-"));
|
||||
try {
|
||||
const baseRoot = path.join(tempRoot, "base");
|
||||
const sourceConfigPath = path.join(baseRoot, ".paperclip", "config.json");
|
||||
const targetRoot = path.join(tempRoot, "worktree");
|
||||
const targetConfigPath = path.join(targetRoot, ".paperclip", "config.json");
|
||||
const targetPaths = resolveWorktreeLocalPaths({
|
||||
cwd: targetRoot,
|
||||
homeDir: path.join(tempRoot, "worktree-home"),
|
||||
instanceId: "managed-target",
|
||||
});
|
||||
const sourceConfig = buildSourceConfig();
|
||||
const targetConfig = buildWorktreeConfig({
|
||||
sourceConfig,
|
||||
paths: targetPaths,
|
||||
serverPort: 3195,
|
||||
databasePort: 54995,
|
||||
});
|
||||
fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true });
|
||||
fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`);
|
||||
fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=managed-source\n");
|
||||
fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`);
|
||||
fs.writeFileSync(
|
||||
path.join(path.dirname(targetConfigPath), ".env"),
|
||||
`PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=managed-target\n`,
|
||||
);
|
||||
markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath });
|
||||
const seedDatabase = vi.fn().mockResolvedValue(mockVerifiedSeedResult());
|
||||
|
||||
await expect(ensureWorktreeSeeded({
|
||||
config: targetConfigPath,
|
||||
registeredBaseWorkspaceCwd: baseRoot,
|
||||
registeredProjectWorkspaceId: "project-workspace-1",
|
||||
expectedCompanyId: "company-1",
|
||||
}, { seedDatabase })).resolves.toMatchObject({ seeded: true, reason: "seeded" });
|
||||
|
||||
expect(seedDatabase).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sourceConfigPath,
|
||||
expectedCompanyId: "company-1",
|
||||
}));
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["sibling", "foreign_instance", "symlink", "instance_mismatch"] as const)(
|
||||
"managed ensure-seeded rejects a %s manifest source before lock or seed mutation",
|
||||
async (variant) => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), `paperclip-worktree-managed-${variant}-`));
|
||||
try {
|
||||
const baseRoot = path.join(tempRoot, "base");
|
||||
const canonicalSource = path.join(baseRoot, ".paperclip", "config.json");
|
||||
const targetRoot = path.join(tempRoot, "worktree");
|
||||
const targetConfigPath = path.join(targetRoot, ".paperclip", "config.json");
|
||||
const attackerRoot = path.join(tempRoot, variant);
|
||||
const attackerConfig = path.join(attackerRoot, "config.json");
|
||||
fs.mkdirSync(path.dirname(canonicalSource), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true });
|
||||
fs.mkdirSync(attackerRoot, { recursive: true });
|
||||
fs.writeFileSync(canonicalSource, `${JSON.stringify(buildSourceConfig())}\n`);
|
||||
fs.writeFileSync(path.join(path.dirname(canonicalSource), ".env"), "PAPERCLIP_INSTANCE_ID=registered-source\n");
|
||||
fs.writeFileSync(targetConfigPath, `${JSON.stringify(buildSourceConfig())}\n`);
|
||||
fs.writeFileSync(
|
||||
path.join(path.dirname(targetConfigPath), ".env"),
|
||||
`PAPERCLIP_HOME=${path.join(tempRoot, "worktree-home")}\nPAPERCLIP_INSTANCE_ID=managed-target\n`,
|
||||
);
|
||||
fs.writeFileSync(attackerConfig, `${JSON.stringify(buildSourceConfig())}\n`);
|
||||
fs.writeFileSync(
|
||||
path.join(attackerRoot, ".env"),
|
||||
`PAPERCLIP_INSTANCE_ID=${variant === "foreign_instance" ? "foreign" : "registered-source"}\n`,
|
||||
);
|
||||
const diagnosticPath = variant === "instance_mismatch"
|
||||
? canonicalSource
|
||||
: variant === "symlink"
|
||||
? path.join(attackerRoot, "source-link.json")
|
||||
: attackerConfig;
|
||||
if (variant === "symlink") fs.symlinkSync(canonicalSource, diagnosticPath);
|
||||
markWorktreeSeedPending({
|
||||
configPath: targetConfigPath,
|
||||
sourceConfigPath: diagnosticPath,
|
||||
targetInstanceId: "managed-target",
|
||||
});
|
||||
if (variant === "instance_mismatch") {
|
||||
const manifest = readWorktreeSeedManifest(targetConfigPath)!;
|
||||
fs.writeFileSync(
|
||||
path.join(path.dirname(targetConfigPath), "seed-manifest.json"),
|
||||
JSON.stringify({ ...manifest, source: { ...manifest.source, instanceId: "foreign" } }),
|
||||
);
|
||||
}
|
||||
const seedDatabase = vi.fn();
|
||||
|
||||
await expect(ensureWorktreeSeeded({
|
||||
config: targetConfigPath,
|
||||
registeredBaseWorkspaceCwd: baseRoot,
|
||||
registeredProjectWorkspaceId: "project-workspace-1",
|
||||
expectedCompanyId: "company-1",
|
||||
}, { seedDatabase })).rejects.toThrow();
|
||||
|
||||
expect(seedDatabase).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed.lock"))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("ensure-seeded keeps the pending marker when seeding fails", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-failure-"));
|
||||
try {
|
||||
|
|
@ -485,6 +715,7 @@ describe("worktree helpers", () => {
|
|||
fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true });
|
||||
fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`);
|
||||
fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n");
|
||||
fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`);
|
||||
fs.writeFileSync(
|
||||
path.join(targetRoot, ".paperclip", ".env"),
|
||||
|
|
@ -494,12 +725,16 @@ describe("worktree helpers", () => {
|
|||
|
||||
await expect(
|
||||
ensureWorktreeSeeded(
|
||||
{ config: targetConfigPath },
|
||||
{ config: targetConfigPath, fromConfig: sourceConfigPath },
|
||||
{ seedDatabase: vi.fn().mockRejectedValue(new Error("seed failed")) },
|
||||
),
|
||||
).rejects.toThrow("seed failed");
|
||||
|
||||
expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-pending"))).toBe(true);
|
||||
expect(readWorktreeSeedManifest(targetConfigPath)).toMatchObject({
|
||||
state: "failed",
|
||||
phase: "pending",
|
||||
});
|
||||
expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-pending"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-complete"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed.lock"))).toBe(false);
|
||||
} finally {
|
||||
|
|
@ -528,6 +763,7 @@ describe("worktree helpers", () => {
|
|||
fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true });
|
||||
fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`);
|
||||
fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n");
|
||||
fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`);
|
||||
fs.writeFileSync(
|
||||
path.join(targetRoot, ".paperclip", ".env"),
|
||||
|
|
@ -537,31 +773,17 @@ describe("worktree helpers", () => {
|
|||
|
||||
const seedDatabase = vi.fn(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return {
|
||||
backupSummary: "snapshot.sql",
|
||||
pausedScheduledRoutines: 0,
|
||||
executionQuarantine: {
|
||||
disabledTimerHeartbeats: 0,
|
||||
resetRunningAgents: 0,
|
||||
quarantinedInProgressIssues: 0,
|
||||
unassignedTodoIssues: 0,
|
||||
unassignedReviewIssues: 0,
|
||||
stoppedProjectWorkspaceRuntimes: 0,
|
||||
stoppedExecutionWorkspaceRuntimes: 0,
|
||||
stoppedRuntimeServices: 0,
|
||||
},
|
||||
reboundWorkspaces: [],
|
||||
};
|
||||
return mockVerifiedSeedResult();
|
||||
});
|
||||
|
||||
const results = await Promise.all([
|
||||
ensureWorktreeSeeded({ config: targetConfigPath }, { seedDatabase }),
|
||||
ensureWorktreeSeeded({ config: targetConfigPath }, { seedDatabase }),
|
||||
ensureWorktreeSeeded({ config: targetConfigPath, fromConfig: sourceConfigPath }, { seedDatabase }),
|
||||
ensureWorktreeSeeded({ config: targetConfigPath, fromConfig: sourceConfigPath }, { seedDatabase }),
|
||||
]);
|
||||
|
||||
expect(results).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ seeded: true, reason: "seeded" }),
|
||||
{ seeded: false, reason: "complete_marker" },
|
||||
{ seeded: false, reason: "verified_manifest" },
|
||||
]));
|
||||
expect(seedDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed.lock"))).toBe(false);
|
||||
|
|
@ -570,6 +792,59 @@ describe("worktree helpers", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("records an interrupted phase before retrying to a verified terminal state", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-interrupted-seed-"));
|
||||
try {
|
||||
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
||||
const targetRoot = path.join(tempRoot, "worktree");
|
||||
const targetConfigPath = path.join(targetRoot, ".paperclip", "config.json");
|
||||
const targetPaths = resolveWorktreeLocalPaths({
|
||||
cwd: targetRoot,
|
||||
homeDir: path.join(tempRoot, "worktree-home"),
|
||||
instanceId: "interrupted-seed",
|
||||
});
|
||||
const sourceConfig = buildSourceConfig();
|
||||
const targetConfig = buildWorktreeConfig({
|
||||
sourceConfig,
|
||||
paths: targetPaths,
|
||||
serverPort: 3196,
|
||||
databasePort: 54996,
|
||||
});
|
||||
fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true });
|
||||
fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`);
|
||||
fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n");
|
||||
fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`);
|
||||
fs.writeFileSync(
|
||||
path.join(targetRoot, ".paperclip", ".env"),
|
||||
`PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`,
|
||||
);
|
||||
markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath });
|
||||
const interrupted = readWorktreeSeedManifest(targetConfigPath)!;
|
||||
fs.writeFileSync(
|
||||
path.join(targetRoot, ".paperclip", "seed-manifest.json"),
|
||||
`${JSON.stringify({ ...interrupted, state: "running", phase: "restore" }, null, 2)}\n`,
|
||||
);
|
||||
|
||||
await expect(ensureWorktreeSeeded(
|
||||
{ config: targetConfigPath, fromConfig: sourceConfigPath },
|
||||
{ seedDatabase: vi.fn().mockResolvedValue(mockVerifiedSeedResult()) },
|
||||
)).resolves.toMatchObject({ seeded: true, reason: "seeded" });
|
||||
|
||||
const verified = readWorktreeSeedManifest(targetConfigPath)!;
|
||||
expect(verified.state).toBe("verified");
|
||||
expect(verified.diagnostics).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
phase: "restore",
|
||||
status: "failed",
|
||||
message: "The previous seed attempt ended without a terminal result.",
|
||||
}),
|
||||
]));
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed instead of racing to reclaim a stale seed lock", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-stale-lock-"));
|
||||
try {
|
||||
|
|
@ -969,7 +1244,7 @@ describe("worktree helpers", () => {
|
|||
});
|
||||
|
||||
itEmbeddedPostgres(
|
||||
"seeds authenticated users into minimally cloned worktree instances",
|
||||
"seeds a source whose migration journal is behind the code journal",
|
||||
async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-auth-seed-"));
|
||||
const worktreeRoot = path.join(tempRoot, "PAP-999-auth-seed");
|
||||
|
|
@ -983,15 +1258,23 @@ describe("worktree helpers", () => {
|
|||
const sourceDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-auth-source-");
|
||||
|
||||
try {
|
||||
await seedValidWorktreeSource(sourceDb.connectionString);
|
||||
const sourceDbClient = createDb(sourceDb.connectionString);
|
||||
await sourceDbClient.insert(authUsers).values({
|
||||
id: "user-existing",
|
||||
email: "existing@paperclip.ing",
|
||||
name: "Existing User",
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await sourceDbClient.$client.unsafe(`
|
||||
DELETE FROM "drizzle"."__drizzle_migrations"
|
||||
WHERE "id" = (
|
||||
SELECT max("id") FROM "drizzle"."__drizzle_migrations"
|
||||
)
|
||||
`);
|
||||
await sourceDbClient.$client.end({ timeout: 5 });
|
||||
const laggingMigrationState = await inspectMigrations(sourceDb.connectionString);
|
||||
expect(laggingMigrationState.status).toBe("needsMigrations");
|
||||
if (laggingMigrationState.status !== "needsMigrations") {
|
||||
throw new Error("Expected the source migration journal to lag the code journal");
|
||||
}
|
||||
expect(laggingMigrationState.pendingMigrations).toHaveLength(1);
|
||||
const sourceMigrationRevision = laggingMigrationState.appliedMigrations.at(-1);
|
||||
expect(sourceMigrationRevision).toBeTruthy();
|
||||
|
||||
fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true });
|
||||
fs.mkdirSync(worktreeRoot, { recursive: true });
|
||||
|
|
@ -1028,6 +1311,19 @@ describe("worktree helpers", () => {
|
|||
const targetConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(worktreeRoot, ".paperclip", "config.json"), "utf8"),
|
||||
) as PaperclipConfig;
|
||||
const manifestText = fs.readFileSync(
|
||||
path.join(worktreeRoot, ".paperclip", "seed-manifest.json"),
|
||||
"utf8",
|
||||
);
|
||||
expect(JSON.parse(manifestText)).toMatchObject({
|
||||
version: 2,
|
||||
seedMode: "minimal",
|
||||
state: "verified",
|
||||
phase: "complete",
|
||||
});
|
||||
expect(manifestText).toContain(`Validated migration ${sourceMigrationRevision}`);
|
||||
expect(manifestText).not.toContain("fixture-password-hash");
|
||||
expect(manifestText).not.toContain("source-master-key");
|
||||
const { default: EmbeddedPostgres } = await import("embedded-postgres");
|
||||
const targetPg = new EmbeddedPostgres({
|
||||
databaseDir: targetConfig.database.embeddedPostgresDataDir,
|
||||
|
|
@ -1284,9 +1580,8 @@ describe("worktree helpers", () => {
|
|||
const originalCwd = process.cwd();
|
||||
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
|
||||
const currentDatabaseReservation = await reserveTestPort();
|
||||
const sourceDatabaseReservation = await reserveTestPort();
|
||||
const currentDatabasePort = currentDatabaseReservation.port;
|
||||
const sourceDatabasePort = sourceDatabaseReservation.port;
|
||||
const sourceDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-reseed-source-");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true });
|
||||
|
|
@ -1301,15 +1596,28 @@ describe("worktree helpers", () => {
|
|||
serverPort: 3114,
|
||||
databasePort: currentDatabasePort,
|
||||
});
|
||||
const sourceConfig = buildWorktreeConfig({
|
||||
sourceConfig: buildSourceConfig(),
|
||||
paths: sourcePaths,
|
||||
serverPort: 3200,
|
||||
databasePort: sourceDatabasePort,
|
||||
});
|
||||
const sourceConfig = buildSourceConfig();
|
||||
sourceConfig.database = {
|
||||
mode: "postgres",
|
||||
embeddedPostgresDataDir: sourcePaths.embeddedPostgresDataDir,
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: sourcePaths.backupDir,
|
||||
},
|
||||
connectionString: sourceDb.connectionString,
|
||||
};
|
||||
sourceConfig.logging.logDir = sourcePaths.logDir;
|
||||
sourceConfig.storage.localDisk.baseDir = sourcePaths.storageDir;
|
||||
sourceConfig.secrets.localEncrypted.keyFilePath = sourcePaths.secretsKeyFilePath;
|
||||
await seedValidWorktreeSource(sourceDb.connectionString);
|
||||
fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8");
|
||||
fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8");
|
||||
fs.writeFileSync(sourcePaths.secretsKeyFilePath, "source-secret", "utf8");
|
||||
const worktreeSentinelPath = path.join(repoRoot, "user-worktree-file.txt");
|
||||
fs.writeFileSync(worktreeSentinelPath, "preserve me", "utf8");
|
||||
fs.writeFileSync(
|
||||
currentPaths.envPath,
|
||||
[
|
||||
|
|
@ -1325,11 +1633,11 @@ describe("worktree helpers", () => {
|
|||
process.chdir(repoRoot);
|
||||
|
||||
await currentDatabaseReservation.release();
|
||||
await sourceDatabaseReservation.release();
|
||||
|
||||
await worktreeReseedCommand({
|
||||
fromConfig: sourcePaths.configPath,
|
||||
yes: true,
|
||||
backupTarget: true,
|
||||
});
|
||||
|
||||
const rewrittenConfig = JSON.parse(fs.readFileSync(currentPaths.configPath, "utf8"));
|
||||
|
|
@ -1341,9 +1649,13 @@ describe("worktree helpers", () => {
|
|||
expect(rewrittenEnv).toContain(`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`);
|
||||
expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_NAME=existing-name");
|
||||
expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_COLOR=\"#112233\"");
|
||||
expect(fs.readFileSync(worktreeSentinelPath, "utf8")).toBe("preserve me");
|
||||
expect(
|
||||
fs.readdirSync(path.join(currentPaths.backupDir, "repair")).some((name) => name.endsWith(".sql.gz")),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await currentDatabaseReservation.release();
|
||||
await sourceDatabaseReservation.release();
|
||||
await sourceDb.cleanup();
|
||||
process.chdir(originalCwd);
|
||||
if (originalPaperclipConfig === undefined) {
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
|
|
|
|||
|
|
@ -5,12 +5,52 @@ import { expandHomePrefix } from "../config/home.js";
|
|||
|
||||
export const DEFAULT_WORKTREE_HOME = "~/.paperclip-worktrees";
|
||||
export const WORKTREE_SEED_MODES = ["minimal", "full"] as const;
|
||||
export const WORKTREE_SEED_MANIFEST = "seed-manifest.json";
|
||||
export const WORKTREE_SEED_PENDING_MARKER = "seed-pending";
|
||||
export const WORKTREE_SEED_COMPLETE_MARKER = "seed-complete";
|
||||
export const WORKTREE_SEED_LOCK_MARKER = "seed.lock";
|
||||
|
||||
export type WorktreeSeedMode = (typeof WORKTREE_SEED_MODES)[number];
|
||||
|
||||
export const WORKTREE_SEED_PHASES = [
|
||||
"pending",
|
||||
"source_validation",
|
||||
"snapshot",
|
||||
"restore",
|
||||
"migrations",
|
||||
"execution_quarantine",
|
||||
"routine_pause",
|
||||
"workspace_rebind",
|
||||
"post_restore_validation",
|
||||
"complete",
|
||||
] as const;
|
||||
|
||||
export type WorktreeSeedPhase = (typeof WORKTREE_SEED_PHASES)[number];
|
||||
export type WorktreeSeedState = "pending" | "running" | "verified" | "failed";
|
||||
|
||||
export type WorktreeSeedManifest = {
|
||||
version: 2;
|
||||
source: {
|
||||
instanceId: string;
|
||||
configPath: string;
|
||||
};
|
||||
snapshotAt: string | null;
|
||||
seedMode: WorktreeSeedMode;
|
||||
migrationRevision: string | null;
|
||||
targetInstanceId: string;
|
||||
phase: WorktreeSeedPhase;
|
||||
state: WorktreeSeedState;
|
||||
attemptId: string;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
diagnostics: Array<{
|
||||
phase: WorktreeSeedPhase;
|
||||
status: "started" | "succeeded" | "failed";
|
||||
at: string;
|
||||
message?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type WorktreeSeedPlan = {
|
||||
mode: WorktreeSeedMode;
|
||||
excludedTables: string[];
|
||||
|
|
@ -54,6 +94,7 @@ export type WorktreeUiBranding = {
|
|||
};
|
||||
|
||||
export type WorktreeSeedMarkerPaths = {
|
||||
manifest: string;
|
||||
pending: string;
|
||||
complete: string;
|
||||
lock: string;
|
||||
|
|
@ -62,6 +103,7 @@ export type WorktreeSeedMarkerPaths = {
|
|||
export function resolveWorktreeSeedMarkerPaths(configPath: string): WorktreeSeedMarkerPaths {
|
||||
const configDir = path.dirname(path.resolve(configPath));
|
||||
return {
|
||||
manifest: path.resolve(configDir, WORKTREE_SEED_MANIFEST),
|
||||
pending: path.resolve(configDir, WORKTREE_SEED_PENDING_MARKER),
|
||||
complete: path.resolve(configDir, WORKTREE_SEED_COMPLETE_MARKER),
|
||||
lock: path.resolve(configDir, WORKTREE_SEED_LOCK_MARKER),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
readdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
|
|
@ -21,11 +22,15 @@ import { Readable } from "node:stream";
|
|||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { resolveCanonicalWorktreeSeedSource } from "@paperclipai/shared/worktree-seed-source";
|
||||
import {
|
||||
applyPendingMigrations,
|
||||
agents,
|
||||
authAccounts,
|
||||
authUsers,
|
||||
assets,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
documentRevisions,
|
||||
documents,
|
||||
|
|
@ -39,6 +44,7 @@ import {
|
|||
issueComments,
|
||||
issueDocuments,
|
||||
issues,
|
||||
instanceUserRoles,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
routines,
|
||||
|
|
@ -65,13 +71,16 @@ import {
|
|||
formatShellExports,
|
||||
generateWorktreeColor,
|
||||
isWorktreeSeedMode,
|
||||
WORKTREE_SEED_PHASES,
|
||||
resolveSuggestedWorktreeName,
|
||||
resolveWorktreeSeedPlan,
|
||||
resolveWorktreeSeedMarkerPaths,
|
||||
resolveWorktreeLocalPaths,
|
||||
sanitizeWorktreeInstanceId,
|
||||
type WorktreeSeedPlan,
|
||||
type WorktreeSeedManifest,
|
||||
type WorktreeSeedMode,
|
||||
type WorktreeSeedPhase,
|
||||
type WorktreeLocalPaths,
|
||||
} from "./worktree-lib.js";
|
||||
import {
|
||||
|
|
@ -137,6 +146,7 @@ type WorktreeReseedOptions = {
|
|||
preserveLiveWork?: boolean;
|
||||
yes?: boolean;
|
||||
allowLiveTarget?: boolean;
|
||||
backupTarget?: boolean;
|
||||
};
|
||||
|
||||
type WorktreeRepairOptions = {
|
||||
|
|
@ -157,6 +167,9 @@ type WorktreeEnsureSeededOptions = {
|
|||
fromDataDir?: string;
|
||||
fromInstance?: string;
|
||||
preserveLiveWork?: boolean;
|
||||
registeredBaseWorkspaceCwd?: string;
|
||||
registeredProjectWorkspaceId?: string;
|
||||
expectedCompanyId?: string;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
|
|
@ -197,6 +210,8 @@ type CopiedGitHooksResult = {
|
|||
|
||||
type SeedWorktreeDatabaseResult = {
|
||||
backupSummary: string;
|
||||
snapshotAt: string;
|
||||
migrationRevision: string;
|
||||
pausedScheduledRoutines: number;
|
||||
executionQuarantine: SeededWorktreeExecutionQuarantineSummary;
|
||||
reboundWorkspaces: Array<{
|
||||
|
|
@ -204,28 +219,26 @@ type SeedWorktreeDatabaseResult = {
|
|||
fromCwd: string;
|
||||
toCwd: string;
|
||||
}>;
|
||||
validation: WorktreeSeedValidationSummary;
|
||||
};
|
||||
|
||||
type WorktreeSeedPendingMarker = {
|
||||
version: 1;
|
||||
state: "pending";
|
||||
sourceConfigPath: string;
|
||||
seedMode: "minimal";
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type WorktreeSeedCompleteMarker = {
|
||||
version: 1;
|
||||
state: "complete";
|
||||
seedMode: WorktreeSeedMode;
|
||||
completedAt: string;
|
||||
export type WorktreeSeedValidationSummary = {
|
||||
authUserCount: number;
|
||||
credentialAccountCount: number;
|
||||
instanceAdminCount: number;
|
||||
activeMembershipCount: number;
|
||||
companyCount: number;
|
||||
issueCount: number;
|
||||
representativeCompanyId: string;
|
||||
representativeIssueId: string;
|
||||
migrationRevision: string;
|
||||
};
|
||||
|
||||
type SeedWorktreeDatabase = typeof seedWorktreeDatabase;
|
||||
|
||||
export type EnsureWorktreeSeededResult = {
|
||||
seeded: boolean;
|
||||
reason: "seeded" | "complete_marker" | "legacy_unmarked";
|
||||
reason: "seeded" | "verified_manifest" | "complete_marker" | "legacy_unmarked";
|
||||
details?: SeedWorktreeDatabaseResult;
|
||||
};
|
||||
|
||||
|
|
@ -1393,6 +1406,174 @@ export async function quarantineSeededWorktreeExecutionState(
|
|||
}
|
||||
}
|
||||
|
||||
type WorktreeSeedValidationExpectation = {
|
||||
adminUserId: string;
|
||||
representativeCompanyId: string;
|
||||
representativeIssueId: string;
|
||||
};
|
||||
|
||||
export function resolveWorktreeSeedMigrationRevision(
|
||||
migrationState: Awaited<ReturnType<typeof inspectMigrations>>,
|
||||
requirement: "sourcePrefix" | "upToDate",
|
||||
): string {
|
||||
if (migrationState.journalEntryCount > migrationState.availableMigrations.length) {
|
||||
throw new Error(
|
||||
`Migration journal is ahead of this Paperclip checkout (${migrationState.journalEntryCount} applied migration(s), ${migrationState.availableMigrations.length} available).`,
|
||||
);
|
||||
}
|
||||
|
||||
const expectedAppliedPrefix = migrationState.availableMigrations.slice(
|
||||
0,
|
||||
migrationState.appliedMigrations.length,
|
||||
);
|
||||
if (
|
||||
migrationState.appliedMigrations.some(
|
||||
(migration, index) => migration !== expectedAppliedPrefix[index],
|
||||
)
|
||||
) {
|
||||
throw new Error("Migration journal is not a prefix of this Paperclip checkout's migration journal.");
|
||||
}
|
||||
|
||||
if (requirement === "upToDate" && migrationState.status !== "upToDate") {
|
||||
throw new Error(
|
||||
`Migration journal is not current (${migrationState.pendingMigrations.length} pending migration(s)).`,
|
||||
);
|
||||
}
|
||||
|
||||
const migrationRevision = migrationState.appliedMigrations.at(-1);
|
||||
if (!migrationRevision) {
|
||||
throw new Error("Migration journal has no applied revision.");
|
||||
}
|
||||
return migrationRevision;
|
||||
}
|
||||
|
||||
async function inspectVerifiedSeedDatabase(
|
||||
connectionString: string,
|
||||
expected?: WorktreeSeedValidationExpectation,
|
||||
migrationRequirement: "sourcePrefix" | "upToDate" = "upToDate",
|
||||
requiredCompanyId?: string,
|
||||
): Promise<{ summary: WorktreeSeedValidationSummary; expectation: WorktreeSeedValidationExpectation }> {
|
||||
const migrationState = await inspectMigrations(connectionString);
|
||||
const migrationRevision = resolveWorktreeSeedMigrationRevision(
|
||||
migrationState,
|
||||
migrationRequirement,
|
||||
);
|
||||
|
||||
const db = createDb(connectionString);
|
||||
try {
|
||||
const [counts] = await db
|
||||
.select({
|
||||
authUserCount: sql<number>`count(distinct ${authUsers.id})::int`,
|
||||
credentialAccountCount: sql<number>`count(distinct ${authAccounts.id})::int`,
|
||||
instanceAdminCount: sql<number>`count(distinct ${instanceUserRoles.userId})::int`,
|
||||
activeMembershipCount: sql<number>`count(distinct ${companyMemberships.id})::int`,
|
||||
companyCount: sql<number>`count(distinct ${companies.id})::int`,
|
||||
issueCount: sql<number>`count(distinct ${issues.id})::int`,
|
||||
})
|
||||
.from(authUsers)
|
||||
.leftJoin(authAccounts, eq(authAccounts.userId, authUsers.id))
|
||||
.leftJoin(
|
||||
instanceUserRoles,
|
||||
and(eq(instanceUserRoles.userId, authUsers.id), eq(instanceUserRoles.role, "instance_admin")),
|
||||
)
|
||||
.leftJoin(
|
||||
companyMemberships,
|
||||
and(
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, authUsers.id),
|
||||
eq(companyMemberships.status, "active"),
|
||||
),
|
||||
)
|
||||
.leftJoin(companies, eq(companies.id, companyMemberships.companyId))
|
||||
.leftJoin(issues, eq(issues.companyId, companies.id));
|
||||
|
||||
const admin = await db
|
||||
.select({ userId: authUsers.id })
|
||||
.from(authUsers)
|
||||
.innerJoin(
|
||||
instanceUserRoles,
|
||||
and(eq(instanceUserRoles.userId, authUsers.id), eq(instanceUserRoles.role, "instance_admin")),
|
||||
)
|
||||
.innerJoin(
|
||||
authAccounts,
|
||||
and(
|
||||
eq(authAccounts.userId, authUsers.id),
|
||||
sql`length(trim(${authAccounts.providerId})) > 0`,
|
||||
sql`length(trim(${authAccounts.accountId})) > 0`,
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
companyMemberships,
|
||||
and(
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, authUsers.id),
|
||||
eq(companyMemberships.status, "active"),
|
||||
),
|
||||
)
|
||||
.where(and(
|
||||
expected ? eq(authUsers.id, expected.adminUserId) : undefined,
|
||||
requiredCompanyId ? eq(companyMemberships.companyId, requiredCompanyId) : undefined,
|
||||
))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!admin) {
|
||||
throw new Error(
|
||||
"No auth user has a non-empty credential account, instance-admin role, and active company membership.",
|
||||
);
|
||||
}
|
||||
|
||||
const representative = await db
|
||||
.select({ companyId: companies.id, issueId: issues.id })
|
||||
.from(companies)
|
||||
.innerJoin(issues, eq(issues.companyId, companies.id))
|
||||
.where(
|
||||
and(
|
||||
expected ? eq(companies.id, expected.representativeCompanyId) : undefined,
|
||||
expected ? eq(issues.id, expected.representativeIssueId) : undefined,
|
||||
requiredCompanyId ? eq(companies.id, requiredCompanyId) : undefined,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!representative) {
|
||||
throw new Error("No representative cloned company and issue pair is readable.");
|
||||
}
|
||||
|
||||
const summary: WorktreeSeedValidationSummary = {
|
||||
authUserCount: counts?.authUserCount ?? 0,
|
||||
credentialAccountCount: counts?.credentialAccountCount ?? 0,
|
||||
instanceAdminCount: counts?.instanceAdminCount ?? 0,
|
||||
activeMembershipCount: counts?.activeMembershipCount ?? 0,
|
||||
companyCount: counts?.companyCount ?? 0,
|
||||
issueCount: counts?.issueCount ?? 0,
|
||||
representativeCompanyId: representative.companyId,
|
||||
representativeIssueId: representative.issueId,
|
||||
migrationRevision,
|
||||
};
|
||||
if (
|
||||
summary.authUserCount < 1
|
||||
|| summary.credentialAccountCount < 1
|
||||
|| summary.instanceAdminCount < 1
|
||||
|| summary.activeMembershipCount < 1
|
||||
|| summary.companyCount < 1
|
||||
|| summary.issueCount < 1
|
||||
) {
|
||||
throw new Error("Seed validation found an incomplete auth, membership, company, or issue shape.");
|
||||
}
|
||||
|
||||
return {
|
||||
summary,
|
||||
expectation: {
|
||||
adminUserId: admin.userId,
|
||||
representativeCompanyId: representative.companyId,
|
||||
representativeIssueId: representative.issueId,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedWorktreeDatabase(input: {
|
||||
sourceConfigPath: string;
|
||||
sourceConfig: PaperclipConfig;
|
||||
|
|
@ -1401,16 +1582,12 @@ async function seedWorktreeDatabase(input: {
|
|||
instanceId: string;
|
||||
seedMode: WorktreeSeedMode;
|
||||
preserveLiveWork?: boolean;
|
||||
expectedCompanyId?: string;
|
||||
onPhase?: (phase: WorktreeSeedPhase, status: "started" | "succeeded", message?: string) => void;
|
||||
}): Promise<SeedWorktreeDatabaseResult> {
|
||||
const seedPlan = resolveWorktreeSeedPlan(input.seedMode);
|
||||
const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath);
|
||||
const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile);
|
||||
copySeededSecretsKey({
|
||||
sourceConfigPath: input.sourceConfigPath,
|
||||
sourceConfig: input.sourceConfig,
|
||||
sourceEnvEntries,
|
||||
targetKeyFilePath: input.targetPaths.secretsKeyFilePath,
|
||||
});
|
||||
let sourceHandle: EmbeddedPostgresHandle | null = null;
|
||||
let targetHandle: EmbeddedPostgresHandle | null = null;
|
||||
|
||||
|
|
@ -1428,6 +1605,27 @@ async function seedWorktreeDatabase(input: {
|
|||
sourceEnvEntries,
|
||||
sourceHandle?.port,
|
||||
);
|
||||
input.onPhase?.("source_validation", "started");
|
||||
const sourceValidation = await inspectVerifiedSeedDatabase(
|
||||
sourceConnectionString,
|
||||
undefined,
|
||||
"sourcePrefix",
|
||||
input.expectedCompanyId,
|
||||
);
|
||||
input.onPhase?.(
|
||||
"source_validation",
|
||||
"succeeded",
|
||||
`Validated migration ${sourceValidation.summary.migrationRevision}, ${sourceValidation.summary.companyCount} company record(s), and ${sourceValidation.summary.issueCount} issue record(s).`,
|
||||
);
|
||||
copySeededSecretsKey({
|
||||
sourceConfigPath: input.sourceConfigPath,
|
||||
sourceConfig: input.sourceConfig,
|
||||
sourceEnvEntries,
|
||||
targetKeyFilePath: input.targetPaths.secretsKeyFilePath,
|
||||
});
|
||||
|
||||
const snapshotAt = new Date().toISOString();
|
||||
input.onPhase?.("snapshot", "started");
|
||||
const backup = await runDatabaseBackup({
|
||||
connectionString: sourceConnectionString,
|
||||
backupDir: path.resolve(input.targetPaths.backupDir, "seed"),
|
||||
|
|
@ -1438,6 +1636,7 @@ async function seedWorktreeDatabase(input: {
|
|||
excludeTables: seedPlan.excludedTables,
|
||||
nullifyColumns: seedPlan.nullifyColumns,
|
||||
});
|
||||
input.onPhase?.("snapshot", "succeeded", `Created ${path.basename(backup.backupFile)}.`);
|
||||
|
||||
targetHandle = await ensureEmbeddedPostgres(
|
||||
input.targetConfig.database.embeddedPostgresDataDir,
|
||||
|
|
@ -1445,27 +1644,56 @@ async function seedWorktreeDatabase(input: {
|
|||
);
|
||||
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/postgres`;
|
||||
input.onPhase?.("restore", "started");
|
||||
await resetPostgresDatabase(adminConnectionString, "paperclip");
|
||||
const targetConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/paperclip`;
|
||||
await runDatabaseRestore({
|
||||
connectionString: targetConnectionString,
|
||||
backupFile: backup.backupFile,
|
||||
});
|
||||
input.onPhase?.("restore", "succeeded");
|
||||
input.onPhase?.("migrations", "started");
|
||||
await applyPendingMigrations(targetConnectionString);
|
||||
input.onPhase?.("migrations", "succeeded");
|
||||
input.onPhase?.("execution_quarantine", "started");
|
||||
const executionQuarantine = input.preserveLiveWork
|
||||
? { ...EMPTY_SEEDED_WORKTREE_EXECUTION_QUARANTINE_SUMMARY }
|
||||
: await quarantineSeededWorktreeExecutionState(targetConnectionString);
|
||||
input.onPhase?.(
|
||||
"execution_quarantine",
|
||||
"succeeded",
|
||||
input.preserveLiveWork
|
||||
? "Preserved copied live work by explicit request."
|
||||
: formatSeededWorktreeExecutionQuarantineSummary(executionQuarantine),
|
||||
);
|
||||
input.onPhase?.("routine_pause", "started");
|
||||
const pausedScheduledRoutines = await pauseSeededScheduledRoutines(targetConnectionString);
|
||||
input.onPhase?.("routine_pause", "succeeded", `Paused ${pausedScheduledRoutines} scheduled routine(s).`);
|
||||
input.onPhase?.("workspace_rebind", "started");
|
||||
const reboundWorkspaces = await rebindSeededProjectWorkspaces({
|
||||
targetConnectionString,
|
||||
currentCwd: input.targetPaths.cwd,
|
||||
});
|
||||
input.onPhase?.("workspace_rebind", "succeeded", `Rebound ${reboundWorkspaces.length} workspace path(s).`);
|
||||
input.onPhase?.("post_restore_validation", "started");
|
||||
const targetValidation = await inspectVerifiedSeedDatabase(
|
||||
targetConnectionString,
|
||||
sourceValidation.expectation,
|
||||
);
|
||||
input.onPhase?.(
|
||||
"post_restore_validation",
|
||||
"succeeded",
|
||||
`Validated migration ${targetValidation.summary.migrationRevision}.`,
|
||||
);
|
||||
|
||||
return {
|
||||
backupSummary: formatDatabaseBackupResult(backup),
|
||||
snapshotAt,
|
||||
migrationRevision: targetValidation.summary.migrationRevision,
|
||||
pausedScheduledRoutines,
|
||||
executionQuarantine,
|
||||
reboundWorkspaces,
|
||||
validation: targetValidation.summary,
|
||||
};
|
||||
} finally {
|
||||
if (targetHandle?.startedByThisProcess) {
|
||||
|
|
@ -1477,46 +1705,184 @@ async function seedWorktreeDatabase(input: {
|
|||
}
|
||||
}
|
||||
|
||||
function writeWorktreeSeedMarker(
|
||||
filePath: string,
|
||||
marker: WorktreeSeedPendingMarker | WorktreeSeedCompleteMarker,
|
||||
): void {
|
||||
const WORKTREE_SEED_DIAGNOSTIC_LIMIT = 32;
|
||||
const WORKTREE_SEED_DIAGNOSTIC_MESSAGE_LIMIT = 512;
|
||||
const activeSeedInterruptHandlers = new Map<string, (signal: NodeJS.Signals) => void>();
|
||||
|
||||
function dispatchSeedInterruption(signal: NodeJS.Signals): void {
|
||||
for (const handler of activeSeedInterruptHandlers.values()) {
|
||||
try {
|
||||
handler(signal);
|
||||
} catch {
|
||||
// Continue terminalizing the other active manifests before exiting.
|
||||
}
|
||||
}
|
||||
process.exit(signal === "SIGINT" ? 130 : 143);
|
||||
}
|
||||
|
||||
const dispatchSeedSigint = () => dispatchSeedInterruption("SIGINT");
|
||||
const dispatchSeedSigterm = () => dispatchSeedInterruption("SIGTERM");
|
||||
|
||||
function registerSeedInterruptHandler(handler: (signal: NodeJS.Signals) => void): () => void {
|
||||
const id = randomUUID();
|
||||
if (activeSeedInterruptHandlers.size === 0) {
|
||||
process.once("SIGINT", dispatchSeedSigint);
|
||||
process.once("SIGTERM", dispatchSeedSigterm);
|
||||
}
|
||||
activeSeedInterruptHandlers.set(id, handler);
|
||||
return () => {
|
||||
activeSeedInterruptHandlers.delete(id);
|
||||
if (activeSeedInterruptHandlers.size === 0) {
|
||||
process.off("SIGINT", dispatchSeedSigint);
|
||||
process.off("SIGTERM", dispatchSeedSigterm);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type LegacyWorktreeSeedPendingMarker = {
|
||||
version: 1;
|
||||
state: "pending";
|
||||
sourceConfigPath: string;
|
||||
};
|
||||
|
||||
function resolveSeedInstanceId(configPath: string): string {
|
||||
const envEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(configPath));
|
||||
return nonEmpty(envEntries.PAPERCLIP_INSTANCE_ID)
|
||||
?? sanitizeWorktreeInstanceId(path.basename(path.dirname(path.resolve(configPath))));
|
||||
}
|
||||
|
||||
function writeWorktreeSeedManifest(filePath: string, manifest: WorktreeSeedManifest): void {
|
||||
mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 });
|
||||
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
||||
writeFileSync(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
||||
renameSync(temporaryPath, filePath);
|
||||
}
|
||||
|
||||
export function readWorktreeSeedManifest(configPath: string): WorktreeSeedManifest | null {
|
||||
const manifestPath = resolveWorktreeSeedMarkerPaths(configPath).manifest;
|
||||
if (!existsSync(manifestPath)) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Invalid worktree seed manifest at ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
const value = parsed as Partial<WorktreeSeedManifest>;
|
||||
const diagnosticsValid = Array.isArray(value.diagnostics) && value.diagnostics.every((diagnostic) => (
|
||||
diagnostic
|
||||
&& typeof diagnostic === "object"
|
||||
&& WORKTREE_SEED_PHASES.includes(diagnostic.phase)
|
||||
&& ["started", "succeeded", "failed"].includes(diagnostic.status)
|
||||
&& typeof diagnostic.at === "string"
|
||||
&& (diagnostic.message === undefined || typeof diagnostic.message === "string")
|
||||
));
|
||||
const verifiedTerminalValid = value.state !== "verified" || (
|
||||
value.phase === "complete"
|
||||
&& typeof value.snapshotAt === "string"
|
||||
&& value.snapshotAt.length > 0
|
||||
&& typeof value.migrationRevision === "string"
|
||||
&& value.migrationRevision.length > 0
|
||||
&& typeof value.startedAt === "string"
|
||||
&& typeof value.finishedAt === "string"
|
||||
&& value.diagnostics?.some((diagnostic) => (
|
||||
diagnostic.phase === "complete" && diagnostic.status === "succeeded"
|
||||
)) === true
|
||||
);
|
||||
if (
|
||||
!value
|
||||
|| typeof value !== "object"
|
||||
|| value.version !== 2
|
||||
|| !value.source
|
||||
|| typeof value.source.instanceId !== "string"
|
||||
|| typeof value.source.configPath !== "string"
|
||||
|| typeof value.targetInstanceId !== "string"
|
||||
|| value.targetInstanceId.length === 0
|
||||
|| !isWorktreeSeedMode(String(value.seedMode ?? ""))
|
||||
|| !WORKTREE_SEED_PHASES.includes(value.phase as WorktreeSeedPhase)
|
||||
|| !["pending", "running", "verified", "failed"].includes(String(value.state ?? ""))
|
||||
|| typeof value.attemptId !== "string"
|
||||
|| value.attemptId.length === 0
|
||||
|| !diagnosticsValid
|
||||
|| !verifiedTerminalValid
|
||||
) {
|
||||
throw new Error(`Invalid worktree seed manifest at ${manifestPath}.`);
|
||||
}
|
||||
return value as WorktreeSeedManifest;
|
||||
}
|
||||
|
||||
export function markWorktreeSeedPending(input: {
|
||||
configPath: string;
|
||||
sourceConfigPath: string;
|
||||
now?: Date;
|
||||
}): void {
|
||||
const markers = resolveWorktreeSeedMarkerPaths(input.configPath);
|
||||
rmSync(markers.complete, { force: true });
|
||||
writeWorktreeSeedMarker(markers.pending, {
|
||||
version: 1,
|
||||
state: "pending",
|
||||
sourceConfigPath: path.resolve(input.sourceConfigPath),
|
||||
seedMode: "minimal",
|
||||
createdAt: (input.now ?? new Date()).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export function markWorktreeSeedComplete(input: {
|
||||
configPath: string;
|
||||
targetInstanceId?: string;
|
||||
seedMode?: WorktreeSeedMode;
|
||||
now?: Date;
|
||||
}): void {
|
||||
const markers = resolveWorktreeSeedMarkerPaths(input.configPath);
|
||||
writeWorktreeSeedMarker(markers.complete, {
|
||||
version: 1,
|
||||
state: "complete",
|
||||
const at = (input.now ?? new Date()).toISOString();
|
||||
writeWorktreeSeedManifest(markers.manifest, {
|
||||
version: 2,
|
||||
source: {
|
||||
instanceId: resolveSeedInstanceId(input.sourceConfigPath),
|
||||
configPath: path.resolve(input.sourceConfigPath),
|
||||
},
|
||||
snapshotAt: null,
|
||||
seedMode: input.seedMode ?? "minimal",
|
||||
completedAt: (input.now ?? new Date()).toISOString(),
|
||||
migrationRevision: null,
|
||||
targetInstanceId: input.targetInstanceId ?? resolveSeedInstanceId(input.configPath),
|
||||
phase: "pending",
|
||||
state: "pending",
|
||||
attemptId: randomUUID(),
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
diagnostics: [{ phase: "pending", status: "succeeded", at }],
|
||||
});
|
||||
// New manifests are authoritative. Legacy files are removed so no caller can
|
||||
// mistake a stale binary marker for current verified seed state.
|
||||
rmSync(markers.complete, { force: true });
|
||||
rmSync(markers.pending, { force: true });
|
||||
}
|
||||
|
||||
function readWorktreeSeedPendingMarker(filePath: string): WorktreeSeedPendingMarker {
|
||||
function updateWorktreeSeedManifest(input: {
|
||||
configPath: string;
|
||||
phase: WorktreeSeedPhase;
|
||||
status: "started" | "succeeded" | "failed";
|
||||
state?: WorktreeSeedManifest["state"];
|
||||
message?: string;
|
||||
snapshotAt?: string | null;
|
||||
migrationRevision?: string | null;
|
||||
now?: Date;
|
||||
}): WorktreeSeedManifest {
|
||||
const markers = resolveWorktreeSeedMarkerPaths(input.configPath);
|
||||
const current = readWorktreeSeedManifest(input.configPath);
|
||||
if (!current) throw new Error(`Worktree seed manifest does not exist at ${markers.manifest}.`);
|
||||
const at = (input.now ?? new Date()).toISOString();
|
||||
const nextState = input.state ?? current.state;
|
||||
const diagnostic = {
|
||||
phase: input.phase,
|
||||
status: input.status,
|
||||
at,
|
||||
...(input.message
|
||||
? { message: input.message.slice(0, WORKTREE_SEED_DIAGNOSTIC_MESSAGE_LIMIT) }
|
||||
: {}),
|
||||
};
|
||||
const next: WorktreeSeedManifest = {
|
||||
...current,
|
||||
phase: input.phase,
|
||||
state: nextState,
|
||||
snapshotAt: input.snapshotAt === undefined ? current.snapshotAt : input.snapshotAt,
|
||||
migrationRevision:
|
||||
input.migrationRevision === undefined ? current.migrationRevision : input.migrationRevision,
|
||||
startedAt: current.startedAt ?? (input.status === "started" ? at : null),
|
||||
finishedAt: nextState === "verified" || nextState === "failed" ? at : null,
|
||||
diagnostics: [...current.diagnostics, diagnostic].slice(-WORKTREE_SEED_DIAGNOSTIC_LIMIT),
|
||||
};
|
||||
writeWorktreeSeedManifest(markers.manifest, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function readLegacyWorktreeSeedPendingMarker(filePath: string): LegacyWorktreeSeedPendingMarker {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
||||
|
|
@ -1537,7 +1903,7 @@ function readWorktreeSeedPendingMarker(filePath: string): WorktreeSeedPendingMar
|
|||
throw new Error(`Invalid worktree seed-pending marker at ${filePath}.`);
|
||||
}
|
||||
|
||||
return parsed as WorktreeSeedPendingMarker;
|
||||
return parsed as LegacyWorktreeSeedPendingMarker;
|
||||
}
|
||||
|
||||
const WORKTREE_SEED_LOCK_POLL_MS = 50;
|
||||
|
|
@ -1631,41 +1997,222 @@ async function acquireWorktreeSeedLock(lockPath: string): Promise<() => Promise<
|
|||
}
|
||||
}
|
||||
|
||||
function startWorktreeSeedAttempt(configPath: string, now = new Date()): WorktreeSeedManifest {
|
||||
const markers = resolveWorktreeSeedMarkerPaths(configPath);
|
||||
const current = readWorktreeSeedManifest(configPath);
|
||||
if (!current) throw new Error(`Worktree seed manifest does not exist at ${markers.manifest}.`);
|
||||
const at = now.toISOString();
|
||||
const next: WorktreeSeedManifest = {
|
||||
...current,
|
||||
state: "running",
|
||||
phase: "pending",
|
||||
attemptId: randomUUID(),
|
||||
snapshotAt: null,
|
||||
migrationRevision: null,
|
||||
startedAt: at,
|
||||
finishedAt: null,
|
||||
diagnostics: [
|
||||
...current.diagnostics,
|
||||
{ phase: "pending" as const, status: "started" as const, at },
|
||||
].slice(-WORKTREE_SEED_DIAGNOSTIC_LIMIT),
|
||||
};
|
||||
writeWorktreeSeedManifest(markers.manifest, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
async function runVerifiedWorktreeSeed(input: {
|
||||
configPath: string;
|
||||
sourceConfigPath: string;
|
||||
sourceConfig: PaperclipConfig;
|
||||
targetConfig: PaperclipConfig;
|
||||
targetPaths: WorktreeLocalPaths;
|
||||
instanceId: string;
|
||||
seedMode: WorktreeSeedMode;
|
||||
preserveLiveWork?: boolean;
|
||||
expectedCompanyId?: string;
|
||||
seedDatabase: SeedWorktreeDatabase;
|
||||
}): Promise<SeedWorktreeDatabaseResult> {
|
||||
let activePhase: WorktreeSeedPhase = "pending";
|
||||
const previous = readWorktreeSeedManifest(input.configPath);
|
||||
if (previous?.state === "running") {
|
||||
updateWorktreeSeedManifest({
|
||||
configPath: input.configPath,
|
||||
phase: previous.phase,
|
||||
status: "failed",
|
||||
state: "failed",
|
||||
message: "The previous seed attempt ended without a terminal result.",
|
||||
});
|
||||
}
|
||||
startWorktreeSeedAttempt(input.configPath);
|
||||
|
||||
const unregisterInterruption = registerSeedInterruptHandler((signal) => {
|
||||
updateWorktreeSeedManifest({
|
||||
configPath: input.configPath,
|
||||
phase: activePhase,
|
||||
status: "failed",
|
||||
state: "failed",
|
||||
message: `Seed interrupted by ${signal} during ${activePhase}.`,
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const details = await input.seedDatabase({
|
||||
sourceConfigPath: input.sourceConfigPath,
|
||||
sourceConfig: input.sourceConfig,
|
||||
targetConfig: input.targetConfig,
|
||||
targetPaths: input.targetPaths,
|
||||
instanceId: input.instanceId,
|
||||
seedMode: input.seedMode,
|
||||
preserveLiveWork: input.preserveLiveWork,
|
||||
expectedCompanyId: input.expectedCompanyId,
|
||||
onPhase: (phase, status, message) => {
|
||||
activePhase = phase;
|
||||
updateWorktreeSeedManifest({
|
||||
configPath: input.configPath,
|
||||
phase,
|
||||
status,
|
||||
state: "running",
|
||||
message,
|
||||
...(phase === "snapshot" && status === "started"
|
||||
? { snapshotAt: new Date().toISOString() }
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
});
|
||||
if (!details.snapshotAt || !details.migrationRevision || !details.validation) {
|
||||
throw new Error("Seed implementation returned without required validation evidence.");
|
||||
}
|
||||
updateWorktreeSeedManifest({
|
||||
configPath: input.configPath,
|
||||
phase: "complete",
|
||||
status: "succeeded",
|
||||
state: "verified",
|
||||
snapshotAt: details.snapshotAt,
|
||||
migrationRevision: details.migrationRevision,
|
||||
message:
|
||||
`Verified ${details.validation.companyCount} company record(s), `
|
||||
+ `${details.validation.issueCount} issue record(s), auth, admin, membership, and migration state.`,
|
||||
});
|
||||
return details;
|
||||
} catch (error) {
|
||||
updateWorktreeSeedManifest({
|
||||
configPath: input.configPath,
|
||||
phase: activePhase,
|
||||
status: "failed",
|
||||
state: "failed",
|
||||
// Do not persist the underlying error: database/driver errors may contain
|
||||
// connection credentials. The CLI still returns the exact error to its caller.
|
||||
message: `Seed failed during ${activePhase}.`,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
unregisterInterruption();
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureWorktreeSeeded(
|
||||
opts: WorktreeEnsureSeededOptions = {},
|
||||
dependencies: { seedDatabase?: SeedWorktreeDatabase } = {},
|
||||
): Promise<EnsureWorktreeSeededResult> {
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
const markers = resolveWorktreeSeedMarkerPaths(configPath);
|
||||
let initialManifest = readWorktreeSeedManifest(configPath);
|
||||
if (initialManifest?.state === "verified") {
|
||||
return { seeded: false, reason: "verified_manifest" };
|
||||
}
|
||||
if (!initialManifest && existsSync(markers.complete)) {
|
||||
return { seeded: false, reason: "complete_marker" };
|
||||
}
|
||||
const legacyPending = !initialManifest && existsSync(markers.pending)
|
||||
? readLegacyWorktreeSeedPendingMarker(markers.pending)
|
||||
: null;
|
||||
if (!initialManifest && !legacyPending) {
|
||||
if (existsSync(markers.lock)) {
|
||||
const releaseExistingLock = await acquireWorktreeSeedLock(markers.lock);
|
||||
await releaseExistingLock();
|
||||
}
|
||||
return { seeded: false, reason: "legacy_unmarked" };
|
||||
}
|
||||
|
||||
const hasExplicitSource = Boolean(opts.fromConfig || opts.fromDataDir || opts.fromInstance);
|
||||
const explicitSourceConfigPath = hasExplicitSource
|
||||
? resolveSourceConfigPath({
|
||||
fromConfig: opts.fromConfig,
|
||||
fromDataDir: opts.fromDataDir,
|
||||
fromInstance: opts.fromInstance,
|
||||
})
|
||||
: null;
|
||||
const registeredBaseWorkspaceCwd = opts.registeredBaseWorkspaceCwd
|
||||
?? nonEmpty(process.env.PAPERCLIP_WORKSPACE_BASE_CWD)
|
||||
?? null;
|
||||
const registeredProjectWorkspaceId = opts.registeredProjectWorkspaceId
|
||||
?? nonEmpty(process.env.PAPERCLIP_PROJECT_WORKSPACE_ID)
|
||||
?? null;
|
||||
const expectedCompanyId = opts.expectedCompanyId
|
||||
?? nonEmpty(process.env.PAPERCLIP_SEED_EXPECTED_COMPANY_ID)
|
||||
?? nonEmpty(process.env.PAPERCLIP_COMPANY_ID)
|
||||
?? undefined;
|
||||
if (!explicitSourceConfigPath && registeredBaseWorkspaceCwd && (!registeredProjectWorkspaceId || !expectedCompanyId)) {
|
||||
throw new Error(
|
||||
"Managed worktree seed registration is incomplete; project workspace and company bindings are required.",
|
||||
);
|
||||
}
|
||||
|
||||
const targetRoot = path.dirname(path.dirname(configPath));
|
||||
const targetPaths = resolveWorktreeReseedTargetPaths({ configPath, rootPath: targetRoot });
|
||||
const resolveSeedSource = (manifest: WorktreeSeedManifest | null) => {
|
||||
const diagnosticSource = manifest?.source ?? (legacyPending
|
||||
? {
|
||||
configPath: legacyPending.sourceConfigPath,
|
||||
instanceId: resolveSeedInstanceId(legacyPending.sourceConfigPath),
|
||||
}
|
||||
: null);
|
||||
return resolveCanonicalWorktreeSeedSource({
|
||||
registeredBaseWorkspaceCwd,
|
||||
explicitSourceConfigPath,
|
||||
targetConfigPath: configPath,
|
||||
expectedTargetInstanceId: targetPaths.instanceId,
|
||||
manifestSource: diagnosticSource,
|
||||
manifestTargetInstanceId: manifest?.targetInstanceId ?? targetPaths.instanceId,
|
||||
});
|
||||
};
|
||||
|
||||
// Fail before creating the seed lock or rewriting a legacy marker. The manifest
|
||||
// is agent-writable diagnostic evidence and can never select this source.
|
||||
let canonicalSource = resolveSeedSource(initialManifest);
|
||||
mkdirSync(path.dirname(markers.lock), { recursive: true });
|
||||
const releaseLock = await acquireWorktreeSeedLock(markers.lock);
|
||||
try {
|
||||
// These checks deliberately happen under the cross-process lock. A second
|
||||
// service process waits for the first seed transaction, then observes the
|
||||
// complete marker instead of cloning the same database concurrently.
|
||||
if (existsSync(markers.complete)) {
|
||||
// verified manifest instead of cloning the same database concurrently.
|
||||
let manifest = readWorktreeSeedManifest(configPath);
|
||||
if (manifest?.state === "verified") {
|
||||
return { seeded: false, reason: "verified_manifest" };
|
||||
}
|
||||
if (!manifest && existsSync(markers.complete)) {
|
||||
return { seeded: false, reason: "complete_marker" };
|
||||
}
|
||||
if (!existsSync(markers.pending)) {
|
||||
if (!manifest && existsSync(markers.pending)) {
|
||||
const currentLegacyPending = readLegacyWorktreeSeedPendingMarker(markers.pending);
|
||||
if (currentLegacyPending.sourceConfigPath !== legacyPending?.sourceConfigPath) {
|
||||
throw new Error("Worktree seed source diagnostics changed while waiting for the seed lock.");
|
||||
}
|
||||
markWorktreeSeedPending({
|
||||
configPath,
|
||||
sourceConfigPath: canonicalSource.configPath,
|
||||
targetInstanceId: targetPaths.instanceId,
|
||||
seedMode: "minimal",
|
||||
});
|
||||
manifest = readWorktreeSeedManifest(configPath);
|
||||
}
|
||||
if (!manifest) {
|
||||
// Worktrees created before lazy seeding shipped were seeded eagerly and
|
||||
// have neither marker. Preserve that compatibility without re-cloning.
|
||||
return { seeded: false, reason: "legacy_unmarked" };
|
||||
}
|
||||
|
||||
const pending = readWorktreeSeedPendingMarker(markers.pending);
|
||||
const sourceConfigPath = opts.fromConfig || opts.fromDataDir || opts.fromInstance
|
||||
? resolveSourceConfigPath({
|
||||
fromConfig: opts.fromConfig,
|
||||
fromDataDir: opts.fromDataDir,
|
||||
fromInstance: opts.fromInstance,
|
||||
})
|
||||
: path.resolve(pending.sourceConfigPath);
|
||||
|
||||
if (path.resolve(sourceConfigPath) === path.resolve(configPath)) {
|
||||
throw new Error(
|
||||
"Source and target Paperclip configs are the same. Pass --from-config for the source instance.",
|
||||
);
|
||||
}
|
||||
canonicalSource = resolveSeedSource(manifest);
|
||||
const sourceConfigPath = canonicalSource.configPath;
|
||||
|
||||
const sourceConfig = readConfig(sourceConfigPath);
|
||||
if (!sourceConfig) {
|
||||
|
|
@ -1676,19 +2223,19 @@ export async function ensureWorktreeSeeded(
|
|||
throw new Error(`Target config not found at ${configPath}.`);
|
||||
}
|
||||
|
||||
const targetRoot = path.dirname(path.dirname(configPath));
|
||||
const targetPaths = resolveWorktreeReseedTargetPaths({ configPath, rootPath: targetRoot });
|
||||
const seedDatabase = dependencies.seedDatabase ?? seedWorktreeDatabase;
|
||||
const details = await seedDatabase({
|
||||
const details = await runVerifiedWorktreeSeed({
|
||||
configPath,
|
||||
sourceConfigPath,
|
||||
sourceConfig,
|
||||
targetConfig,
|
||||
targetPaths,
|
||||
instanceId: targetPaths.instanceId,
|
||||
seedMode: "minimal",
|
||||
seedMode: manifest.seedMode,
|
||||
preserveLiveWork: opts.preserveLiveWork,
|
||||
expectedCompanyId,
|
||||
seedDatabase,
|
||||
});
|
||||
markWorktreeSeedComplete({ configPath });
|
||||
return { seeded: true, reason: "seeded", details };
|
||||
} finally {
|
||||
await releaseLock();
|
||||
|
|
@ -1762,6 +2309,8 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
|||
markWorktreeSeedPending({
|
||||
configPath: paths.configPath,
|
||||
sourceConfigPath,
|
||||
targetInstanceId: instanceId,
|
||||
seedMode,
|
||||
});
|
||||
const sourceEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(sourceConfigPath));
|
||||
const existingAgentJwtSecret =
|
||||
|
|
@ -1790,8 +2339,11 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
|||
}
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Seeding isolated worktree database from source instance (${seedMode})...`);
|
||||
const markers = resolveWorktreeSeedMarkerPaths(paths.configPath);
|
||||
const releaseSeedLock = await acquireWorktreeSeedLock(markers.lock);
|
||||
try {
|
||||
const seeded = await seedWorktreeDatabase({
|
||||
const seeded = await runVerifiedWorktreeSeed({
|
||||
configPath: paths.configPath,
|
||||
sourceConfigPath,
|
||||
sourceConfig,
|
||||
targetConfig,
|
||||
|
|
@ -1799,16 +2351,18 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
|||
instanceId,
|
||||
seedMode,
|
||||
preserveLiveWork: opts.preserveLiveWork,
|
||||
seedDatabase: seedWorktreeDatabase,
|
||||
});
|
||||
seedSummary = seeded.backupSummary;
|
||||
seedExecutionQuarantineSummary = seeded.executionQuarantine;
|
||||
pausedScheduledRoutineCount = seeded.pausedScheduledRoutines;
|
||||
reboundWorkspaceSummary = seeded.reboundWorkspaces;
|
||||
markWorktreeSeedComplete({ configPath: paths.configPath, seedMode });
|
||||
spinner.stop(`Seeded isolated worktree database (${seedMode}).`);
|
||||
} catch (error) {
|
||||
spinner.stop(pc.red("Failed to seed worktree database."));
|
||||
throw error;
|
||||
} finally {
|
||||
await releaseSeedLock();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3480,6 +4034,34 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
|
|||
}
|
||||
}
|
||||
|
||||
async function backupWorktreeReseedTarget(input: {
|
||||
targetConfig: PaperclipConfig;
|
||||
targetPaths: WorktreeLocalPaths;
|
||||
}): Promise<string> {
|
||||
if (input.targetConfig.database.mode !== "embedded-postgres") {
|
||||
throw new Error("Managed worktree repair requires an embedded PostgreSQL target.");
|
||||
}
|
||||
const targetHandle = await ensureEmbeddedPostgres(
|
||||
input.targetConfig.database.embeddedPostgresDataDir,
|
||||
input.targetConfig.database.embeddedPostgresPort,
|
||||
);
|
||||
try {
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const result = await runDatabaseBackup({
|
||||
connectionString: `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/paperclip`,
|
||||
backupDir: path.resolve(input.targetPaths.backupDir, "repair"),
|
||||
retention: { dailyDays: 30, weeklyWeeks: 12, monthlyMonths: 12 },
|
||||
filenamePrefix: `${input.targetPaths.instanceId}-pre-repair`,
|
||||
backupEngine: "auto",
|
||||
includeMigrationJournal: true,
|
||||
});
|
||||
return formatDatabaseBackupResult(result);
|
||||
} finally {
|
||||
if (targetHandle.startedByThisProcess) await targetHandle.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise<void> {
|
||||
const seedMode = opts.seedMode ?? "full";
|
||||
if (!isWorktreeSeedMode(seedMode)) {
|
||||
|
|
@ -3535,8 +4117,23 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise<void> {
|
|||
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Reseeding ${targetEndpoint.label} from ${source.label} (${seedMode})...`);
|
||||
const markers = resolveWorktreeSeedMarkerPaths(targetEndpoint.configPath);
|
||||
mkdirSync(path.dirname(markers.lock), { recursive: true });
|
||||
const releaseSeedLock = await acquireWorktreeSeedLock(markers.lock);
|
||||
try {
|
||||
const seeded = await seedWorktreeDatabase({
|
||||
let targetBackupSummary: string | null = null;
|
||||
if (opts.backupTarget) {
|
||||
targetBackupSummary = await backupWorktreeReseedTarget({ targetConfig, targetPaths });
|
||||
p.log.message(pc.dim(`Recoverable pre-repair backup: ${targetBackupSummary}`));
|
||||
}
|
||||
markWorktreeSeedPending({
|
||||
configPath: targetEndpoint.configPath,
|
||||
sourceConfigPath: source.configPath,
|
||||
targetInstanceId: targetPaths.instanceId,
|
||||
seedMode,
|
||||
});
|
||||
const seeded = await runVerifiedWorktreeSeed({
|
||||
configPath: targetEndpoint.configPath,
|
||||
sourceConfigPath: source.configPath,
|
||||
sourceConfig,
|
||||
targetConfig,
|
||||
|
|
@ -3544,8 +4141,9 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise<void> {
|
|||
instanceId: targetPaths.instanceId,
|
||||
seedMode,
|
||||
preserveLiveWork: opts.preserveLiveWork,
|
||||
expectedCompanyId: nonEmpty(process.env.PAPERCLIP_SEED_EXPECTED_COMPANY_ID) ?? undefined,
|
||||
seedDatabase: seedWorktreeDatabase,
|
||||
});
|
||||
markWorktreeSeedComplete({ configPath: targetEndpoint.configPath, seedMode });
|
||||
spinner.stop(`Reseeded ${targetEndpoint.label} (${seedMode}).`);
|
||||
p.log.message(pc.dim(`Source: ${source.configPath}`));
|
||||
p.log.message(pc.dim(`Target: ${targetEndpoint.configPath}`));
|
||||
|
|
@ -3567,6 +4165,8 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise<void> {
|
|||
} catch (error) {
|
||||
spinner.stop(pc.red("Failed to reseed worktree database."));
|
||||
throw error;
|
||||
} finally {
|
||||
await releaseSeedLock();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3748,6 +4348,7 @@ export function registerWorktreeCommands(program: Command): void {
|
|||
.option("--preserve-live-work", "Do not quarantine copied agent work or workspace runtime services in the seeded worktree", false)
|
||||
.option("--yes", "Skip the destructive confirmation prompt", false)
|
||||
.option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false)
|
||||
.option("--backup-target", "Retain a recoverable full backup of the isolated target DB before reseeding", false)
|
||||
.action(worktreeReseedCommand);
|
||||
|
||||
worktree
|
||||
|
|
|
|||
|
|
@ -479,25 +479,26 @@ After `worktree init`, both the server and the CLI auto-load the repo-local `.pa
|
|||
|
||||
Seeding a worktree database is the heaviest part of `worktree init`. That work can be deferred so a worktree is cheap to create and only pays the seed cost the first time it is actually used — the CLI/dev-time analog of the server's lazy runtime provisioning (see the board-operator guide's "Lazy runtime provisioning" section).
|
||||
|
||||
Seeding state is tracked with two marker files under the worktree's `.paperclip/` directory:
|
||||
Seeding state is tracked in `.paperclip/seed-manifest.json`. The versioned manifest records only non-secret evidence: source instance id/config path, target instance id, seed mode, snapshot time, migration revision, attempt timestamps, current phase, terminal state, and a bounded phase diagnostic history. It never stores database credentials or auth credential material. The legacy `seed-pending` and `seed-complete` files remain read-only compatibility signals for worktrees created before the manifest shipped.
|
||||
|
||||
- `seed-pending` — the isolated database has not been seeded yet (a **lean** worktree). Written by `worktree init` before any seed runs.
|
||||
- `seed-complete` — the database was seeded; the pending marker is removed.
|
||||
The default `worktree init` still seeds eagerly. A lean worktree (created without an eager seed) has a `pending` manifest until something seeds it on demand:
|
||||
|
||||
The default `worktree init` still seeds eagerly and writes `seed-complete` immediately. A lean worktree (created without an eager seed) keeps its `seed-pending` marker until something seeds it on demand:
|
||||
|
||||
- `pnpm paperclipai worktree ensure-seeded` performs the deferred seed **exactly once**. It is lock-guarded and idempotent: a present `seed-complete` marker or a missing `seed-pending` marker short-circuits it, so it is safe to call repeatedly and from concurrent processes. It reads the source instance from the `seed-pending` marker unless you pass `--from-config`.
|
||||
- `paperclipai run` calls `ensureWorktreeSeeded` automatically before doctor/boot, so `run` transparently seeds a lean worktree on first launch.
|
||||
- Managed git-worktree runtime startup also runs `scripts/provision-worktree-runtime.sh` automatically when a legacy workspace policy has no explicit runtime provision command and the worktree is still `seed-pending`. An explicitly configured runtime provision command always takes precedence.
|
||||
- `pnpm paperclipai worktree ensure-seeded` performs the deferred seed **exactly once**. It is lock-guarded and idempotent: only a complete `verified` manifest short-circuits it, so it is safe to call repeatedly and from concurrent processes. Managed workspaces derive the source exclusively from the control-plane-provided base project workspace; manual worktrees must pass `--from-config`.
|
||||
- `paperclipai run` calls `ensureWorktreeSeeded` automatically before doctor/boot. Managed runs transparently seed a lean worktree from their registered base workspace; an unmanaged lean worktree must first run `worktree ensure-seeded --from-config <source-config>`.
|
||||
- Managed git-worktree runtime startup also runs `scripts/provision-worktree-runtime.sh` automatically when a legacy workspace policy has no explicit runtime provision command and the manifest is not verified. An explicitly configured runtime provision command always takes precedence.
|
||||
- Worktrees created before lazy seeding shipped have neither marker; they are treated as already-seeded for backward compatibility (never re-cloned).
|
||||
|
||||
**Seed-pending guard.** `pnpm dev` (the dev-runner) refuses to boot a worktree whose database is still `seed-pending` and points you at the fix:
|
||||
Both `minimal` and `full` modes use the same terminal data-validation contract. Source validation accepts a migration journal that is a prefix of the checkout's journal and records the source revision in seed diagnostics; it rejects a source that is ahead of the checkout because that would require a downgrade. After restore, Paperclip applies pending migrations and requires the target journal to be current. Both validations also read a credential-backed auth user, instance administrator role, active company membership, and representative cloned company/issue pair. Restore, migrations, execution quarantine, routine pausing, workspace rebinding, and post-restore validation all run under the seed lock. An interruption leaves the exact active phase in terminal `failed` state; it cannot produce readiness evidence.
|
||||
|
||||
The seed manifest never grants source-path authority. Its source path and instance are diagnostic assertions that must exactly match the realpath-canonical registered source before any lock, backup, service stop, spawn, or database mutation. Missing registration, sibling or foreign paths, symlink aliases, source/target identity collisions, and company mismatches fail closed.
|
||||
|
||||
**Unverified-seed guard.** `pnpm dev` (the dev-runner) refuses to boot a worktree whose manifest is pending, running, failed, malformed, or missing required verification evidence and points you at the fix:
|
||||
|
||||
```
|
||||
[paperclip] this worktree database is seed-pending. Run `pnpm paperclipai worktree ensure-seeded` before `pnpm dev`.
|
||||
```
|
||||
|
||||
This guard (`isWorktreeSeedPending` in `server/src/dev-runner-worktree.ts`) prevents `pnpm dev` from starting the app against an empty, unseeded database — run `worktree ensure-seeded` once and re-run `pnpm dev`.
|
||||
This guard (`isWorktreeSeedPending` in `server/src/dev-runner-worktree.ts`) prevents `pnpm dev` from starting the app against an empty or partially restored database — run `worktree ensure-seeded` once and re-run `pnpm dev`.
|
||||
|
||||
Provisioned git worktrees also pause seeded routines that still have enabled schedule triggers in the isolated worktree database by default. This prevents copied daily/cron routines from firing unexpectedly inside the new workspace instance during development without disabling webhook/API-only routines.
|
||||
|
||||
|
|
@ -522,6 +523,27 @@ paperclipai worktree env
|
|||
eval "$(paperclipai worktree env)"
|
||||
```
|
||||
|
||||
### Workspace login handoff and readiness
|
||||
|
||||
Opening a managed workspace board no longer depends on knowing which cloned password is current. `Open workspace` asks the main control plane for a short-lived, single-use ticket; the isolated workspace verifies it and creates its own instance-scoped Better Auth session.
|
||||
|
||||
- **Issue** — `POST /api/execution-workspaces/{id}/login-handoff` (board actors only). The ticket is bound to the caller's user id and email, the execution workspace id, the workspace's company id, the isolated instance id, the live runtime origin, a nonce, and a ~90 s expiry. Nothing in the request body influences that binding; only the landing path is caller-supplied and it is reduced to a same-origin path before signing.
|
||||
- **Exchange** — `GET /api/auth/{workspace-handoff}/exchange?ticket=…` on the workspace itself, registered as a Better Auth plugin so session creation and cookie signing use Better Auth's own path. It verifies the signature, expiry, origin, instance, workspace, company, the cloned user's email, and an active membership **in that company**, records the nonce so a replay loses, and answers with an HTTP redirect — which is what keeps the ticket out of browser history. Request logs redact the `ticket` parameter.
|
||||
- **Fallback** — direct email/password sign-in still works and the UI labels it accurately as *snapshot-local credentials*. A rejected ticket redirects to `/auth?workspaceHandoffError=<reason>` rather than failing opaquely.
|
||||
|
||||
Key material is derived, never shared. The control plane keeps a root secret (`PAPERCLIP_WORKSPACE_HANDOFF_SECRET`, or a domain-separated derivation from the instance's existing signing secret when that is unset) and injects only per-workspace values into the guest process:
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `PAPERCLIP_WORKSPACE_HANDOFF_KEY` | Per-workspace ticket verification key. A guest cannot mint a ticket for a sibling workspace. |
|
||||
| `PAPERCLIP_WORKSPACE_READINESS_TOKEN` | Bearer token the control plane presents to read this workspace's protected readiness. |
|
||||
| `PAPERCLIP_EXECUTION_WORKSPACE_ID` | Execution workspace the guest was provisioned for, used for identity checks. |
|
||||
| `PAPERCLIP_EXECUTION_WORKSPACE_COMPANY_ID` | Company whose board the guest represents. Scopes both the membership check and the readiness probes, so "some company in the clone is fine" cannot pass for the one being opened. |
|
||||
|
||||
Protected `/api/health` on a cloned workspace additionally carries a `workspace` block — `state`, `databaseReady`, `cloneDataReady`, `authHandoffReady`, `seedState`, `seedPhase`, `instanceId`, `executionWorkspaceId`, `failurePhase`. Public health stays redacted. Managed runtime start will not publish `running / healthy` unless that block agrees and names this exact instance and workspace, and runtime-service work products are refreshed from the live runtime row so a port change cannot leave a stale user-facing URL.
|
||||
|
||||
The workspace UI surfaces `Provisioning database`, `Validating clone`, `Ready`, `Degraded`, `Repairing`, and `Repair failed`, each with one safe action (open, start, repair, or read the log).
|
||||
|
||||
### Worktree CLI Reference
|
||||
|
||||
**`npx paperclipai worktree init [options]`** — Create repo-local config/env and an isolated instance for the current worktree.
|
||||
|
|
@ -603,6 +625,7 @@ For an already-created worktree where you want to keep the existing repo-local c
|
|||
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `full`) |
|
||||
| `--yes` | Skip the destructive confirmation prompt |
|
||||
| `--allow-live-target` | Override the guard that requires the target worktree DB to be stopped first |
|
||||
| `--backup-target` | Retain a recoverable full target-DB backup before reseeding |
|
||||
|
||||
Examples:
|
||||
|
||||
|
|
@ -622,6 +645,8 @@ npx paperclipai worktree reseed \
|
|||
--seed-mode full
|
||||
```
|
||||
|
||||
Managed workspace repair uses this same verified full-reseed contract through `POST /api/execution-workspaces/:id/runtime-commands/repair`. The exclusive, audited operation stops managed services, writes a recoverable pre-repair database backup under the isolated instance's backup directory, performs the full seed/migration/quarantine/rebinding sequence, and restarts only after terminal manifest and service-health validation. It preserves the worktree filesystem. On failure, services remain stopped while the database backup, seed manifest, bounded phase diagnostics, and operation log are retained for inspection; repair never retries itself in a loop.
|
||||
|
||||
**`npx paperclipai worktree:make <name> [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step.
|
||||
|
||||
| Option | Description |
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ describe("claude_local ACP startup fallback", () => {
|
|||
|
||||
it("trusts the Paperclip API URL when network access is allowlisted", async () => {
|
||||
const paperclipApiUrl = "http://127.0.0.1:4310";
|
||||
vi.stubEnv("PAPERCLIP_RUNTIME_API_URL", paperclipApiUrl);
|
||||
vi.stubEnv("PAPERCLIP_API_URL", paperclipApiUrl);
|
||||
const ctx = buildContext({ networkScope: "allowlist" });
|
||||
|
||||
await execute(ctx as never);
|
||||
|
|
|
|||
|
|
@ -35,13 +35,20 @@ function splitMigrationStatements(content: string): string[] {
|
|||
}
|
||||
|
||||
export type MigrationState =
|
||||
| { status: "upToDate"; tableCount: number; availableMigrations: string[]; appliedMigrations: string[] }
|
||||
| {
|
||||
status: "upToDate";
|
||||
tableCount: number;
|
||||
availableMigrations: string[];
|
||||
appliedMigrations: string[];
|
||||
journalEntryCount: number;
|
||||
}
|
||||
| {
|
||||
status: "needsMigrations";
|
||||
tableCount: number;
|
||||
availableMigrations: string[];
|
||||
appliedMigrations: string[];
|
||||
pendingMigrations: string[];
|
||||
journalEntryCount: number;
|
||||
reason: "no-migration-journal-empty-db" | "no-migration-journal-non-empty-db" | "pending-migrations";
|
||||
};
|
||||
|
||||
|
|
@ -683,6 +690,7 @@ export async function inspectMigrations(url: string): Promise<MigrationState> {
|
|||
availableMigrations,
|
||||
appliedMigrations: [],
|
||||
pendingMigrations: availableMigrations,
|
||||
journalEntryCount: 0,
|
||||
reason: "no-migration-journal-non-empty-db",
|
||||
};
|
||||
}
|
||||
|
|
@ -693,10 +701,16 @@ export async function inspectMigrations(url: string): Promise<MigrationState> {
|
|||
availableMigrations,
|
||||
appliedMigrations: [],
|
||||
pendingMigrations: availableMigrations,
|
||||
journalEntryCount: 0,
|
||||
reason: "no-migration-journal-empty-db",
|
||||
};
|
||||
}
|
||||
|
||||
const qualifiedMigrationTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`;
|
||||
const journalCountRows = await sql.unsafe<{ count: number }[]>(
|
||||
`SELECT count(*)::int AS count FROM ${qualifiedMigrationTable}`,
|
||||
);
|
||||
const journalEntryCount = journalCountRows[0]?.count ?? 0;
|
||||
const appliedMigrations = await loadAppliedMigrations(sql, migrationTableSchema, availableMigrations);
|
||||
const pendingMigrations = availableMigrations.filter((name) => !appliedMigrations.includes(name));
|
||||
if (pendingMigrations.length === 0) {
|
||||
|
|
@ -705,6 +719,7 @@ export async function inspectMigrations(url: string): Promise<MigrationState> {
|
|||
tableCount,
|
||||
availableMigrations,
|
||||
appliedMigrations,
|
||||
journalEntryCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -714,6 +729,7 @@ export async function inspectMigrations(url: string): Promise<MigrationState> {
|
|||
availableMigrations,
|
||||
appliedMigrations,
|
||||
pendingMigrations,
|
||||
journalEntryCount,
|
||||
reason: "pending-migrations",
|
||||
};
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config";
|
|||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -876,6 +876,11 @@ export type {
|
|||
WorkspaceOperation,
|
||||
WorkspaceOperationPhase,
|
||||
WorkspaceOperationStatus,
|
||||
WorkspaceLoginHandoffTicketResponse,
|
||||
WorkspaceReadiness,
|
||||
WorkspaceReadinessProbeResult,
|
||||
WorkspaceReadinessState,
|
||||
WorkspaceSeedReadinessState,
|
||||
NormalizedWorkspaceFileAvailabilityQuery,
|
||||
WorkspaceFileAvailabilityQuery,
|
||||
WorkspaceFileAvailabilityRequest,
|
||||
|
|
@ -1436,6 +1441,7 @@ export type {
|
|||
QuotaWindow,
|
||||
ProviderQuotaResult,
|
||||
} from "./types/index.js";
|
||||
export { WORKSPACE_READINESS_STATES } from "./types/index.js";
|
||||
export {
|
||||
COMPANY_SEARCH_EXTRACT_KINDS,
|
||||
COMPANY_SEARCH_EXTRACT_SCOPES,
|
||||
|
|
|
|||
|
|
@ -407,6 +407,14 @@ export type {
|
|||
WorkspaceOperationPhase,
|
||||
WorkspaceOperationStatus,
|
||||
} from "./workspace-operation.js";
|
||||
export { WORKSPACE_READINESS_STATES } from "./workspace-readiness.js";
|
||||
export type {
|
||||
WorkspaceLoginHandoffTicketResponse,
|
||||
WorkspaceReadiness,
|
||||
WorkspaceReadinessProbeResult,
|
||||
WorkspaceReadinessState,
|
||||
WorkspaceSeedReadinessState,
|
||||
} from "./workspace-readiness.js";
|
||||
export type {
|
||||
NormalizedWorkspaceFileAvailabilityQuery,
|
||||
WorkspaceFileAvailabilityQuery,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export type WorkspaceOperationPhase =
|
|||
| "workspace_config_freshness"
|
||||
| "workspace_provision"
|
||||
| "workspace_runtime_provision"
|
||||
| "workspace_repair"
|
||||
| "workspace_teardown"
|
||||
| "worktree_cleanup"
|
||||
| "workspace_finalize";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Workspace readiness contract (PAP-17572).
|
||||
*
|
||||
* Transport health ("the port answered") is not user readiness. A cloned
|
||||
* workspace is only usable when its own database is reachable, the seed
|
||||
* manifest reached a verified terminal state, representative cloned rows are
|
||||
* readable, the password-independent login handoff is configured, and the
|
||||
* instance/workspace/company identity matches the one the control plane expects.
|
||||
*
|
||||
* These fields ride on the *protected* health response only. Public health
|
||||
* stays redacted: an anonymous caller learns liveness, never which instance or
|
||||
* execution workspace answered.
|
||||
*/
|
||||
|
||||
export const WORKSPACE_READINESS_STATES = [
|
||||
/** The isolated database is still being restored. */
|
||||
"provisioning",
|
||||
/** Restore finished; the clone is being validated. */
|
||||
"validating",
|
||||
/** Every readiness signal agrees; the workspace can be opened. */
|
||||
"ready",
|
||||
/** Serving, but at least one readiness signal regressed after being ready. */
|
||||
"degraded",
|
||||
/** A managed repair is running right now. */
|
||||
"repairing",
|
||||
/** A terminal provisioning or repair failure needs an operator action. */
|
||||
"failed",
|
||||
] as const;
|
||||
|
||||
export type WorkspaceReadinessState = (typeof WORKSPACE_READINESS_STATES)[number];
|
||||
|
||||
/** Terminal state of the versioned seed manifest, or `unknown` for a legacy marker. */
|
||||
export type WorkspaceSeedReadinessState =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "verified"
|
||||
| "failed"
|
||||
| "unknown"
|
||||
| "absent";
|
||||
|
||||
export interface WorkspaceReadiness {
|
||||
state: WorkspaceReadinessState;
|
||||
/** `SELECT 1` plus a readable migration journal on this instance's own database. */
|
||||
databaseReady: boolean;
|
||||
/** A representative cloned company/issue pair is readable. */
|
||||
cloneDataReady: boolean;
|
||||
/** A signing key and a resolvable cloned admin identity are both present. */
|
||||
authHandoffReady: boolean;
|
||||
/** Exact cloned user checked for a caller-scoped handoff probe, or null. */
|
||||
authHandoffUserId: string | null;
|
||||
seedState: WorkspaceSeedReadinessState;
|
||||
/** Last recorded seed phase, e.g. `restore` or `post_restore_validation`. */
|
||||
seedPhase: string | null;
|
||||
seedMode: "minimal" | "full" | null;
|
||||
/** Resolved instance identity of the process that answered, never a branch heuristic. */
|
||||
instanceId: string | null;
|
||||
/** Execution workspace this instance was provisioned for, when known. */
|
||||
executionWorkspaceId: string | null;
|
||||
/** Company whose cloned board this workspace serves, when known. */
|
||||
companyId: string | null;
|
||||
/** Phase that failed, so operators do not need host-shell archaeology. */
|
||||
failurePhase: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the control plane's protected readiness probe against a managed
|
||||
* workspace runtime. `reason` is a stable machine code so the runtime gate and
|
||||
* the UI can branch without parsing prose.
|
||||
*/
|
||||
export type WorkspaceReadinessProbeResult =
|
||||
| { ok: true; readiness: WorkspaceReadiness }
|
||||
| {
|
||||
ok: false;
|
||||
reason:
|
||||
| "unreachable"
|
||||
| "http_error"
|
||||
| "unhealthy_payload"
|
||||
| "readiness_missing"
|
||||
| "not_ready"
|
||||
| "identity_mismatch";
|
||||
readiness: WorkspaceReadiness | null;
|
||||
detail: string | null;
|
||||
};
|
||||
|
||||
export interface WorkspaceLoginHandoffTicketResponse {
|
||||
/** Absolute URL that exchanges the ticket and redirects to the cloned board. */
|
||||
url: string;
|
||||
expiresAt: string;
|
||||
/** `handoff` for the signed path, `credentials` when only the fallback is available. */
|
||||
mode: "handoff" | "credentials";
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resolveCanonicalWorktreeSeedSource } from "./worktree-seed-source.js";
|
||||
|
||||
const cleanup: string[] = [];
|
||||
|
||||
function makeInstance(prefix: string, instanceId: string) {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
cleanup.push(cwd);
|
||||
const configDir = path.join(cwd, ".paperclip");
|
||||
const configPath = path.join(configDir, "config.json");
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, "{}\n");
|
||||
fs.writeFileSync(path.join(configDir, ".env"), `PAPERCLIP_INSTANCE_ID=${instanceId}\n`);
|
||||
return { cwd, configPath, instanceId };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of cleanup.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("resolveCanonicalWorktreeSeedSource", () => {
|
||||
it("returns only the registered base workspace config", () => {
|
||||
const source = makeInstance("paperclip-seed-source-", "source-instance");
|
||||
const target = makeInstance("paperclip-seed-target-", "target-instance");
|
||||
|
||||
expect(resolveCanonicalWorktreeSeedSource({
|
||||
registeredBaseWorkspaceCwd: source.cwd,
|
||||
targetConfigPath: target.configPath,
|
||||
expectedTargetInstanceId: target.instanceId,
|
||||
manifestSource: { configPath: source.configPath, instanceId: source.instanceId },
|
||||
manifestTargetInstanceId: target.instanceId,
|
||||
})).toMatchObject({
|
||||
baseWorkspaceCwd: source.cwd,
|
||||
configPath: source.configPath,
|
||||
targetConfigPath: target.configPath,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed without registration and when source equals target", () => {
|
||||
const target = makeInstance("paperclip-seed-same-target-", "target-instance");
|
||||
const diagnostic = { configPath: target.configPath, instanceId: target.instanceId };
|
||||
|
||||
expect(() => resolveCanonicalWorktreeSeedSource({
|
||||
targetConfigPath: target.configPath,
|
||||
expectedTargetInstanceId: target.instanceId,
|
||||
manifestSource: diagnostic,
|
||||
manifestTargetInstanceId: target.instanceId,
|
||||
})).toThrow(/not registered/);
|
||||
|
||||
expect(() => resolveCanonicalWorktreeSeedSource({
|
||||
registeredBaseWorkspaceCwd: target.cwd,
|
||||
targetConfigPath: target.configPath,
|
||||
expectedTargetInstanceId: target.instanceId,
|
||||
manifestSource: diagnostic,
|
||||
manifestTargetInstanceId: target.instanceId,
|
||||
})).toThrow(/same canonical file/);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export type WorktreeSeedSourceDiagnostic = {
|
||||
configPath?: unknown;
|
||||
instanceId?: unknown;
|
||||
};
|
||||
|
||||
export type CanonicalWorktreeSeedSource = {
|
||||
baseWorkspaceCwd: string | null;
|
||||
configPath: string;
|
||||
instanceId: string;
|
||||
targetConfigPath: string;
|
||||
targetInstanceId: string;
|
||||
};
|
||||
|
||||
function readInstanceId(configPath: string, label: "source" | "target"): string {
|
||||
const envPath = path.join(path.dirname(configPath), ".env");
|
||||
if (!existsSync(envPath)) {
|
||||
throw new Error(`Registered ${label} Paperclip config is missing its adjacent .env instance pointer.`);
|
||||
}
|
||||
const contents = readFileSync(envPath, "utf8");
|
||||
for (const rawLine of contents.split(/\r?\n/)) {
|
||||
const match = rawLine.match(
|
||||
/^\s*(?:export\s+)?PAPERCLIP_INSTANCE_ID\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s#]+))/,
|
||||
);
|
||||
const value = (match?.[1] ?? match?.[2] ?? match?.[3] ?? "").trim();
|
||||
if (value) return value;
|
||||
}
|
||||
throw new Error(`Registered ${label} Paperclip config has no PAPERCLIP_INSTANCE_ID binding.`);
|
||||
}
|
||||
|
||||
function canonicalRegularFile(filePath: string, label: string): string {
|
||||
const resolved = path.resolve(filePath);
|
||||
let canonical: string;
|
||||
try {
|
||||
canonical = realpathSync(resolved);
|
||||
} catch {
|
||||
throw new Error(`${label} does not exist at ${resolved}.`);
|
||||
}
|
||||
if (canonical !== resolved || lstatSync(resolved).isSymbolicLink()) {
|
||||
throw new Error(`${label} must be a canonical path and cannot use a symlink alias.`);
|
||||
}
|
||||
if (!lstatSync(canonical).isFile()) {
|
||||
throw new Error(`${label} is not a regular file at ${canonical}.`);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a worktree seed source without granting authority to the seed manifest.
|
||||
*
|
||||
* A managed caller supplies the project-workspace cwd from its server-owned row.
|
||||
* An operator may instead supply an explicit source config. When both are present,
|
||||
* the explicit path must still equal the registered project-workspace config.
|
||||
* Manifest source fields are diagnostic assertions only and can only make this
|
||||
* validation fail; they never select the returned source.
|
||||
*/
|
||||
export function resolveCanonicalWorktreeSeedSource(input: {
|
||||
registeredBaseWorkspaceCwd?: string | null;
|
||||
explicitSourceConfigPath?: string | null;
|
||||
targetConfigPath: string;
|
||||
expectedTargetInstanceId: string;
|
||||
manifestSource: WorktreeSeedSourceDiagnostic | null | undefined;
|
||||
manifestTargetInstanceId?: unknown;
|
||||
}): CanonicalWorktreeSeedSource {
|
||||
const registeredCwd = input.registeredBaseWorkspaceCwd?.trim();
|
||||
const explicitSource = input.explicitSourceConfigPath?.trim();
|
||||
if (!registeredCwd && !explicitSource) {
|
||||
throw new Error(
|
||||
"Worktree seed source is not registered. Managed boot requires a project workspace; manual boot requires --from-config.",
|
||||
);
|
||||
}
|
||||
|
||||
let canonicalBaseCwd: string | null = null;
|
||||
let registeredConfigPath: string | null = null;
|
||||
if (registeredCwd) {
|
||||
const resolvedRegisteredCwd = path.resolve(registeredCwd);
|
||||
try {
|
||||
canonicalBaseCwd = realpathSync(resolvedRegisteredCwd);
|
||||
} catch {
|
||||
throw new Error(`Registered base project workspace does not exist at ${resolvedRegisteredCwd}.`);
|
||||
}
|
||||
if (canonicalBaseCwd !== resolvedRegisteredCwd) {
|
||||
throw new Error("Registered base project workspace must be canonical and cannot use a symlink alias.");
|
||||
}
|
||||
if (!lstatSync(canonicalBaseCwd).isDirectory()) {
|
||||
throw new Error(`Registered base project workspace is not a directory at ${canonicalBaseCwd}.`);
|
||||
}
|
||||
registeredConfigPath = path.join(canonicalBaseCwd, ".paperclip", "config.json");
|
||||
}
|
||||
|
||||
const selectedPath = registeredConfigPath ?? explicitSource!;
|
||||
const canonicalSourceConfigPath = canonicalRegularFile(selectedPath, "Registered source Paperclip config");
|
||||
if (registeredConfigPath && canonicalSourceConfigPath !== registeredConfigPath) {
|
||||
throw new Error("Registered source Paperclip config escapes the base project workspace or uses a symlink alias.");
|
||||
}
|
||||
|
||||
if (explicitSource) {
|
||||
const canonicalExplicitSource = canonicalRegularFile(explicitSource, "Explicit source Paperclip config");
|
||||
if (canonicalExplicitSource !== canonicalSourceConfigPath) {
|
||||
throw new Error("Explicit source Paperclip config does not match the registered base project workspace.");
|
||||
}
|
||||
}
|
||||
|
||||
const canonicalTargetConfigPath = canonicalRegularFile(
|
||||
input.targetConfigPath,
|
||||
"Target worktree Paperclip config",
|
||||
);
|
||||
if (canonicalSourceConfigPath === canonicalTargetConfigPath) {
|
||||
throw new Error("Source and target Paperclip configs are the same canonical file.");
|
||||
}
|
||||
|
||||
const sourceInstanceId = readInstanceId(canonicalSourceConfigPath, "source");
|
||||
const targetInstanceId = readInstanceId(canonicalTargetConfigPath, "target");
|
||||
if (targetInstanceId !== input.expectedTargetInstanceId) {
|
||||
throw new Error("Target Paperclip instance does not match the registered worktree instance.");
|
||||
}
|
||||
if (sourceInstanceId === targetInstanceId) {
|
||||
throw new Error("Source and target Paperclip configs name the same instance.");
|
||||
}
|
||||
|
||||
const diagnosticPath = typeof input.manifestSource?.configPath === "string"
|
||||
? input.manifestSource.configPath.trim()
|
||||
: "";
|
||||
if (!diagnosticPath) {
|
||||
throw new Error("Worktree seed manifest is missing source path diagnostics.");
|
||||
}
|
||||
const canonicalDiagnosticPath = canonicalRegularFile(
|
||||
diagnosticPath,
|
||||
"Worktree seed manifest source diagnostic",
|
||||
);
|
||||
if (path.resolve(diagnosticPath) !== canonicalSourceConfigPath || canonicalDiagnosticPath !== canonicalSourceConfigPath) {
|
||||
throw new Error("Worktree seed manifest source path does not match the registered canonical source.");
|
||||
}
|
||||
if (input.manifestSource?.instanceId !== sourceInstanceId) {
|
||||
throw new Error("Worktree seed manifest source instance does not match the registered source instance.");
|
||||
}
|
||||
if (input.manifestTargetInstanceId !== targetInstanceId) {
|
||||
throw new Error("Worktree seed manifest target instance does not match the registered target instance.");
|
||||
}
|
||||
|
||||
return {
|
||||
baseWorkspaceCwd: canonicalBaseCwd,
|
||||
configPath: canonicalSourceConfigPath,
|
||||
instanceId: sourceInstanceId,
|
||||
targetConfigPath: canonicalTargetConfigPath,
|
||||
targetInstanceId,
|
||||
};
|
||||
}
|
||||
|
|
@ -37,6 +37,9 @@ test.after(() => {
|
|||
*/
|
||||
function makeBaseWorkspace({ helpExit, initExit, ensureExit = 0 }) {
|
||||
const baseCwd = makeTempDir("paperclip-provision-base-");
|
||||
fs.mkdirSync(path.join(baseCwd, ".paperclip"), { recursive: true });
|
||||
fs.writeFileSync(path.join(baseCwd, ".paperclip", "config.json"), "{}\n");
|
||||
fs.writeFileSync(path.join(baseCwd, ".paperclip", ".env"), "PAPERCLIP_INSTANCE_ID=base-source\n");
|
||||
const runnerPath = path.join(baseCwd, "cli", "node_modules", "tsx", "dist", "cli.mjs");
|
||||
const entryPath = path.join(baseCwd, "cli", "src", "index.ts");
|
||||
fs.mkdirSync(path.dirname(runnerPath), { recursive: true });
|
||||
|
|
@ -91,6 +94,8 @@ function runProvision(baseCwd, { pathPrefix } = {}) {
|
|||
PAPERCLIP_WORKSPACE_BRANCH: "feature/provision-test",
|
||||
PAPERCLIP_WORKTREES_DIR: worktreesHome,
|
||||
PAPERCLIP_HOME: path.join(worktreesHome, "no-such-instance-home"),
|
||||
PAPERCLIP_PROJECT_WORKSPACE_ID: "project-workspace-1",
|
||||
PAPERCLIP_SEED_EXPECTED_COMPANY_ID: "company-1",
|
||||
},
|
||||
});
|
||||
return { result, worktreeCwd, worktreesHome };
|
||||
|
|
@ -109,6 +114,8 @@ function runRuntimeProvision(baseCwd, worktreeCwd) {
|
|||
PAPERCLIP_WORKSPACE_BRANCH: "feature/provision-runtime-test",
|
||||
PAPERCLIP_WORKTREES_DIR: worktreesHome,
|
||||
PAPERCLIP_HOME: path.join(worktreesHome, "no-such-instance-home"),
|
||||
PAPERCLIP_PROJECT_WORKSPACE_ID: "project-workspace-1",
|
||||
PAPERCLIP_COMPANY_ID: "company-1",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -137,7 +144,10 @@ test("uses the base CLI when its import graph boots", () => {
|
|||
assert.equal(result.status, 0, result.stderr);
|
||||
const config = readWorktreeConfig(worktreeCwd);
|
||||
assert.equal(config.$meta.source, "fake-cli");
|
||||
assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending")));
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state,
|
||||
"pending",
|
||||
);
|
||||
const initInvocation = readCliInvocations(baseCwd).find(
|
||||
(args) => args[0] === "worktree" && args[1] === "init",
|
||||
);
|
||||
|
|
@ -166,7 +176,10 @@ test("falls back to an isolated config when the base CLI cannot boot", () => {
|
|||
);
|
||||
const env = fs.readFileSync(path.join(worktreeCwd, ".paperclip", ".env"), "utf8");
|
||||
assert.match(env, /PAPERCLIP_IN_WORKTREE=true/);
|
||||
assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending")));
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state,
|
||||
"pending",
|
||||
);
|
||||
});
|
||||
|
||||
test("repairs an unhealthy base install under the lock and then uses the CLI", (t) => {
|
||||
|
|
@ -181,6 +194,9 @@ test("repairs an unhealthy base install under the lock and then uses the CLI", (
|
|||
// The CLI's health is controlled by a flag file, and a fake `pnpm install`
|
||||
// creates that flag — modeling a forced reinstall that relinks the store.
|
||||
const baseCwd = makeTempDir("paperclip-provision-repair-base-");
|
||||
fs.mkdirSync(path.join(baseCwd, ".paperclip"), { recursive: true });
|
||||
fs.writeFileSync(path.join(baseCwd, ".paperclip", "config.json"), "{}\n");
|
||||
fs.writeFileSync(path.join(baseCwd, ".paperclip", ".env"), "PAPERCLIP_INSTANCE_ID=base-source\n");
|
||||
const healthFlag = path.join(baseCwd, "cli-healthy.flag");
|
||||
const runnerPath = path.join(baseCwd, "cli", "node_modules", "tsx", "dist", "cli.mjs");
|
||||
const entryPath = path.join(baseCwd, "cli", "src", "index.ts");
|
||||
|
|
@ -267,11 +283,11 @@ test("runtime provisioning invokes ensure-seeded once and fast-exits after succe
|
|||
.filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded");
|
||||
assert.equal(ensureCallsAfterFirst.length, 1);
|
||||
assert.ok(ensureCallsAfterFirst[0].includes("--config"));
|
||||
assert.ok(ensureCallsAfterFirst[0].includes("--from-config"));
|
||||
assert.ok(!ensureCallsAfterFirst[0].includes("--from-config"));
|
||||
|
||||
const second = runRuntimeProvision(baseCwd, worktreeCwd);
|
||||
assert.equal(second.status, 0, second.stderr);
|
||||
assert.match(second.stderr, /already seeded; skipping/);
|
||||
assert.match(second.stderr, /already seeded.*skipping/);
|
||||
const ensureCallsAfterSecond = readCliInvocations(baseCwd)
|
||||
.filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded");
|
||||
assert.equal(ensureCallsAfterSecond.length, 1);
|
||||
|
|
@ -290,3 +306,22 @@ test("runtime provisioning leaves seed-pending in place when ensure-seeded fails
|
|||
assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending")));
|
||||
assert.ok(!fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-complete")));
|
||||
});
|
||||
|
||||
test("runtime provisioning does not trust a truncated verified manifest", () => {
|
||||
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0, ensureExit: 4 });
|
||||
const worktreeCwd = makeTempDir("paperclip-provision-runtime-truncated-");
|
||||
fs.mkdirSync(path.join(worktreeCwd, ".paperclip"), { recursive: true });
|
||||
fs.writeFileSync(path.join(worktreeCwd, ".paperclip", "config.json"), "{}\n");
|
||||
fs.writeFileSync(
|
||||
path.join(worktreeCwd, ".paperclip", "seed-manifest.json"),
|
||||
JSON.stringify({ version: 2, state: "verified" }),
|
||||
);
|
||||
|
||||
const result = runRuntimeProvision(baseCwd, worktreeCwd);
|
||||
assert.equal(result.status, 4, result.stderr);
|
||||
assert.equal(
|
||||
readCliInvocations(baseCwd)
|
||||
.filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded").length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,10 +3,9 @@ set -euo pipefail
|
|||
|
||||
base_cwd="${PAPERCLIP_WORKSPACE_BASE_CWD:?PAPERCLIP_WORKSPACE_BASE_CWD is required}"
|
||||
worktree_cwd="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}"
|
||||
paperclip_home="${PAPERCLIP_HOME:-$HOME/.paperclip}"
|
||||
paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}"
|
||||
paperclip_dir="$worktree_cwd/.paperclip"
|
||||
worktree_config_path="$paperclip_dir/config.json"
|
||||
seed_manifest_path="$paperclip_dir/seed-manifest.json"
|
||||
seed_pending_marker_path="$paperclip_dir/seed-pending"
|
||||
seed_complete_marker_path="$paperclip_dir/seed-complete"
|
||||
|
||||
|
|
@ -20,8 +19,37 @@ if [[ ! -d "$worktree_cwd" ]]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -e "$seed_complete_marker_path" || ! -e "$seed_pending_marker_path" ]]; then
|
||||
echo "Worktree database is already seeded; skipping runtime provisioning." >&2
|
||||
if [[ -e "$seed_manifest_path" ]]; then
|
||||
seed_manifest_state="$(SEED_MANIFEST_PATH="$seed_manifest_path" node <<'EOF'
|
||||
const fs = require("node:fs");
|
||||
try {
|
||||
const value = JSON.parse(fs.readFileSync(process.env.SEED_MANIFEST_PATH, "utf8"));
|
||||
const complete = value?.version === 2
|
||||
&& value?.state === "verified"
|
||||
&& value?.phase === "complete"
|
||||
&& typeof value?.source?.instanceId === "string" && value.source.instanceId.length > 0
|
||||
&& typeof value?.source?.configPath === "string" && value.source.configPath.length > 0
|
||||
&& (value?.seedMode === "minimal" || value?.seedMode === "full")
|
||||
&& typeof value?.snapshotAt === "string" && value.snapshotAt.length > 0
|
||||
&& typeof value?.migrationRevision === "string" && value.migrationRevision.length > 0
|
||||
&& typeof value?.targetInstanceId === "string" && value.targetInstanceId.length > 0
|
||||
&& typeof value?.attemptId === "string" && value.attemptId.length > 0
|
||||
&& typeof value?.startedAt === "string"
|
||||
&& typeof value?.finishedAt === "string"
|
||||
&& Array.isArray(value?.diagnostics)
|
||||
&& value.diagnostics.some((entry) => entry?.phase === "complete" && entry?.status === "succeeded" && typeof entry?.at === "string");
|
||||
process.stdout.write(complete ? "verified" : "incomplete");
|
||||
} catch {
|
||||
process.stdout.write("invalid");
|
||||
}
|
||||
EOF
|
||||
)"
|
||||
if [[ "$seed_manifest_state" == "verified" ]]; then
|
||||
echo "Worktree database has a verified seed manifest; skipping runtime provisioning." >&2
|
||||
exit 0
|
||||
fi
|
||||
elif [[ -e "$seed_complete_marker_path" || ! -e "$seed_pending_marker_path" ]]; then
|
||||
echo "Worktree database is already seeded by a legacy marker; skipping runtime provisioning." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
|
@ -30,20 +58,10 @@ if [[ ! -f "$worktree_config_path" ]]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
source_config_path="${PAPERCLIP_CONFIG:-}"
|
||||
if [[ -z "$source_config_path" && ( -e "$base_cwd/.paperclip/config.json" || -L "$base_cwd/.paperclip/config.json" ) ]]; then
|
||||
source_config_path="$base_cwd/.paperclip/config.json"
|
||||
fi
|
||||
if [[ -z "$source_config_path" ]]; then
|
||||
source_config_path="$paperclip_home/instances/$paperclip_instance_id/config.json"
|
||||
fi
|
||||
source_config_args=(--from-config "$source_config_path")
|
||||
if [[ "$source_config_path" == "$worktree_config_path" ]]; then
|
||||
# A human may invoke this after sourcing `worktree env`, which points
|
||||
# PAPERCLIP_CONFIG at the target. In that case the CLI reads the original
|
||||
# source config from the seed-pending marker instead.
|
||||
source_config_args=()
|
||||
fi
|
||||
# The CLI derives the source from PAPERCLIP_WORKSPACE_BASE_CWD, which the
|
||||
# control plane injects from the registered project-workspace row. The seed
|
||||
# manifest is diagnostic evidence only and must never choose the clone source.
|
||||
source_config_args=()
|
||||
|
||||
base_cli_runner_path="$base_cwd/cli/node_modules/tsx/dist/cli.mjs"
|
||||
base_cli_entry_path="$base_cwd/cli/src/index.ts"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}"
|
|||
paperclip_dir="$worktree_cwd/.paperclip"
|
||||
worktree_config_path="$paperclip_dir/config.json"
|
||||
worktree_env_path="$paperclip_dir/.env"
|
||||
seed_manifest_path="$paperclip_dir/seed-manifest.json"
|
||||
seed_pending_marker_path="$paperclip_dir/seed-pending"
|
||||
seed_complete_marker_path="$paperclip_dir/seed-complete"
|
||||
worktree_name="${PAPERCLIP_WORKSPACE_BRANCH:-$(basename "$worktree_cwd")}"
|
||||
|
|
@ -39,12 +40,16 @@ if [[ ! -d "$worktree_cwd" ]]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
source_config_path="${PAPERCLIP_CONFIG:-}"
|
||||
if [[ -z "$source_config_path" && ( -e "$base_cwd/.paperclip/config.json" || -L "$base_cwd/.paperclip/config.json" ) ]]; then
|
||||
source_config_path="$base_cwd/.paperclip/config.json"
|
||||
canonical_base_cwd="$(cd "$base_cwd" && pwd -P)"
|
||||
source_config_path="$canonical_base_cwd/.paperclip/config.json"
|
||||
if [[ ! -f "$source_config_path" || -L "$source_config_path" ]]; then
|
||||
echo "Registered base project workspace has no canonical Paperclip config: $source_config_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$source_config_path" ]]; then
|
||||
source_config_path="$paperclip_home/instances/$paperclip_instance_id/config.json"
|
||||
canonical_source_dir="$(cd "$(dirname "$source_config_path")" && pwd -P)"
|
||||
if [[ "$canonical_source_dir/config.json" != "$source_config_path" ]]; then
|
||||
echo "Registered base project workspace Paperclip config uses a symlink alias: $source_config_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
source_env_path="$(dirname "$source_config_path")/.env"
|
||||
|
||||
|
|
@ -237,25 +242,48 @@ for (const rawValue of runtimePaths) {
|
|||
EOF
|
||||
}
|
||||
|
||||
write_seed_pending_marker() {
|
||||
write_seed_pending_manifest() {
|
||||
SEED_MANIFEST_PATH="$seed_manifest_path" \
|
||||
SEED_PENDING_MARKER_PATH="$seed_pending_marker_path" \
|
||||
SEED_COMPLETE_MARKER_PATH="$seed_complete_marker_path" \
|
||||
SOURCE_CONFIG_PATH="$source_config_path" \
|
||||
TARGET_INSTANCE_ID="$worktree_instance_id" \
|
||||
node <<'EOF'
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const manifestPath = process.env.SEED_MANIFEST_PATH;
|
||||
const pendingPath = process.env.SEED_PENDING_MARKER_PATH;
|
||||
const completePath = process.env.SEED_COMPLETE_MARKER_PATH;
|
||||
const sourceConfigPath = path.resolve(process.env.SOURCE_CONFIG_PATH);
|
||||
const sourceEnvPath = path.join(path.dirname(sourceConfigPath), ".env");
|
||||
let sourceInstanceId = path.basename(path.dirname(sourceConfigPath));
|
||||
if (fs.existsSync(sourceEnvPath)) {
|
||||
const match = fs.readFileSync(sourceEnvPath, "utf8").match(/^\s*(?:export\s+)?PAPERCLIP_INSTANCE_ID\s*=\s*["']?([^\s"'#]+)["']?/m);
|
||||
if (match?.[1]) sourceInstanceId = match[1];
|
||||
}
|
||||
fs.rmSync(completePath, { force: true });
|
||||
fs.rmSync(pendingPath, { force: true });
|
||||
const at = new Date().toISOString();
|
||||
fs.writeFileSync(
|
||||
pendingPath,
|
||||
manifestPath,
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
state: "pending",
|
||||
sourceConfigPath: path.resolve(process.env.SOURCE_CONFIG_PATH),
|
||||
version: 2,
|
||||
source: {
|
||||
instanceId: sourceInstanceId,
|
||||
configPath: sourceConfigPath,
|
||||
},
|
||||
snapshotAt: null,
|
||||
seedMode: "minimal",
|
||||
createdAt: new Date().toISOString(),
|
||||
migrationRevision: null,
|
||||
targetInstanceId: process.env.TARGET_INSTANCE_ID,
|
||||
phase: "pending",
|
||||
state: "pending",
|
||||
attemptId: crypto.randomUUID(),
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
diagnostics: [{ phase: "pending", status: "succeeded", at }],
|
||||
}, null, 2)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
|
@ -551,8 +579,8 @@ else
|
|||
created_worktree_config=1
|
||||
fi
|
||||
|
||||
if [[ "$created_worktree_config" -eq 1 && ! -e "$seed_pending_marker_path" && ! -e "$seed_complete_marker_path" ]]; then
|
||||
write_seed_pending_marker
|
||||
if [[ "$created_worktree_config" -eq 1 && ! -e "$seed_manifest_path" && ! -e "$seed_pending_marker_path" && ! -e "$seed_complete_marker_path" ]]; then
|
||||
write_seed_pending_manifest
|
||||
fi
|
||||
|
||||
list_base_node_modules_paths() {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ const serializedServerVitestArgs = [
|
|||
"--no-file-parallelism",
|
||||
"--maxWorkers=1",
|
||||
];
|
||||
const sourceOnlyVitestArgs = ["--exclude", "**/dist/**"];
|
||||
|
||||
function walk(dir) {
|
||||
const entries = readdirSync(dir);
|
||||
|
|
@ -283,7 +284,7 @@ function runVitest(args, label) {
|
|||
};
|
||||
mkdirSync(env.PAPERCLIP_HOME, { recursive: true });
|
||||
mkdirSync(env.TMPDIR, { recursive: true });
|
||||
const result = spawnSync("pnpm", ["exec", "vitest", "run", ...args], {
|
||||
const result = spawnSync("pnpm", ["exec", "vitest", "run", ...sourceOnlyVitestArgs, ...args], {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config";
|
|||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
// Each server suite boots + tears down its own embedded Postgres in
|
||||
// beforeAll/afterAll. Under the loaded serial shard (maxWorkers=1) the
|
||||
// graceful shutdown can occasionally cross vitest's default 10s hookTimeout,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type {
|
|||
ExecutionWorkspaceStatus,
|
||||
ExecutionWorkspaceCloseReadiness,
|
||||
WorkspaceOverviewResponse,
|
||||
WorkspaceLoginHandoffTicketResponse,
|
||||
WorkspaceOperation,
|
||||
WorkspaceRuntimeControlTarget,
|
||||
} from "@paperclipai/shared";
|
||||
|
|
@ -114,6 +115,23 @@ export const executionWorkspacesApi = {
|
|||
`/execution-workspaces/${id}/runtime-commands/${action}`,
|
||||
sanitizeWorkspaceRuntimeControlTarget(target),
|
||||
),
|
||||
repair: (id: string) =>
|
||||
api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>(
|
||||
`/execution-workspaces/${id}/runtime-commands/repair`,
|
||||
{},
|
||||
),
|
||||
/**
|
||||
* Mint a single-use workspace login handoff (PAP-17572).
|
||||
*
|
||||
* The returned URL carries a short-lived ticket, so the caller must navigate to
|
||||
* it rather than store or share it. The server answers the navigation with an
|
||||
* HTTP redirect, which is what keeps the ticket out of browser history.
|
||||
*/
|
||||
requestLoginHandoff: (id: string, next?: string) =>
|
||||
api.post<WorkspaceLoginHandoffTicketResponse>(
|
||||
`/execution-workspaces/${id}/login-handoff`,
|
||||
next ? { next } : {},
|
||||
),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch<ExecutionWorkspace>(`/execution-workspaces/${id}`, data),
|
||||
/**
|
||||
* Reconcile a git-worktree branch divergence via the S4 (`PAP-1586`) op.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WorkspaceAccessCard } from "./WorkspaceAccessCard";
|
||||
import type { WorkspaceAccessState } from "../lib/workspace-access-state";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function act(callback: () => void) {
|
||||
flushSync(callback);
|
||||
}
|
||||
|
||||
function accessState(overrides: Partial<WorkspaceAccessState> = {}): WorkspaceAccessState {
|
||||
return {
|
||||
state: "ready",
|
||||
title: "Ready",
|
||||
description: "Opening the workspace signs you in to the cloned board without a password.",
|
||||
action: { kind: "open", label: "Open workspace" },
|
||||
handoffAvailable: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkspaceAccessCard", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function renderCard(props: Partial<Parameters<typeof WorkspaceAccessCard>[0]> = {}) {
|
||||
const handlers = {
|
||||
onOpen: vi.fn(),
|
||||
onStart: vi.fn(),
|
||||
onRepair: vi.fn(),
|
||||
onViewLogs: vi.fn(),
|
||||
};
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(<WorkspaceAccessCard access={accessState()} {...handlers} {...props} />);
|
||||
});
|
||||
const button = container.querySelector("button");
|
||||
return { ...handlers, root, button };
|
||||
}
|
||||
|
||||
function findButton(label: string) {
|
||||
return Array.from(container.querySelectorAll("button")).find(
|
||||
(candidate) => candidate.textContent?.trim() === label,
|
||||
);
|
||||
}
|
||||
|
||||
it("renders the ready state and opens without asking for a password", () => {
|
||||
const { onOpen, root } = renderCard();
|
||||
const card = container.querySelector("[data-testid='workspace-access-card']");
|
||||
expect(card?.getAttribute("data-state")).toBe("ready");
|
||||
expect(container.textContent).toContain("Ready");
|
||||
expect(container.textContent).toContain("single-use login handoff");
|
||||
|
||||
const button = findButton("Open workspace");
|
||||
expect(button).toBeDefined();
|
||||
act(() => button!.click());
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps a superseded repair failure visible without hiding the open action", () => {
|
||||
const { onOpen, onViewLogs, root } = renderCard({
|
||||
access: accessState({
|
||||
secondaryNotice: {
|
||||
title: "Repair failed",
|
||||
description: "The repair stopped during managed_restart. The pre-repair backup was kept.",
|
||||
action: { kind: "view_logs", label: "View repair log" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const openButton = findButton("Open workspace");
|
||||
const logButton = findButton("View repair log");
|
||||
expect(openButton).toBeDefined();
|
||||
expect(logButton).toBeDefined();
|
||||
expect(container.querySelector("[data-testid='workspace-access-secondary-notice']")?.textContent)
|
||||
.toContain("pre-repair backup was kept");
|
||||
|
||||
act(() => openButton!.click());
|
||||
act(() => logButton!.click());
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
expect(onViewLogs).toHaveBeenCalledTimes(1);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("labels the credential fallback accurately when no handoff is available", () => {
|
||||
const { root } = renderCard({
|
||||
access: accessState({
|
||||
handoffAvailable: false,
|
||||
description: "Opening the board will ask for the credentials captured in this snapshot.",
|
||||
}),
|
||||
});
|
||||
expect(container.textContent).toContain("snapshot-local credentials captured");
|
||||
expect(container.textContent).not.toContain("single-use login handoff");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("gives provisioning, degraded, and failed states their own safe action", () => {
|
||||
const cases = [
|
||||
{
|
||||
access: accessState({
|
||||
state: "provisioning" as const,
|
||||
title: "Provisioning database",
|
||||
action: { kind: "start" as const, label: "Start workspace" },
|
||||
}),
|
||||
label: "Start workspace",
|
||||
handlerKey: "onStart" as const,
|
||||
},
|
||||
{
|
||||
access: accessState({
|
||||
state: "degraded" as const,
|
||||
title: "Workspace is degraded",
|
||||
action: { kind: "repair" as const, label: "Repair workspace" },
|
||||
}),
|
||||
label: "Repair workspace",
|
||||
handlerKey: "onRepair" as const,
|
||||
},
|
||||
{
|
||||
access: accessState({
|
||||
state: "failed" as const,
|
||||
title: "Repair failed",
|
||||
action: { kind: "view_logs" as const, label: "View repair log" },
|
||||
}),
|
||||
label: "View repair log",
|
||||
handlerKey: "onViewLogs" as const,
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const rendered = renderCard({ access: testCase.access });
|
||||
expect(container.textContent).toContain(testCase.access.title);
|
||||
const button = findButton(testCase.label);
|
||||
expect(button).toBeDefined();
|
||||
expect(button!.disabled).toBe(false);
|
||||
act(() => button!.click());
|
||||
expect(rendered[testCase.handlerKey]).toHaveBeenCalledTimes(1);
|
||||
act(() => rendered.root.unmount());
|
||||
container.innerHTML = "";
|
||||
}
|
||||
});
|
||||
|
||||
it("offers nothing clickable while a repair is already running", () => {
|
||||
const { root } = renderCard({
|
||||
access: accessState({
|
||||
state: "repairing",
|
||||
title: "Repairing workspace database",
|
||||
action: { kind: "wait", label: "Repair in progress" },
|
||||
}),
|
||||
});
|
||||
expect(findButton("Repair in progress")?.disabled).toBe(true);
|
||||
expect(
|
||||
container.querySelector("[data-testid='workspace-access-badge']")?.textContent,
|
||||
).toContain("Repairing");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("always names the state and the cause instead of a bare failure", () => {
|
||||
const { root } = renderCard({
|
||||
access: accessState({
|
||||
state: "failed",
|
||||
title: "Database provisioning failed",
|
||||
description: "The clone failed during restore. Repairing replaces only the isolated database.",
|
||||
action: { kind: "repair", label: "Repair workspace" },
|
||||
}),
|
||||
errorMessage: "Failed to open the workspace.",
|
||||
});
|
||||
expect(container.textContent).toContain("Database provisioning failed");
|
||||
expect(container.textContent).toContain("failed during restore");
|
||||
expect(
|
||||
container.querySelector("[data-testid='workspace-access-error']")?.textContent,
|
||||
).toContain("Failed to open the workspace.");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("disables the action while the caller reports it busy", () => {
|
||||
const { root } = renderCard({ isBusy: true });
|
||||
expect(findButton("Open workspace")?.disabled).toBe(true);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
import { ExternalLink, Loader2, Play, ScrollText, Wrench } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { WorkspaceAccessState } from "../lib/workspace-access-state";
|
||||
|
||||
/**
|
||||
* Workspace access surface (PAP-17572).
|
||||
*
|
||||
* One card per workspace that names the state, the cause, and the single safe
|
||||
* next action. It replaces the previous behavior where a cloned workspace whose
|
||||
* database was missing still rendered a plain "Open" link and answered with
|
||||
* "invalid email or password".
|
||||
*/
|
||||
|
||||
/**
|
||||
* Badge tones come from the semantic status token layer, so a theme change moves
|
||||
* these states with every other status surface. `ready` reuses the "done" tone
|
||||
* and `degraded` the "todo" (amber) tone; the icon variants are the contrast-
|
||||
* corrected pair the token layer already tunes per mode.
|
||||
*/
|
||||
const STATE_BADGE_CLASSES: Record<WorkspaceAccessState["state"], string> = {
|
||||
provisioning: "border-border text-muted-foreground",
|
||||
validating: "border-border text-muted-foreground",
|
||||
ready: "border-(--status-task-done) text-(--status-task-icon-done)",
|
||||
degraded: "border-(--status-task-todo) text-(--status-task-icon-todo)",
|
||||
repairing: "border-border text-muted-foreground",
|
||||
failed: "border-destructive/50 text-destructive",
|
||||
};
|
||||
|
||||
const STATE_LABELS: Record<WorkspaceAccessState["state"], string> = {
|
||||
provisioning: "Provisioning",
|
||||
validating: "Validating clone",
|
||||
ready: "Ready",
|
||||
degraded: "Degraded",
|
||||
repairing: "Repairing",
|
||||
failed: "Failed",
|
||||
};
|
||||
|
||||
const ACTION_ICONS = {
|
||||
open: ExternalLink,
|
||||
start: Play,
|
||||
repair: Wrench,
|
||||
view_logs: ScrollText,
|
||||
wait: Loader2,
|
||||
} as const;
|
||||
|
||||
export function WorkspaceAccessCard({
|
||||
access,
|
||||
isBusy,
|
||||
onOpen,
|
||||
onStart,
|
||||
onRepair,
|
||||
onViewLogs,
|
||||
errorMessage,
|
||||
}: {
|
||||
access: WorkspaceAccessState;
|
||||
isBusy?: boolean;
|
||||
onOpen: () => void;
|
||||
onStart: () => void;
|
||||
onRepair: () => void;
|
||||
onViewLogs: () => void;
|
||||
errorMessage?: string | null;
|
||||
}) {
|
||||
const Icon = ACTION_ICONS[access.action.kind];
|
||||
const isWaiting = access.action.kind === "wait";
|
||||
const handlers: Record<WorkspaceAccessState["action"]["kind"], () => void> = {
|
||||
open: onOpen,
|
||||
start: onStart,
|
||||
repair: onRepair,
|
||||
view_logs: onViewLogs,
|
||||
wait: () => undefined,
|
||||
};
|
||||
const SecondaryNoticeIcon = access.secondaryNotice
|
||||
? ACTION_ICONS[access.secondaryNotice.action.kind]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card data-testid="workspace-access-card" data-state={access.state}>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CardTitle>{access.title}</CardTitle>
|
||||
<span
|
||||
data-testid="workspace-access-badge"
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border bg-background px-2.5 py-1 text-xs",
|
||||
STATE_BADGE_CLASSES[access.state],
|
||||
)}
|
||||
>
|
||||
{(access.state === "repairing" || access.state === "provisioning") && (
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
)}
|
||||
{STATE_LABELS[access.state]}
|
||||
</span>
|
||||
</div>
|
||||
<CardDescription>{access.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={access.action.kind === "open" ? "default" : "outline"}
|
||||
disabled={isWaiting || isBusy}
|
||||
onClick={handlers[access.action.kind]}
|
||||
>
|
||||
{isBusy && !isWaiting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Icon className={cn("mr-2 h-4 w-4", isWaiting && "animate-spin")} />
|
||||
)}
|
||||
{access.action.label}
|
||||
</Button>
|
||||
{access.state === "ready" && !access.handoffAvailable ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Signs in with the snapshot-local credentials captured when this clone was made.
|
||||
</span>
|
||||
) : null}
|
||||
{access.state === "ready" && access.handoffAvailable ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Uses a single-use login handoff — no password needed.
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{errorMessage ? (
|
||||
<p data-testid="workspace-access-error" className="text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
{access.secondaryNotice && SecondaryNoticeIcon ? (
|
||||
<div data-testid="workspace-access-secondary-notice" className="flex flex-col gap-1.5 text-sm">
|
||||
<p className="font-medium text-destructive">{access.secondaryNotice.title}</p>
|
||||
<p className="text-muted-foreground">{access.secondaryNotice.description}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto w-fit p-0"
|
||||
disabled={isBusy}
|
||||
onClick={handlers[access.secondaryNotice.action.kind]}
|
||||
>
|
||||
<SecondaryNoticeIcon className="mr-2 h-4 w-4" />
|
||||
{access.secondaryNotice.action.label}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,335 @@
|
|||
import type { WorkspaceOperation, WorkspaceRuntimeService } from "@paperclipai/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
describeWorkspaceReadinessCause,
|
||||
resolveWorkspaceAccessState,
|
||||
} from "./workspace-access-state";
|
||||
|
||||
function runtimeService(overrides: Partial<WorkspaceRuntimeService> = {}): WorkspaceRuntimeService {
|
||||
return {
|
||||
id: "runtime-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: null,
|
||||
executionWorkspaceId: "ews-1",
|
||||
issueId: null,
|
||||
scopeType: "execution_workspace",
|
||||
scopeId: "ews-1",
|
||||
serviceName: "paperclip-dev",
|
||||
status: "running",
|
||||
lifecycle: "shared",
|
||||
reuseKey: null,
|
||||
command: "pnpm dev:once",
|
||||
cwd: "/srv/worktree",
|
||||
port: 42013,
|
||||
url: "https://workspace.example.ts.net:42013/",
|
||||
provider: "local_process",
|
||||
providerRef: null,
|
||||
ownerAgentId: null,
|
||||
startedByRunId: null,
|
||||
lastUsedAt: new Date("2026-08-19T00:00:00.000Z"),
|
||||
startedAt: new Date("2026-08-19T00:00:00.000Z"),
|
||||
stoppedAt: null,
|
||||
stopPolicy: null,
|
||||
healthStatus: "healthy",
|
||||
createdAt: new Date("2026-08-19T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-08-19T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function operation(overrides: Partial<WorkspaceOperation> = {}): WorkspaceOperation {
|
||||
return {
|
||||
id: "op-1",
|
||||
companyId: "company-1",
|
||||
executionWorkspaceId: "ews-1",
|
||||
heartbeatRunId: null,
|
||||
issueId: null,
|
||||
phase: "workspace_runtime_provision",
|
||||
command: null,
|
||||
cwd: null,
|
||||
status: "succeeded",
|
||||
exitCode: 0,
|
||||
logStore: null,
|
||||
logRef: null,
|
||||
logBytes: null,
|
||||
logSha256: null,
|
||||
logCompressed: false,
|
||||
stdoutExcerpt: null,
|
||||
stderrExcerpt: null,
|
||||
metadata: null,
|
||||
startedAt: new Date("2026-08-19T00:00:00.000Z"),
|
||||
finishedAt: new Date("2026-08-19T00:01:00.000Z"),
|
||||
createdAt: new Date("2026-08-19T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-08-19T00:01:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveWorkspaceAccessState", () => {
|
||||
it("is ready with a password-independent open action when a healthy runtime publishes a URL", () => {
|
||||
const access = resolveWorkspaceAccessState({
|
||||
runtimeServices: [runtimeService()],
|
||||
operations: [operation()],
|
||||
});
|
||||
expect(access).toMatchObject({
|
||||
state: "ready",
|
||||
action: { kind: "open", label: "Open workspace" },
|
||||
handoffAvailable: true,
|
||||
});
|
||||
expect(access.description).toContain("without a password");
|
||||
});
|
||||
|
||||
it("shows a repair-in-progress state with the current phase and no action", () => {
|
||||
const access = resolveWorkspaceAccessState({
|
||||
runtimeServices: [runtimeService()],
|
||||
operations: [
|
||||
operation({ phase: "workspace_repair", status: "running", metadata: { repairPhase: "full_reseed" } }),
|
||||
],
|
||||
});
|
||||
expect(access.state).toBe("repairing");
|
||||
expect(access.description).toContain("full_reseed");
|
||||
expect(access.description).toContain("preserved");
|
||||
expect(access.action.kind).toBe("wait");
|
||||
});
|
||||
|
||||
it("shows a failed repair with the failing phase and points at the log", () => {
|
||||
const access = resolveWorkspaceAccessState({
|
||||
runtimeServices: [],
|
||||
operations: [
|
||||
operation({ phase: "workspace_repair", status: "failed", metadata: { repairPhase: "readiness_validation" } }),
|
||||
],
|
||||
});
|
||||
expect(access).toMatchObject({ state: "failed", action: { kind: "view_logs" } });
|
||||
expect(access.title).toBe("Repair failed");
|
||||
expect(access.description).toContain("readiness_validation");
|
||||
expect(access.description).toContain("backup");
|
||||
});
|
||||
|
||||
it("returns to ready after a failed repair when a newer healthy runtime is serving", () => {
|
||||
const access = resolveWorkspaceAccessState({
|
||||
runtimeServices: [runtimeService({ startedAt: new Date("2026-08-19T00:03:00.000Z") })],
|
||||
operations: [
|
||||
operation({
|
||||
phase: "workspace_repair",
|
||||
status: "failed",
|
||||
metadata: { repairPhase: "managed_restart" },
|
||||
finishedAt: new Date("2026-08-19T00:02:00.000Z"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(access).toMatchObject({
|
||||
state: "ready",
|
||||
action: { kind: "open", label: "Open workspace" },
|
||||
secondaryNotice: {
|
||||
title: "Repair failed",
|
||||
action: { kind: "view_logs", label: "View repair log" },
|
||||
},
|
||||
});
|
||||
expect(access.secondaryNotice?.description).toContain("managed_restart");
|
||||
expect(access.secondaryNotice?.description).toContain("backup");
|
||||
});
|
||||
|
||||
it("returns to ready when a serving runtime has subsequently reported ready", () => {
|
||||
const access = resolveWorkspaceAccessState({
|
||||
runtimeServices: [runtimeService({ startedAt: new Date("2026-08-19T00:01:00.000Z") })],
|
||||
operations: [
|
||||
operation({
|
||||
phase: "workspace_repair",
|
||||
status: "failed",
|
||||
metadata: { repairPhase: "managed_restart" },
|
||||
finishedAt: new Date("2026-08-19T00:02:00.000Z"),
|
||||
}),
|
||||
],
|
||||
handoffFailure: {
|
||||
reason: "workspace_not_ready",
|
||||
readiness: {
|
||||
state: "ready",
|
||||
databaseReady: true,
|
||||
cloneDataReady: true,
|
||||
authHandoffReady: true,
|
||||
authHandoffUserId: null,
|
||||
seedState: "verified",
|
||||
seedPhase: "complete",
|
||||
seedMode: "full",
|
||||
instanceId: "instance-a",
|
||||
executionWorkspaceId: "ews-1",
|
||||
companyId: "company-1",
|
||||
failurePhase: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(access).toMatchObject({ state: "ready", action: { kind: "open" } });
|
||||
expect(access.secondaryNotice?.action.kind).toBe("view_logs");
|
||||
});
|
||||
|
||||
it("keeps a newer failed repair primary even when an older runtime is healthy", () => {
|
||||
const access = resolveWorkspaceAccessState({
|
||||
runtimeServices: [runtimeService({ startedAt: new Date("2026-08-19T00:01:00.000Z") })],
|
||||
operations: [
|
||||
operation({
|
||||
phase: "workspace_repair",
|
||||
status: "failed",
|
||||
finishedAt: new Date("2026-08-19T00:02:00.000Z"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(access).toMatchObject({ state: "failed", action: { kind: "view_logs" } });
|
||||
expect(access.secondaryNotice).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows provisioning while the clone is restoring", () => {
|
||||
const access = resolveWorkspaceAccessState({
|
||||
runtimeServices: [],
|
||||
operations: [operation({ status: "running" })],
|
||||
});
|
||||
expect(access).toMatchObject({ state: "provisioning", action: { kind: "wait" } });
|
||||
expect(access.title).toBe("Provisioning database");
|
||||
});
|
||||
|
||||
it("offers a repair when provisioning failed, naming the seed phase", () => {
|
||||
const access = resolveWorkspaceAccessState({
|
||||
runtimeServices: [],
|
||||
operations: [operation({ status: "failed", metadata: { seedFailurePhase: "restore" } })],
|
||||
});
|
||||
expect(access).toMatchObject({ state: "failed", action: { kind: "repair", label: "Repair workspace" } });
|
||||
expect(access.description).toContain("restore");
|
||||
});
|
||||
|
||||
it("shows validating, not degraded, while a fresh clone is still being confirmed", () => {
|
||||
const access = resolveWorkspaceAccessState({
|
||||
runtimeServices: [runtimeService()],
|
||||
operations: [operation()],
|
||||
handoffFailure: {
|
||||
reason: "workspace_not_ready",
|
||||
detail: "clone_data_missing",
|
||||
readiness: {
|
||||
state: "validating",
|
||||
databaseReady: true,
|
||||
cloneDataReady: false,
|
||||
authHandoffReady: true,
|
||||
authHandoffUserId: null,
|
||||
seedState: "unknown",
|
||||
seedPhase: "legacy_complete_marker",
|
||||
seedMode: null,
|
||||
instanceId: "instance-a",
|
||||
executionWorkspaceId: "ews-1",
|
||||
companyId: "company-1",
|
||||
failurePhase: "clone_data_missing",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(access).toMatchObject({ state: "validating", action: { kind: "wait" } });
|
||||
expect(access.description).toContain("restored no company or issue rows");
|
||||
});
|
||||
|
||||
it("degrades a verified clone whose database regressed and offers one repair", () => {
|
||||
const access = resolveWorkspaceAccessState({
|
||||
runtimeServices: [runtimeService()],
|
||||
operations: [operation()],
|
||||
handoffFailure: {
|
||||
reason: "workspace_not_ready",
|
||||
detail: "database_unreachable",
|
||||
readiness: {
|
||||
state: "degraded",
|
||||
databaseReady: false,
|
||||
cloneDataReady: false,
|
||||
authHandoffReady: false,
|
||||
authHandoffUserId: null,
|
||||
seedState: "verified",
|
||||
seedPhase: "complete",
|
||||
seedMode: "minimal",
|
||||
instanceId: "instance-a",
|
||||
executionWorkspaceId: "ews-1",
|
||||
companyId: "company-1",
|
||||
failurePhase: "database_unreachable",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(access).toMatchObject({ state: "degraded", action: { kind: "repair" } });
|
||||
expect(access.description).toContain("isolated database is not answering");
|
||||
});
|
||||
|
||||
it("labels the credential path accurately when no handoff is configured", () => {
|
||||
const access = resolveWorkspaceAccessState({
|
||||
runtimeServices: [runtimeService()],
|
||||
operations: [operation()],
|
||||
handoffFailure: { reason: "handoff_not_configured" },
|
||||
});
|
||||
expect(access).toMatchObject({
|
||||
state: "ready",
|
||||
action: { kind: "open" },
|
||||
handoffAvailable: false,
|
||||
});
|
||||
expect(access.description).toContain("snapshot-local credentials");
|
||||
});
|
||||
|
||||
it("offers start when nothing is running", () => {
|
||||
expect(
|
||||
resolveWorkspaceAccessState({ runtimeServices: [], operations: [] }),
|
||||
).toMatchObject({ state: "provisioning", action: { kind: "start", label: "Start workspace" } });
|
||||
|
||||
expect(
|
||||
resolveWorkspaceAccessState({
|
||||
runtimeServices: [],
|
||||
operations: [],
|
||||
handoffFailure: { reason: "runtime_not_running" },
|
||||
}),
|
||||
).toMatchObject({ state: "provisioning", action: { kind: "start" } });
|
||||
});
|
||||
|
||||
it("refuses to call an unhealthy running runtime ready", () => {
|
||||
const access = resolveWorkspaceAccessState({
|
||||
runtimeServices: [runtimeService({ healthStatus: "unhealthy" })],
|
||||
operations: [operation()],
|
||||
});
|
||||
expect(access).toMatchObject({ state: "degraded", action: { kind: "repair" } });
|
||||
});
|
||||
|
||||
it("treats a running service with no URL as not yet serving", () => {
|
||||
const access = resolveWorkspaceAccessState({
|
||||
runtimeServices: [runtimeService({ url: null })],
|
||||
operations: [operation()],
|
||||
});
|
||||
expect(access.state).not.toBe("ready");
|
||||
});
|
||||
|
||||
it("handles missing operations and services without throwing", () => {
|
||||
expect(resolveWorkspaceAccessState({ runtimeServices: null, operations: undefined }).state)
|
||||
.toBe("provisioning");
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeWorkspaceReadinessCause", () => {
|
||||
it("prefers the recorded readiness phase over the generic reason", () => {
|
||||
expect(
|
||||
describeWorkspaceReadinessCause({
|
||||
reason: "workspace_not_ready",
|
||||
readiness: {
|
||||
state: "degraded",
|
||||
databaseReady: true,
|
||||
cloneDataReady: true,
|
||||
authHandoffReady: false,
|
||||
authHandoffUserId: null,
|
||||
seedState: "verified",
|
||||
seedPhase: "complete",
|
||||
seedMode: "minimal",
|
||||
instanceId: "i",
|
||||
executionWorkspaceId: "e",
|
||||
companyId: "company-1",
|
||||
failurePhase: "cloned_membership_missing",
|
||||
},
|
||||
}),
|
||||
).toBe("No cloned user has an active company membership.");
|
||||
});
|
||||
|
||||
it("falls back to the reason and finally to null", () => {
|
||||
expect(describeWorkspaceReadinessCause({ reason: "runtime_not_running" }))
|
||||
.toContain("No healthy runtime service");
|
||||
expect(describeWorkspaceReadinessCause({ reason: "something_new" })).toBeNull();
|
||||
expect(describeWorkspaceReadinessCause(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
import type {
|
||||
WorkspaceOperation,
|
||||
WorkspaceReadiness,
|
||||
WorkspaceReadinessState,
|
||||
WorkspaceRuntimeService,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* Derives the workspace access state the UI shows (PAP-17572).
|
||||
*
|
||||
* The board cannot read a cloned workspace's protected health directly, so state
|
||||
* comes from three server-side facts it *can* see: the live runtime rows, the
|
||||
* workspace operation log, and the readiness the control plane reported when it
|
||||
* last tried to mint a login handoff.
|
||||
*
|
||||
* Every state carries one concrete next action. The failure this replaces was a
|
||||
* generic "Load failed" (or worse, a green badge) that told an operator nothing
|
||||
* about whether to wait, start, repair, or read a log.
|
||||
*/
|
||||
|
||||
export type WorkspaceAccessActionKind =
|
||||
| "open"
|
||||
| "start"
|
||||
| "repair"
|
||||
| "view_logs"
|
||||
/** Nothing to do but wait for a running operation. */
|
||||
| "wait";
|
||||
|
||||
export type WorkspaceAccessAction = {
|
||||
kind: WorkspaceAccessActionKind;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type WorkspaceAccessNotice = {
|
||||
title: string;
|
||||
description: string;
|
||||
action: WorkspaceAccessAction;
|
||||
};
|
||||
|
||||
export type WorkspaceAccessState = {
|
||||
state: WorkspaceReadinessState;
|
||||
title: string;
|
||||
description: string;
|
||||
action: WorkspaceAccessAction;
|
||||
/** True when a password-independent handoff is the expected way in. */
|
||||
handoffAvailable: boolean;
|
||||
/** A non-blocking historical failure that is still useful to inspect. */
|
||||
secondaryNotice?: WorkspaceAccessNotice;
|
||||
};
|
||||
|
||||
/** What the control plane said the last time a handoff was requested. */
|
||||
export type WorkspaceLoginHandoffFailureInfo = {
|
||||
reason: string;
|
||||
detail?: string | null;
|
||||
readiness?: WorkspaceReadiness | null;
|
||||
};
|
||||
|
||||
function latestOperation(operations: WorkspaceOperation[], phase: WorkspaceOperation["phase"]) {
|
||||
return operations.find((operation) => operation.phase === phase) ?? null;
|
||||
}
|
||||
|
||||
function describeSeedPhase(readiness: WorkspaceReadiness | null | undefined): string | null {
|
||||
if (!readiness?.failurePhase && !readiness?.seedPhase) return null;
|
||||
return readiness.failurePhase ?? readiness.seedPhase ?? null;
|
||||
}
|
||||
|
||||
function timestampMs(value: Date | string | null | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const timestamp = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
function failedRepairNotice(repair: WorkspaceOperation): WorkspaceAccessNotice {
|
||||
const phase = typeof repair.metadata?.repairPhase === "string" ? repair.metadata.repairPhase : null;
|
||||
return {
|
||||
title: "Repair failed",
|
||||
description: phase
|
||||
? `The repair stopped during ${phase}. The pre-repair backup was kept.`
|
||||
: "The repair stopped before the workspace became usable. The pre-repair backup was kept.",
|
||||
action: { kind: "view_logs", label: "View repair log" },
|
||||
};
|
||||
}
|
||||
|
||||
const HANDOFF_REASON_COPY: Record<string, string> = {
|
||||
handoff_not_configured:
|
||||
"This instance has no workspace login handoff configured, so opening the board falls back to snapshot-local credentials.",
|
||||
no_board_identity:
|
||||
"Your session has no cloned user to sign in as, so opening the board falls back to snapshot-local credentials.",
|
||||
runtime_not_running: "No healthy runtime service is publishing a URL for this workspace yet.",
|
||||
runtime_url_unusable: "The runtime row is publishing a URL Paperclip cannot open.",
|
||||
workspace_not_ready: "The cloned database is not ready to accept a login yet.",
|
||||
};
|
||||
|
||||
const READINESS_FAILURE_COPY: Record<string, string> = {
|
||||
database_unreachable: "The isolated database is not answering.",
|
||||
clone_data_missing: "The clone restored no company or issue rows.",
|
||||
clone_data_unreadable: "The cloned product tables could not be read.",
|
||||
cloned_membership_missing: "No cloned user has an active company membership.",
|
||||
cloned_identity_unreadable: "The cloned identity tables could not be read.",
|
||||
auth_handoff_not_configured: "The workspace was started without a login handoff key.",
|
||||
seed_manifest_unreadable: "The seed manifest is unreadable, so the restore cannot be trusted.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Human cause for a readiness rejection, preferring the specific recorded phase
|
||||
* over a generic sentence so the copy names what to fix.
|
||||
*/
|
||||
export function describeWorkspaceReadinessCause(
|
||||
failure: WorkspaceLoginHandoffFailureInfo | null | undefined,
|
||||
): string | null {
|
||||
if (!failure) return null;
|
||||
const phase = describeSeedPhase(failure.readiness);
|
||||
if (phase && READINESS_FAILURE_COPY[phase]) return READINESS_FAILURE_COPY[phase];
|
||||
if (phase) return `Last recorded phase: ${phase}.`;
|
||||
if (failure.detail && READINESS_FAILURE_COPY[failure.detail]) return READINESS_FAILURE_COPY[failure.detail];
|
||||
return HANDOFF_REASON_COPY[failure.reason] ?? null;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceAccessState(input: {
|
||||
runtimeServices: WorkspaceRuntimeService[] | null | undefined;
|
||||
operations: WorkspaceOperation[] | null | undefined;
|
||||
handoffFailure?: WorkspaceLoginHandoffFailureInfo | null;
|
||||
}): WorkspaceAccessState {
|
||||
const operations = input.operations ?? [];
|
||||
const runtimeServices = input.runtimeServices ?? [];
|
||||
const repair = latestOperation(operations, "workspace_repair");
|
||||
const provision =
|
||||
latestOperation(operations, "workspace_runtime_provision")
|
||||
?? latestOperation(operations, "workspace_provision");
|
||||
const failure = input.handoffFailure ?? null;
|
||||
const cause = describeWorkspaceReadinessCause(failure);
|
||||
const handoffAvailable = failure?.reason !== "handoff_not_configured" && failure?.reason !== "no_board_identity";
|
||||
const servingService = runtimeServices.find(
|
||||
(service) => service.status === "running" && service.healthStatus === "healthy" && service.url,
|
||||
);
|
||||
const repairFinishedAt = timestampMs(repair?.finishedAt);
|
||||
const servingServiceStartedAt = timestampMs(servingService?.startedAt);
|
||||
const readinessConfirmsServing = Boolean(servingService && failure?.readiness?.state === "ready");
|
||||
const runtimeStartedAfterRepair = repairFinishedAt !== null
|
||||
&& servingServiceStartedAt !== null
|
||||
&& repairFinishedAt < servingServiceStartedAt;
|
||||
const repairFailureWasSuperseded = repair?.status === "failed" && Boolean(
|
||||
servingService
|
||||
&& (readinessConfirmsServing || runtimeStartedAfterRepair),
|
||||
);
|
||||
const secondaryNotice = repair?.status === "failed" && repairFailureWasSuperseded
|
||||
? failedRepairNotice(repair)
|
||||
: undefined;
|
||||
|
||||
// A live repair outranks everything: it is already changing the answer.
|
||||
if (repair?.status === "running") {
|
||||
const phase = typeof repair.metadata?.repairPhase === "string" ? repair.metadata.repairPhase : null;
|
||||
return {
|
||||
state: "repairing",
|
||||
title: "Repairing workspace database",
|
||||
description: phase
|
||||
? `Only the isolated database is replaced; the git worktree and your files are preserved. Current phase: ${phase}.`
|
||||
: "Only the isolated database is replaced; the git worktree and your files are preserved.",
|
||||
action: { kind: "wait", label: "Repair in progress" },
|
||||
handoffAvailable,
|
||||
};
|
||||
}
|
||||
if (repair?.status === "failed" && !repairFailureWasSuperseded) {
|
||||
const notice = failedRepairNotice(repair);
|
||||
return {
|
||||
state: "failed",
|
||||
...notice,
|
||||
handoffAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
if (provision?.status === "running") {
|
||||
return {
|
||||
state: "provisioning",
|
||||
title: "Provisioning database",
|
||||
description: "Restoring the isolated database clone for this workspace. This runs once before the first start.",
|
||||
action: { kind: "wait", label: "Provisioning" },
|
||||
handoffAvailable,
|
||||
};
|
||||
}
|
||||
if (provision?.status === "failed") {
|
||||
const seedPhase = typeof provision.metadata?.seedFailurePhase === "string"
|
||||
? provision.metadata.seedFailurePhase
|
||||
: null;
|
||||
return {
|
||||
state: "failed",
|
||||
title: "Database provisioning failed",
|
||||
description: seedPhase
|
||||
? `The clone failed during ${seedPhase}. Repairing replaces only the isolated database.`
|
||||
: "The clone did not finish, so this workspace has no usable database yet.",
|
||||
action: { kind: "repair", label: "Repair workspace" },
|
||||
handoffAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
// Readiness the control plane actually observed beats anything inferred from
|
||||
// runtime rows, because it is the only signal that looked inside the clone.
|
||||
const staleNotReadyFailure = failure?.reason === "workspace_not_ready" && readinessConfirmsServing;
|
||||
if (failure && !staleNotReadyFailure) {
|
||||
if (failure.reason === "runtime_not_running" && !servingService) {
|
||||
return {
|
||||
state: "provisioning",
|
||||
title: "Workspace is not running",
|
||||
description: "Start the workspace runtime to publish its board.",
|
||||
action: { kind: "start", label: "Start workspace" },
|
||||
handoffAvailable,
|
||||
};
|
||||
}
|
||||
if (failure.reason === "workspace_not_ready" || failure.reason === "runtime_url_unusable") {
|
||||
const readinessState = failure.readiness?.state;
|
||||
const validating = readinessState === "validating" || readinessState === "provisioning";
|
||||
return {
|
||||
state: validating ? "validating" : "degraded",
|
||||
title: validating ? "Validating clone" : "Workspace is degraded",
|
||||
description: [
|
||||
cause ?? "The workspace is serving, but its clone did not pass the readiness contract.",
|
||||
validating ? "Paperclip is still confirming the clone." : "One bounded repair replaces the isolated database.",
|
||||
].join(" "),
|
||||
action: validating
|
||||
? { kind: "wait", label: "Validating" }
|
||||
: { kind: "repair", label: "Repair workspace" },
|
||||
handoffAvailable,
|
||||
};
|
||||
}
|
||||
if (!handoffAvailable) {
|
||||
return {
|
||||
state: servingService ? "ready" : "degraded",
|
||||
title: servingService ? "Ready — snapshot-local sign-in" : "Workspace is degraded",
|
||||
description: cause ?? "Opening the board will ask for the credentials captured in this snapshot.",
|
||||
action: servingService
|
||||
? { kind: "open", label: "Open workspace" }
|
||||
: { kind: "start", label: "Start workspace" },
|
||||
handoffAvailable: false,
|
||||
secondaryNotice,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (servingService) {
|
||||
return {
|
||||
state: "ready",
|
||||
title: "Ready",
|
||||
description: "Opening the workspace signs you in to the cloned board without a password.",
|
||||
action: { kind: "open", label: "Open workspace" },
|
||||
handoffAvailable,
|
||||
secondaryNotice,
|
||||
};
|
||||
}
|
||||
|
||||
const unhealthyService = runtimeServices.find(
|
||||
(service) => service.status === "running" && service.healthStatus !== "healthy",
|
||||
);
|
||||
if (unhealthyService) {
|
||||
return {
|
||||
state: "degraded",
|
||||
title: "Workspace is degraded",
|
||||
description: cause
|
||||
?? "The runtime is up but did not report a usable database, so Paperclip will not publish it as ready.",
|
||||
action: { kind: "repair", label: "Repair workspace" },
|
||||
handoffAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: "provisioning",
|
||||
title: "Workspace is not running",
|
||||
description: "Start the workspace runtime to publish its board.",
|
||||
action: { kind: "start", label: "Start workspace" },
|
||||
handoffAvailable,
|
||||
};
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import { CopyText } from "../components/CopyText";
|
|||
import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog";
|
||||
import { MissingPluginTabPlaceholder } from "../components/MissingPluginTabPlaceholder";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { ApiError } from "../api/client";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { issuesApi } from "../api/issues";
|
||||
|
|
@ -35,12 +36,17 @@ import {
|
|||
type WorkspaceRuntimeControlRequest,
|
||||
} from "../components/WorkspaceRuntimeControls";
|
||||
import { WorkspaceServiceControlBar } from "../components/WorkspaceServiceControlBar";
|
||||
import { WorkspaceAccessCard } from "../components/WorkspaceAccessCard";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, formatDateTime, issueUrl, projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
|
||||
import {
|
||||
resolveWorkspaceAccessState,
|
||||
type WorkspaceLoginHandoffFailureInfo,
|
||||
} from "../lib/workspace-access-state";
|
||||
import {
|
||||
getWorkspaceSpecificRoutineVariableNames,
|
||||
routineHasWorkspaceSpecificVariables,
|
||||
|
|
@ -412,6 +418,27 @@ export function resolveRuntimeProvisionStatus(input: {
|
|||
return configured ? { kind: "deferred" } : { kind: "eager" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the structured refusal the login-handoff endpoint returns.
|
||||
*
|
||||
* The server keeps a machine `reason` (and, where it probed, the workspace's own
|
||||
* readiness) on the error body so the UI can name the cause instead of showing a
|
||||
* bare HTTP failure. Anything else is a genuine transport error.
|
||||
*/
|
||||
export function readWorkspaceHandoffFailure(error: unknown): WorkspaceLoginHandoffFailureInfo | null {
|
||||
if (!(error instanceof ApiError)) return null;
|
||||
const body = error.body as
|
||||
| { reason?: unknown; detail?: unknown; readiness?: unknown }
|
||||
| null
|
||||
| undefined;
|
||||
if (!body || typeof body.reason !== "string") return null;
|
||||
return {
|
||||
reason: body.reason,
|
||||
detail: typeof body.detail === "string" ? body.detail : null,
|
||||
readiness: (body.readiness as WorkspaceLoginHandoffFailureInfo["readiness"]) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-1.5 sm:flex-row sm:items-start sm:gap-3">
|
||||
|
|
@ -779,6 +806,8 @@ export function ExecutionWorkspaceDetail() {
|
|||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState<string | null>(null);
|
||||
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
|
||||
const [handoffFailure, setHandoffFailure] = useState<WorkspaceLoginHandoffFailureInfo | null>(null);
|
||||
const [handoffErrorMessage, setHandoffErrorMessage] = useState<string | null>(null);
|
||||
const [pendingRuntimeActions, setPendingRuntimeActions] = useState<WorkspaceRuntimeControlRequest[]>([]);
|
||||
const activeRouteTab = workspaceId ? resolveExecutionWorkspaceTab(location.pathname, workspaceId) : null;
|
||||
const pluginTabFromSearch = useMemo(() => {
|
||||
|
|
@ -968,6 +997,67 @@ export function ExecutionWorkspaceDetail() {
|
|||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Password-independent workspace entry (PAP-17572).
|
||||
*
|
||||
* The server answers with a ticket-bearing URL, and the workspace answers *that*
|
||||
* with a redirect — which is what keeps the ticket out of session history.
|
||||
*
|
||||
* The target tab is opened synchronously on click and only pointed at the URL
|
||||
* once the ticket arrives. Opening it after the request resolves would be a
|
||||
* popup the browser did not attribute to the click, and Safari and Firefox
|
||||
* block exactly that. If the tab could not be opened anyway, fall back to
|
||||
* navigating this one rather than silently doing nothing.
|
||||
*/
|
||||
const openWorkspace = useMutation({
|
||||
mutationFn: async () => {
|
||||
const target = window.open("about:blank", "_blank", "noopener,noreferrer");
|
||||
try {
|
||||
return { ticket: await executionWorkspacesApi.requestLoginHandoff(workspace!.id), target };
|
||||
} catch (error) {
|
||||
target?.close();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
onSuccess: ({ ticket, target }) => {
|
||||
setHandoffFailure(null);
|
||||
setHandoffErrorMessage(null);
|
||||
if (target && !target.closed) target.location.replace(ticket.url);
|
||||
else window.location.assign(ticket.url);
|
||||
},
|
||||
onError: async (error) => {
|
||||
// A structured refusal is rendered as workspace state by the access card,
|
||||
// so only an unrecognized transport error needs its own message line.
|
||||
const failure = readWorkspaceHandoffFailure(error);
|
||||
setHandoffFailure(failure);
|
||||
setHandoffErrorMessage(
|
||||
failure ? null : error instanceof Error ? error.message : "Failed to open the workspace.",
|
||||
);
|
||||
// The refusal reason often comes from an operation that has since advanced,
|
||||
// so refresh the log the access card derives its state from.
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.executionWorkspaces.workspaceOperations(workspace!.id),
|
||||
});
|
||||
},
|
||||
});
|
||||
const repairWorkspace = useMutation({
|
||||
mutationFn: () => executionWorkspacesApi.repair(workspace!.id),
|
||||
onSuccess: (result) => {
|
||||
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(result.workspace.id), result.workspace);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.executionWorkspaces.workspaceOperations(result.workspace.id),
|
||||
});
|
||||
setHandoffFailure(null);
|
||||
setHandoffErrorMessage(null);
|
||||
setRuntimeActionErrorMessage(null);
|
||||
setRuntimeActionMessage("Workspace database repaired.");
|
||||
},
|
||||
onError: (error) => {
|
||||
setRuntimeActionMessage(null);
|
||||
setRuntimeActionErrorMessage(error instanceof Error ? error.message : "Failed to repair the workspace.");
|
||||
},
|
||||
});
|
||||
|
||||
if (workspaceQuery.isLoading) return <p className="text-sm text-muted-foreground">Loading workspace…</p>;
|
||||
if (workspaceQuery.error) {
|
||||
return (
|
||||
|
|
@ -992,6 +1082,11 @@ export function ExecutionWorkspaceDetail() {
|
|||
runtimeServices: workspace.runtimeServices ?? [],
|
||||
pendingRequests: pendingRuntimeActions,
|
||||
});
|
||||
const workspaceAccess = resolveWorkspaceAccessState({
|
||||
runtimeServices: workspace.runtimeServices ?? [],
|
||||
operations: workspaceOperationsQuery.data,
|
||||
handoffFailure,
|
||||
});
|
||||
|
||||
const pluginSlotContext = {
|
||||
companyId: workspace.companyId,
|
||||
|
|
@ -1062,6 +1157,20 @@ export function ExecutionWorkspaceDetail() {
|
|||
{runtimeActionErrorMessage ? <p className="text-sm text-destructive">{runtimeActionErrorMessage}</p> : null}
|
||||
{!runtimeActionErrorMessage && runtimeActionMessage ? <p className="text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
|
||||
|
||||
<WorkspaceAccessCard
|
||||
access={workspaceAccess}
|
||||
isBusy={openWorkspace.isPending || repairWorkspace.isPending}
|
||||
onOpen={() => openWorkspace.mutate()}
|
||||
onStart={() => {
|
||||
runRuntimeControlRequests(
|
||||
resolveWorkspaceServiceControlRequests(runtimeControlSections, "start", null),
|
||||
);
|
||||
}}
|
||||
onRepair={() => repairWorkspace.mutate()}
|
||||
onViewLogs={() => handleTabChange("runtime_logs")}
|
||||
errorMessage={handoffErrorMessage}
|
||||
/>
|
||||
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["toolbarButton", "contextMenuItem"]}
|
||||
entityType="execution_workspace"
|
||||
|
|
|
|||
Loading…
Reference in New Issue