mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## 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>
297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
/**
|
|
* 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),
|
|
};
|
|
}
|