feat(workspaces): sign the workspace login handoff and gate readiness (#11671)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Managed worktree services run isolated Paperclip instances with
cloned databases.
> - A reachable service was reported as ready even when its database,
runtime identity, or login path was not usable.
> - The first candidate added verified database seeding and managed
repair in #11665.
> - This pull request consolidates that candidate with signed login
handoff and a complete readiness contract.
> - Post-QA fixes close five defects in repair identity, repair
responses, UI retry, seed journal handling, and seed-source trust.
> - The benefit is a workspace that either opens safely or reports one
accurate recovery action.

## Linked Issues or Issue Description

No public GitHub issue exists for this work, so the problem is described
here.

**What happened**

Managed workspace URLs could return HTTP 200 and report ready while
login failed. QA also found cases where repair used the wrong instance
identity, returned a generic error, left the UI stuck, rejected a safe
journal lag, or trusted a mutable workspace manifest.

**Expected behavior**

Opening a ready workspace signs the board user in to the correct
isolated instance. Provisioning and repair use a registered source and
report a structured recovery state.

**Actual behavior**

Entry depended on a password copied into the clone. Several failure
paths could publish stale readiness, hide the repair precondition, or
trust state that the workspace could modify.

**Additional context**

This pull request includes the commits first published in #11665. That
pull request keeps the original base head for review history. This
consolidated pull request is the merge candidate. Related open readiness
work includes #11575 and #11621.

## What Changed

- Adds a short-lived, signed, single-use login ticket. It binds the
user, workspace, instance, and runtime origin.
- Exchanges the ticket through Better Auth. It creates the session and
cookie through the supported adapter path.
- Adds protected workspace readiness fields for the database, clone
data, login handoff, seed phase, and runtime identity.
- Fails readiness closed when the guest has no company or
execution-workspace binding.
- Binds ticket issuance to the exact cloned user and active company
membership selected for the handoff.
- Verifies every current active board identity through the exact-user
handoff before publication or reuse.
- Gates managed runtime publication on the readiness contract and the
recorded worktree instance identity.
- Refreshes runtime work products from the live runtime row after a port
change.
- Adds one workspace access card with ready, degraded, repairing, and
failed states.
- Uses the runtime response identity for repair. It returns structured
repair precondition errors.
- Lets a valid source journal lag converge during provisioning.
- Binds seed and repair manifests to a source registered outside the
agent-writable worktree.
- Clears recovered UI errors so a successful retry can open the
workspace.
- Makes runtime tests register canonical sources and avoid ports owned
by live host listeners.
- Keeps Vitest on source suites when compiled `dist` trees exist.
- Isolates CLI and adapter tests from ambient AWS and runtime API
environment variables.
- Preserves a 404 response for cross-company workspace ID lookups before
runtime authorization.
- Makes concurrent single-flight coverage independent of
path-canonicalization scheduling order.

## Verification

The following checks passed on the integrated head:

```sh
pnpm -r typecheck
pnpm build
pnpm check:token-gates
pnpm --filter @paperclipai/db check:migrations
```

- The server source lane passed 420 files and 4,953 tests. Five tests
were skipped.
- The CLI lane passed 57 files and 385 tests.
- The database lane passed 26 files and 97 tests.
- The shared package passed 58 files and 506 tests.
- The adapter utility lane passed 640 tests. Four tests were skipped.
- The Claude adapter passed 220 tests. One test was skipped.
- The Codex adapter passed 323 tests.
- The OpenClaw adapter passed 13 tests.
- The OpenCode adapter passed 42 tests.
- The plugin SDK passed 45 tests.
- The workspace runtime suite passed 124 tests.
- The caller-scoped readiness and handoff suite passed 52 tests.
- The workspace provisioning shell suite passed 7 tests.
- The runtime exposure suite passed 17 tests while live host mappings
occupied fixed test ports.
- `git diff --check` passed and the worktree is clean.

The serialized route lane will run in GitHub CI with its normal shards.
No deployment or active-workspace migration was performed.

## Risks

- This is a medium-risk authentication and runtime-readiness change.
- The login ticket uses exact origin, workspace, instance, and user
binding. It has a short expiry and a one-time nonce.
- Runtime publication is stricter. A real readiness, identity, per-user
handoff, or control-plane database disagreement now blocks publication.
- This pull request supersedes #11665 as the merge candidate. Close
#11665 after this pull request merges.
- No new database migration is included. The lockfile and workflow files
are unchanged.
- Deployment and active-workspace migration are intentionally outside
this pull request.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

Claude Opus 5 (`claude-opus-5[1m]`), 1M context, extended thinking, tool
use, and code execution produced the main candidate. OpenAI GPT-5
(`gpt-5`) through Codex, with agentic reasoning, tool use, and code
execution, integrated the post-QA fixes and hardened the test gates. The
Codex context-window size was not exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-08-19 02:37:02 -05:00
1 parent 6aaef2998f
commit a2bf936f9a
59 files changed
+7754 -243

No files matched your search

+3
View File
@@ -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(() => {
+357 -45
View File
@@ -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;