diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 793568d0b5..346640dd08 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -13,6 +13,11 @@ on: # the new tag ref instead. The tag mapping below keys off github.ref either # way. workflow_dispatch: + inputs: + preview_migrator: + description: Publish a commit-specific migrator prerelease for an explicitly pinned staging stack (no npm or fleet promotion) + type: boolean + default: false permissions: contents: read @@ -26,6 +31,49 @@ concurrency: cancel-in-progress: false jobs: + preview-migrator: + if: github.event_name == 'workflow_dispatch' && inputs.preview_migrator + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + steps: + - name: Require a non-default branch + env: + REF: ${{ github.ref }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + [[ "$REF" == refs/heads/* && "$REF" != "refs/heads/$DEFAULT_BRANCH" ]] + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + with: + version: 9.15.4 + - uses: actions/setup-node@v7 + with: + node-version: 24 + - run: pnpm install --frozen-lockfile + - name: Build matching migrator artifacts + run: node scripts/build-preview-migrator.mjs "$RUNNER_TEMP/preview-migrator" + - name: Publish immutable candidate assets without promotion + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="preview/$GITHUB_SHA" + # Never overwrite an earlier candidate, including a partially + # published draft. A failed upload stays inspectable for recovery. + if gh release view "$tag" >/dev/null 2>&1; then + echo "Preview release already exists; refusing to replace its assets" >&2 + exit 1 + fi + gh release create "$tag" --target "$GITHUB_SHA" --draft --prerelease --latest=false \ + --title "Staging preview $GITHUB_SHA" \ + --notes "Commit-specific Cloud migrator. No npm publish or fleet default promotion. Deploy only to explicitly selected pinned stacks." + gh release upload "$tag" "$RUNNER_TEMP/preview-migrator/preview-migrator.json" \ + "$RUNNER_TEMP/preview-migrator/paperclipai-db.tgz" "$RUNNER_TEMP/preview-migrator/paperclipai-shared.tgz" + gh release edit "$tag" --draft=false --prerelease --latest=false + build-and-push: runs-on: ubuntu-latest timeout-minutes: 60 diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 2ad1963b6f..df5940f1f8 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -466,6 +466,12 @@ that file, not as the main completion path for deliverables. ## Default Agent Workspaces +Sandbox execution uses the scoped `$HOME/task`, `agent`, `user`, `project`, and +`repos` directories, with object-storage checkpoints every 180 seconds and at +run completion. See [Sandbox work folders](sandbox-work-folders.md) for the +ownership, recovery, API, and dedicated staging acceptance contract. The local +execution paths below remain unchanged. + When a local agent run has no resolved project/session workspace, Paperclip falls back to an agent home workspace under the instance root: - `~/.paperclip/instances/default/workspaces/` diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 542dad3b99..3000f4bba5 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -109,6 +109,12 @@ in `packages/shared/src/constants.ts`. - local default: `~/.paperclip/instances/default/data/storage` (`local_disk`) - cloud: S3-compatible object storage (`s3`) +Sandbox work folders use these same object-storage providers, with company/owner +bindings, current file references, recoverable deletions, and checkpoint state +in PostgreSQL. Legacy and native sandbox execution share the `$HOME` layout and +180-second/final-flush lifecycle described in [Sandbox work folders](sandbox-work-folders.md). +Local execution retains its existing home and workspace behavior. + ## 6.3 Background Processing A lightweight scheduler/worker in the server process handles: diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md new file mode 100644 index 0000000000..b79240193f --- /dev/null +++ b/doc/sandbox-work-folders.md @@ -0,0 +1,140 @@ +# Sandbox work folders + +The deployed acceptance entry point is `pnpm test:e2e:work-folders:deployed`. +Set `PAPERCLIP_DEPLOYED_STACK_MANIFEST` to a JSON manifest matching +`tests/runner-e2e/deployed-stack.ts`, `PAPERCLIP_DEPLOYED_STACK_AUTH` to a private +0600 JSON file containing `baseURL` and a normally authorized `boardApiToken`, +and `PAPERCLIP_DEPLOYED_STACK_EVIDENCE` to an absolute output directory. +The harness never launches a local server. It checks the deployed commit and +adapter inventory, exercises scoped file APIs, and starts real sandbox tasks +for every configured profile. Missing profiles fail the inventory gate. +Credentials are not recorded in Playwright reports. These API checks supplement +the required browser walkthrough, two real 180-second intervals, and recovery +scenarios; passing them alone is not staging acceptance. + +Sandbox runs use the operating-system user's home directory. Both legacy +adapters and the native runner enter the same host-owned lifecycle before +dispatch. Local execution keeps its existing workspace and home behavior. + +```text +$HOME/ + task/ current issue's working files + agent/ current agent's durable files + user/ responsible user's private Paperclip files + project/ current project's shared files + repos/ task-specific project repository checkouts + .codex/ CLI configuration and provider session state + .cache/ disposable caches +``` + +An absent task, user, or project produces an empty, unbound directory. The +`user/` collection never copies a person's computer home directory. Existing +managed agent workspace files are imported once, excluding CLI homes, caches, +Git metadata, and conventional credential directories. Task attachments become +editable working copies whose filenames include attachment IDs; original +uploads remain unchanged. Task plans and documents are not materialized. + +The agent starts in `$HOME`. `PAPERCLIP_PRIMARY_REPO` and the workspace context +identify the repository for project commands. `AGENT_HOME` and +`PAPERCLIP_{TASK,AGENT,USER,PROJECT,REPOS}_DIR` expose the bound directories. +CLI state is separate from the four shared collections. A change of task, +agent, responsible user, or project cannot reuse a sandbox with another binding. + +## Storage and synchronization + +Postgres stores company/owner bindings, paths, executable bits, current object +references, trash entries, retry receipts, and each sandbox's sync baseline. +Contents use the configured `StorageProvider`: S3 or self-hosted `local_disk`. +The sandbox disk is a working copy, not the durability authority. With local +disk object storage, operators must persist and back up that storage directory +alongside Postgres. S3 recovery needs the database and bucket; it does not need +the original sandbox or application workspace volume. + +Startup hydrates only the four bound collections. A warm startup first saves +uncheckpointed local changes and then downloads changed incoming files. There +is no background incoming refresh while an agent edits. Explicit refresh is +queued until the run stops, after a successful final flush. + +Outgoing checkpoints run every **180 seconds**, with at most one in flight, +and a final flush when execution stops. File signatures include content and +executable state. Unchanged stale working copies do not overwrite newer shared +files. Changed files use server-accepted last-write-wins. Operation IDs persist +before transfer so a lost response retries the same operation rather than +overwriting a later writer. A failed save remains visible and prevents lease +cleanup from destroying the working copy. Providers with resume support can +recover a retained lease on the next run with the same identity/configuration, +including a lease originally configured as ephemeral. + +Deletion moves files to recoverable trash. Restore rejects path collisions. +Explicit purge and permanent owner deletion schedule object cleanup through a +durable deletion journal. Overwritten scoped-file content is not versioned. +Repository checkpoint objects remain retained while their task binding exists. +The scheduler retries object cleanup every three minutes; disabled heartbeat +scheduling also disables this cleanup sweep. + +Every file API checks company and owner authorization. User collections are +available only to the current user and their bound, authorized agent run. +Generic company access does not grant user-file access. Background transfers +also recheck the responsible user's active membership before saving. + +Paths reject traversal, control characters and reserved runtime segments. +Scoped files cannot be symbolic links or hard links. Linux transport pins +parent directory descriptors and uses no-follow opens. Repository symlinks +must stay inside their checkout, outside `.git`. Transfers stream in bounded +chunks; individual files are limited to 1 GiB, scans to 100,000 entries, and UI +previews to 8 MiB. + +## Repositories + +Each task owns independent clones of all project workspaces with a repository +URL. Names derive from repository names, with stable workspace-ID suffixes on +collisions. Initial clones use existing Git credentials and starting-ref policy; +the primary clone also honors the task's configured branch. Warm starts never +reset branches, clean edits, or rerun completed setup. Added repositories are +prepared at the next startup; removed bindings retain saved work. + +A complete repository checkpoint includes Git objects, refs, HEAD and index, +tracked working files, and nonignored untracked files. It excludes dependencies +and generated ignored caches, Git credentials/configuration, hooks, and private +runtime state. Checkpoints reject in-progress Git locks and a tree that changes +during scanning. The database pointer advances only after all required objects +and the manifest have been saved. Restores verify ownership and hashes, then +publish the restored directory atomically. Git origin configuration is recreated +from the host binding. Linked worktrees and submodules using external `.git` +directories are not supported by this checkpoint format. + +## API and UI + +The task, agent, project, and current-user pages expose a Files dialog using the +shared file tree and viewer. It supports uploads, folder creation, previews, +downloads, deletion, trash restore/purge, and sync state with the last save time. + +All routes start at +`/api/companies/:companyId/work-folders/:scope/:ownerId`: + +- `GET /`: paginated active files or trash (`trash=true`). +- `GET /content?path=...`: confined download/preview stream. +- `PUT /content?path=...`: raw `application/octet-stream` upload; supports + `Idempotency-Key`, `X-File-Content-Type`, and `X-File-Executable`. +- `POST /operations`: idempotent mkdir, delete, restore, or purge. +- `GET /sync`: save state, last successful save, errors, and refresh state. +- `POST /refresh`: request refresh at the active run's safe boundary. + +## Acceptance gate + +Automated tests do not qualify a deployed runner image. Before merging, use a +new pinned staging stack with the branch's Cloud image and matching migrator. +The deployed harness must target that tenant URL without launching a local +server. Enumerate every sandbox-capable adapter/engine and native profile +exposed by the stack; missing credentials or skipped required profiles block +acceptance. Record real browser operations, two actual 180-second intervals, +short-run flushes, independent task checkouts, identity/privacy boundaries, +interrupted saves, and recovery without the original sandbox or app volume. + +`Docker` workflow's optional `preview_migrator` input builds a commit-specific +GitHub prerelease containing DB/shared tarballs and an integrity manifest. It +does not publish to npm or advance release channels. Cloud resolves +`preview:`, verifies artifact identity, migration coverage and the +dependency lockfile, and permits deployment only to explicitly selected pinned +stacks. Preview artifacts cannot become the fleet default. A failed or existing +preview release is never silently overwritten. diff --git a/package.json b/package.json index 309517bd71..f28e77a5bd 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "test:e2e:runner:history:publish": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/history-publish.ts", "test:e2e:runner:unit": "vitest run --config tests/runner-e2e/vitest.config.ts", "test:e2e:runner:typecheck": "tsc -p tests/runner-e2e/tsconfig.json", + "test:e2e:work-folders:deployed": "playwright test --config tests/runner-e2e/playwright.deployed.config.ts", "test:e2e:runner:report": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/report.ts", "test:runner-workflow-evals": "pnpm --filter @paperclipai/paperclip-eval-kernel build && pnpm --filter @paperclipai/paperclip-runner test:runner-workflow-evals", "test:e2e:mcp-user-stories": "node scripts/e2e-mcp-user-stories.mjs", diff --git a/packages/paperclip-runner/src/work-folder-environment.test.ts b/packages/paperclip-runner/src/work-folder-environment.test.ts index c7b96cb720..a0380f2002 100644 --- a/packages/paperclip-runner/src/work-folder-environment.test.ts +++ b/packages/paperclip-runner/src/work-folder-environment.test.ts @@ -15,5 +15,6 @@ describe("external sandbox work-folder environment", () => { it("leaves local execution unchanged and rejects inconsistent sandbox bindings", () => { expect(externalWorkFolderEnvironment({ ...environment, PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: undefined })).toEqual({}); expect(() => externalWorkFolderEnvironment({ ...environment, PAPERCLIP_USER_DIR: "/other/user" })).toThrow("does not match"); + expect(() => externalWorkFolderEnvironment({ ...environment, PAPERCLIP_PRIMARY_REPO: `${home}/repos/../../.codex` })).toThrow("Invalid sandbox primary"); }); }); diff --git a/packages/paperclip-runner/src/work-folder-environment.ts b/packages/paperclip-runner/src/work-folder-environment.ts index 0ca161722c..e4451669e3 100644 --- a/packages/paperclip-runner/src/work-folder-environment.ts +++ b/packages/paperclip-runner/src/work-folder-environment.ts @@ -14,6 +14,11 @@ export function externalWorkFolderEnvironment(source: NodeJS.ProcessEnv): NodeJS } result.AGENT_HOME = path.join(home, "agent"); const primary = source.PAPERCLIP_PRIMARY_REPO; - if (primary && (primary.startsWith(`${home}/repos/`) || primary === `${home}/task`)) result.PAPERCLIP_PRIMARY_REPO = primary; + if (primary) { + if (path.resolve(primary) !== primary || !(primary.startsWith(`${home}/repos/`) || primary === `${home}/task`)) { + throw new Error("Invalid sandbox primary repository path"); + } + result.PAPERCLIP_PRIMARY_REPO = primary; + } return result; } diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index afb988d64e..3a33b12c11 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -2228,6 +2228,7 @@ const plugin = definePlugin({ metadata: { expired: true, workspaceSentinel }, }; } + if (config.autoDeleteInterval === -1) await sandbox.setAutoDeleteInterval(-1); const shellCommand = await detectSandboxShellCommand( sandbox, toTimeoutSeconds(config.timeoutMs), diff --git a/scripts/build-preview-migrator.mjs b/scripts/build-preview-migrator.mjs new file mode 100644 index 0000000000..c8eae1a0b9 --- /dev/null +++ b/scripts/build-preview-migrator.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { materializePublishManifest, prepareBundledPackage } from "./prepare-bundled-package.mjs"; + +export function previewIdentity(sha, date, repository = "paperclipai/paperclip") { + if (!/^[a-f0-9]{40}$/.test(sha) || Number.isNaN(date.getTime())) throw new Error("Invalid preview commit"); + const day = `${date.getUTCMonth() + 1}${String(date.getUTCDate()).padStart(2, "0")}`; + const second = date.getUTCHours() * 3600 + date.getUTCMinutes() * 60 + date.getUTCSeconds() + 1; + return { tag: `preview/${sha}`, version: `${date.getUTCFullYear()}.${day}.${second}-preview.sha${sha}`, + baseUrl: `https://github.com/${repository}/releases/download/${encodeURIComponent(`preview/${sha}`)}` }; +} + +export function buildPreviewMigrator(outputDirectory) { + const repo = path.resolve(fileURLToPath(new URL("..", import.meta.url))); + const git = (...args) => execFileSync("git", args, { cwd: repo, encoding: "utf8" }).trim(); + const sha = git("rev-parse", "HEAD"); + if (process.env.GITHUB_SHA && process.env.GITHUB_SHA !== sha) throw new Error("Preview checkout differs from the workflow commit"); + git("diff", "--quiet", "HEAD"); + const identity = previewIdentity(sha, new Date(git("show", "-s", "--format=%cI", "HEAD"))); + execFileSync("pnpm", ["--filter", "@paperclipai/db...", "build"], { cwd: repo, stdio: "inherit" }); + const output = path.resolve(outputDirectory); + mkdirSync(output, { recursive: true }); + const temporary = mkdtempSync(path.join(os.tmpdir(), "paperclip-preview-migrator-")); + try { + for (const name of ["shared", "db"]) { + const source = path.join(repo, "packages", name); + const staged = path.join(temporary, name); + if (name === "db") prepareBundledPackage(source, staged); + else { mkdirSync(staged); cpSync(path.join(source, "dist"), path.join(staged, "dist"), { recursive: true }); } + const manifest = name === "db" ? JSON.parse(readFileSync(path.join(staged, "package.json"), "utf8")) + : materializePublishManifest(JSON.parse(readFileSync(path.join(source, "package.json"), "utf8"))); + manifest.version = identity.version; + manifest.gitHead = sha; + delete manifest.devDependencies; + delete manifest.scripts; + if (name === "db") manifest.dependencies["@paperclipai/shared"] = `${identity.baseUrl}/paperclipai-shared.tgz`; + writeFileSync(path.join(staged, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`); + for (const file of ["LICENSE", "README.md"]) { + if (existsSync(path.join(source, file))) cpSync(path.join(source, file), path.join(staged, file)); + } + const result = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temporary], { cwd: staged, encoding: "utf8" })); + const destination = path.join(output, `paperclipai-${name}.tgz`); + if (existsSync(destination)) throw new Error("Refusing to overwrite a preview artifact"); + renameSync(path.join(temporary, result[0].filename), destination); + } + const integrity = (name) => `sha512-${createHash("sha512").update(readFileSync(path.join(output, `paperclipai-${name}.tgz`))).digest("base64")}`; + const manifest = { version: 1, githubSha: sha, dbPackageVersion: identity.version, + dbPackageIntegrity: integrity("db"), dbPackageTarballUrl: `${identity.baseUrl}/paperclipai-db.tgz`, + sharedPackageIntegrity: integrity("shared"), sharedPackageTarballUrl: `${identity.baseUrl}/paperclipai-shared.tgz` }; + writeFileSync(path.join(output, "preview-migrator.json"), `${JSON.stringify(manifest, null, 2)}\n`, { flag: "wx" }); + return manifest; + } finally { rmSync(temporary, { recursive: true, force: true }); } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + if (!process.argv[2]) throw new Error("Usage: build-preview-migrator.mjs "); + console.log(JSON.stringify(buildPreviewMigrator(process.argv[2]))); +} diff --git a/scripts/build-preview-migrator.test.mjs b/scripts/build-preview-migrator.test.mjs new file mode 100644 index 0000000000..d779e89bfe --- /dev/null +++ b/scripts/build-preview-migrator.test.mjs @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { previewIdentity } from "./build-preview-migrator.mjs"; + +test("preview artifact identity is immutable, namespaced, and ordered by commit time", () => { + const sha = "2f42a4968d5761fd62172e35ecf8188195b8d431"; + const identity = previewIdentity(sha, new Date("2026-07-19T09:30:00.000Z")); + assert.equal(identity.version, `2026.719.34201-preview.sha${sha}`); + assert.equal(identity.tag, `preview/${sha}`); + assert.equal(identity.baseUrl, `https://github.com/paperclipai/paperclip/releases/download/preview%2F${sha}`); + assert.throws(() => previewIdentity("master", new Date()), /Invalid preview/); + assert.throws(() => previewIdentity(sha, new Date("invalid")), /Invalid preview/); +}); diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index 8caf881dea..a2a7a1059a 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readdirSync, statSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readdirSync, realpathSync, statSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -275,7 +275,9 @@ function runVitest(args, label) { console.log(`\n[test:run] ${label}`); invocationIndex += 1; const tempRootParent = process.platform === "win32" ? os.tmpdir() : "/tmp"; - const testRoot = mkdtempSync(path.join(tempRootParent, `pcvt-${process.pid}-${invocationIndex}-`)); + // /tmp is a symlink on macOS. Workspace confinement intentionally rejects + // aliases, so fixtures must receive the canonical root just as Linux does. + const testRoot = realpathSync(mkdtempSync(path.join(tempRootParent, `pcvt-${process.pid}-${invocationIndex}-`))); // Keep per-run paths compact so Unix socket fixtures stay under macOS path limits. const env = { ...process.env, diff --git a/server/src/__tests__/cli-auth-routes.test.ts b/server/src/__tests__/cli-auth-routes.test.ts index 391510856b..0a9e057603 100644 --- a/server/src/__tests__/cli-auth-routes.test.ts +++ b/server/src/__tests__/cli-auth-routes.test.ts @@ -101,6 +101,17 @@ describe.sequential("cli auth routes", () => { vi.resetAllMocks(); }); + it.each(["session", "cloud_tenant"])("recognizes an authenticated %s user for board CLI approval", async (source) => { + mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ id: "challenge-1", requestedAccess: "board", status: "pending" }); + const app = await createApp({ type: "board", source, userId: "user-1", companyIds: ["company-1"], isInstanceAdmin: false }); + const result = await request(app).get("/api/cli-auth/challenges/challenge-1?token=pcp_cli_auth_secret"); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ requiresSignIn: false, canApprove: true, currentUserId: "user-1" }); + mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ id: "challenge-1", requestedAccess: "instance_admin_required", status: "pending" }); + const admin = await request(app).get("/api/cli-auth/challenges/challenge-1?token=pcp_cli_auth_secret"); + expect(admin.body.canApprove).toBe(false); + }); + it.sequential("creates a CLI auth challenge with approval metadata", async () => { mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({ challenge: { diff --git a/server/src/__tests__/sandbox-work-folders.test.ts b/server/src/__tests__/sandbox-work-folders.test.ts index 57a2e3fc1d..ba0b68fe78 100644 --- a/server/src/__tests__/sandbox-work-folders.test.ts +++ b/server/src/__tests__/sandbox-work-folders.test.ts @@ -1,11 +1,12 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; -import { agents, companies, createDb, heartbeatRuns, issues, environments, environmentLeases, projects, projectWorkspaces, startEmbeddedPostgresTestDatabase, type Db } from "@paperclipai/db"; +import { agents, assets, companyMemberships, issueAttachments, companies, createDb, heartbeatRuns, issues, environments, environmentLeases, projects, projectWorkspaces, taskRepositoryBindings, startEmbeddedPostgresTestDatabase, type Db } from "@paperclipai/db"; import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js"; import { prepareSandboxWorkFolders } from "../services/sandbox-work-folders.js"; import { retainUnsavedWorkFolderLease, workFolderSandboxKey } from "../services/work-folder-retention.js"; @@ -44,14 +45,14 @@ describe("shared sandbox work-folder lifecycle", () => { for (const run of active) await run.stop().catch(() => {}); await database?.cleanup(); if (root) await fs.rm(root, { recursive: true, force: true }); }); - async function prepare(home: string, leaseId: string, physicalId = leaseId) { + async function prepare(home: string, leaseId: string, physicalId = leaseId, responsibleUserId: string | null = null) { await fs.mkdir(home, { recursive: true }); const runId = randomUUID(); - await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running" }); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, responsibleUserId, status: "running" }); const lease = { id: leaseId, companyId, environmentId, provider: "test", providerLeaseId: physicalId }; await db.insert(environmentLeases).values({ ...lease, heartbeatRunId: runId }).onConflictDoUpdate({ target: environmentLeases.id, set: { heartbeatRunId: runId } }); const run = await prepareSandboxWorkFolders({ db, companyId, agentId, projectId, taskId, runId, - responsibleUserId: null, storage, sandboxKey: workFolderSandboxKey(lease), target: { kind: "remote", transport: "sandbox", leaseId, remoteCwd: home, + responsibleUserId, storage, sandboxKey: workFolderSandboxKey(lease), target: { kind: "remote", transport: "sandbox", leaseId, remoteCwd: home, runner: { execute: (input) => localTestWorkFolderRunner.execute({ ...input, env: { ...input.env, HOME: home } }) } } }); active.push(run); return run; } @@ -123,4 +124,78 @@ describe("shared sandbox work-folder lifecycle", () => { await resumed.stop(); active.splice(active.indexOf(resumed), 1); }, 120_000); + it("stops private-file synchronization after responsible-user membership is revoked", async () => { + const userId = randomUUID(); + const [membership] = await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: "member" }).returning(); + const leaseId = randomUUID(); + const run = await prepare(path.join(root, "revoked-user"), leaseId, leaseId, userId); + await fs.writeFile(path.join(run.home, "user/private"), "pending private edit"); + await db.update(companyMemberships).set({ status: "inactive" }).where(eq(companyMemberships.id, membership!.id)); + await expect(run.stop()).rejects.toThrow("no longer authorized"); + expect(await retainUnsavedWorkFolderLease(db, { id: leaseId, companyId })).toBe(true); + const svc = workFolderService(db, storage); + const folder = await svc.ensure({ companyId, scope: "user", ownerId: userId }); + expect((await svc.list(folder)).files).toHaveLength(0); + expect(await fs.readFile(path.join(run.home, "user/private"), "utf8")).toBe("pending private edit"); + await db.update(companyMemberships).set({ status: "active" }).where(eq(companyMemberships.id, membership!.id)); + await run.stop(); active.splice(active.indexOf(run), 1); + }, 120_000); + + it("does not publish a partial repository checkpoint and retries a failed final save", async () => { + const leaseId = randomUUID(); + const run = await prepare(path.join(root, "interrupted-checkpoint"), leaseId); + await run.flush(); + const bindingId = run.manifest.repositories[0]!.bindingId; + const [before] = await db.select().from(taskRepositoryBindings).where(eq(taskRepositoryBindings.id, bindingId)); + await fs.writeFile(path.join(run.primaryRepo, "new-unsaved-file"), "must survive a failed save"); + const put = storage.putObject.bind(storage); + const fail = vi.spyOn(storage, "putObject").mockImplementation(async (input) => { + if (input.objectKey.includes("/checkpoints/")) throw new Error("Injected storage outage"); + return put(input); + }); + try { + await expect(run.stop()).rejects.toThrow("Injected storage outage"); + const [after] = await db.select().from(taskRepositoryBindings).where(eq(taskRepositoryBindings.id, bindingId)); + expect(after!.checkpointKey).toBe(before!.checkpointKey); + expect(await retainUnsavedWorkFolderLease(db, { id: leaseId, companyId })).toBe(true); + } finally { fail.mockRestore(); } + await run.stop(); active.splice(active.indexOf(run), 1); + await fs.rm(run.home, { recursive: true }); + const recovered = await prepare(path.join(root, "interrupted-recovered"), randomUUID()); + expect(await fs.readFile(path.join(recovered.primaryRepo, "new-unsaved-file"), "utf8")).toBe("must survive a failed save"); + await recovered.stop(); active.splice(active.indexOf(recovered), 1); + }, 120_000); + + it("seeds duplicate and reserved attachment names idempotently without changing original uploads", async () => { + const attachmentIds: string[] = []; + const originalKeys: string[] = []; + const content = Buffer.from("original upload"); + for (const originalFilename of ["same.txt", "same.txt", ".", ".paperclip-runtime"]) { + const id = randomUUID(); + const objectKey = `${companyId}/attachments/${id}`; + originalKeys.push(objectKey); + await storage.putObject({ objectKey, body: content, contentType: "text/plain", contentLength: content.length }); + await db.insert(assets).values({ id, companyId, provider: storage.id, objectKey, contentType: "text/plain", byteSize: content.length, + sha256: createHash("sha256").update(content).digest("hex"), originalFilename }); + const attachmentId = randomUUID(); attachmentIds.push(attachmentId); + await db.insert(issueAttachments).values({ id: attachmentId, companyId, issueId: taskId, assetId: id }); + } + const leaseId = randomUUID(); + const home = path.join(root, "attachment-seeding"); + const first = await prepare(home, leaseId); + const files = (await fs.readdir(path.join(home, "task"))).filter((file) => attachmentIds.some((id) => file.includes(id))); + expect(files).toHaveLength(4); + await fs.writeFile(path.join(home, "task", files[0]!), "edited working copy"); + await first.stop(); active.splice(active.indexOf(first), 1); + const warm = await prepare(home, randomUUID(), leaseId); + expect(await fs.readFile(path.join(home, "task", files[0]!), "utf8")).toBe("edited working copy"); + expect((await fs.readdir(path.join(home, "task"))).filter((file) => attachmentIds.some((id) => file.includes(id)))).toEqual(files); + await warm.stop(); active.splice(active.indexOf(warm), 1); + for (const objectKey of originalKeys) { + const original = await storage.getObject({ objectKey }); + const chunks: Buffer[] = []; + for await (const chunk of original.stream) chunks.push(Buffer.from(chunk)); + expect(Buffer.concat(chunks)).toEqual(content); + } + }, 120_000); }); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index a6052861cc..2e96216096 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -2797,7 +2797,7 @@ export function accessRoutes( const isSignedInBoardUser = req.actor.type === "board" && - (req.actor.source === "session" || isLocalImplicit(req)) && + (req.actor.source === "session" || req.actor.source === "cloud_tenant" || isLocalImplicit(req)) && Boolean(req.actor.userId); const canApprove = isSignedInBoardUser && diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 2939f517cc..e44fcdbe7a 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -1794,6 +1794,10 @@ function createSandboxEnvironmentDriver( } const workerConfig = stripSandboxProviderEnvelope(parsed.config); + // A provider reaper must not delete the only copy after a failed final + // checkpoint. Normal release still deletes ephemeral sandboxes after + // the host confirms durability; idle stop/archive remains enabled. + if (boundRun && parsed.config.provider === "daytona") workerConfig.autoDeleteInterval = -1; const storedConfig = storedParsed.config; const providerConfigForLease = sandboxConfigForLeaseMetadata(storedConfig); // Require the reusable-lease capability AND a worker that verifies the @@ -1816,7 +1820,6 @@ function createSandboxEnvironmentDriver( declaredReusableLeases && capabilityIsVerified("reusableLeases", pluginVerifiedMethods); const leaseFingerprint = supportsReusableLeases && - parsed.config.reuseLease && input.heartbeatRunId !== null && input.executionWorkspaceId !== null && input.agentId !== null @@ -1845,13 +1848,12 @@ function createSandboxEnvironmentDriver( // or terminal rows cannot be matched. const reusableCandidateLeases = supportsReusableLeases && - parsed.config.reuseLease && input.heartbeatRunId !== null && input.executionWorkspaceId !== null && input.agentId !== null ? (await environmentsSvc.listLeases(input.environment.id)) .filter((lease) => - lease.leasePolicy === "reuse_by_environment" && + ((parsed.config.reuseLease && lease.leasePolicy === "reuse_by_environment") || lease.metadata?.workFolderRecoveryRequired === true) && reusableLeaseCanBeResumed({ lease, heartbeatRunId: input.heartbeatRunId }) && lease.executionWorkspaceId === input.executionWorkspaceId && lease.metadata?.agentId === input.agentId, @@ -1876,6 +1878,9 @@ function createSandboxEnvironmentDriver( lease.heartbeatRunId === input.heartbeatRunId, }), ); + if (reusableCandidateLeases.some((lease) => lease.metadata?.workFolderRecoveryRequired === true && !reusableExistingLeases.includes(lease))) { + throw new Error("Unsaved sandbox work requires recovery with its original run identity and configuration"); + } if (reusableCandidateLeases.length > reusableExistingLeases.length) { await cleanupObsoleteReusableSandboxLeases({ environment: input.environment, @@ -1885,7 +1890,6 @@ function createSandboxEnvironmentDriver( } const reusableProviderLeaseId = supportsReusableLeases && - parsed.config.reuseLease && input.heartbeatRunId !== null && input.executionWorkspaceId !== null && input.agentId !== null @@ -1982,6 +1986,9 @@ function createSandboxEnvironmentDriver( }); } if (!providerLease) { + if (await retainUnsavedWorkFolderLease(db, reusableLease)) { + throw new Error("Saved sandbox could not be resumed; unsaved work was retained for recovery"); + } if ( input.adapterType === "paperclip_runner" && !verifyNativeHarnessBackupStamp( @@ -2046,7 +2053,7 @@ function createSandboxEnvironmentDriver( metadata: acquiredLease.metadata, schema: pluginProvider.resolved.driver.configSchema as Record | null | undefined, }); - const reusableScope = resolvedLeasePolicy === "reuse_by_environment" + const reusableScope = supportsReusableLeases && input.heartbeatRunId !== null ? buildReusableSandboxLeaseScope({ responsibleUserId, issueId: input.issueId, @@ -2186,7 +2193,6 @@ function createSandboxEnvironmentDriver( const providerConfigForLease = sandboxConfigForLeaseMetadata(parsed.config); const leaseFingerprint = supportsReusableLeases && - parsed.config.reuseLease && input.heartbeatRunId !== null && input.executionWorkspaceId !== null && input.agentId !== null @@ -2203,13 +2209,12 @@ function createSandboxEnvironmentDriver( : null; const reusableCandidateLeases = supportsReusableLeases && - parsed.config.reuseLease && input.heartbeatRunId !== null && input.executionWorkspaceId !== null && input.agentId !== null ? (await environmentsSvc.listLeases(input.environment.id)) .filter((lease) => - lease.leasePolicy === "reuse_by_environment" && + ((parsed.config.reuseLease && lease.leasePolicy === "reuse_by_environment") || lease.metadata?.workFolderRecoveryRequired === true) && reusableLeaseCanBeResumed({ lease, heartbeatRunId: input.heartbeatRunId }) && lease.executionWorkspaceId === input.executionWorkspaceId && lease.metadata?.agentId === input.agentId, @@ -2234,6 +2239,9 @@ function createSandboxEnvironmentDriver( lease.heartbeatRunId === input.heartbeatRunId, }), ); + if (reusableCandidateLeases.some((lease) => lease.metadata?.workFolderRecoveryRequired === true && !reusableExistingLeases.includes(lease))) { + throw new Error("Unsaved sandbox work requires recovery with its original run identity and configuration"); + } if (reusableCandidateLeases.length > reusableExistingLeases.length) { await cleanupObsoleteReusableSandboxLeases({ environment: input.environment, @@ -2243,7 +2251,6 @@ function createSandboxEnvironmentDriver( } const reusableProviderLeaseId = supportsReusableLeases && - parsed.config.reuseLease && input.heartbeatRunId !== null && input.executionWorkspaceId !== null && input.agentId !== null @@ -2290,7 +2297,7 @@ function createSandboxEnvironmentDriver( const resolvedLeasePolicy = supportsReusableLeases && parsed.config.reuseLease && input.heartbeatRunId !== null ? "reuse_by_environment" : "ephemeral"; - const reusableScope = resolvedLeasePolicy === "reuse_by_environment" + const reusableScope = supportsReusableLeases && input.heartbeatRunId !== null ? buildReusableSandboxLeaseScope({ responsibleUserId, issueId: input.issueId, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 10c63377d7..22d041ed01 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -19743,6 +19743,7 @@ export function heartbeatService( sandboxWorkFolders = await prepareSandboxWorkFolders({ db, companyId: run.companyId, runId: run.id, agentId: agent.id, responsibleUserId: run.responsibleUserId ?? null, taskId: issueRef?.id ?? null, projectId: issueRef?.projectId ?? null, target: executionTarget, + primaryWorkspaceId: executionWorkspace.workspaceId, primaryBranchName: executionWorkspace.branchName, sandboxKey: workFolderSandboxKey(activeEnvironmentLease.lease) }); if (sandboxWorkFolders.identityChanged) { taskSessionForRun = null; previousSessionParams = null; } executionTarget.workFolderHome = sandboxWorkFolders.home; diff --git a/server/src/services/sandbox-work-folders.ts b/server/src/services/sandbox-work-folders.ts index 89a9f18ca2..019dc3a1d2 100644 --- a/server/src/services/sandbox-work-folders.ts +++ b/server/src/services/sandbox-work-folders.ts @@ -3,7 +3,7 @@ import { createReadStream } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { and, asc, desc, eq, sql } from "drizzle-orm"; -import { assets, issueAttachments, projectWorkspaces, taskRepositoryBindings, workFileOperations, workFolderRuns, workFolders, type Db } from "@paperclipai/db"; +import { agents, assets, companyMemberships, heartbeatRuns, issues, projects, issueAttachments, projectWorkspaces, taskRepositoryBindings, workFileOperations, workFolderRuns, workFolders, type Db } from "@paperclipai/db"; import { WORK_FOLDER_SCOPES, type SandboxWorkFolderManifest, type WorkFolderScope } from "@paperclipai/shared"; import type { AdapterSandboxExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; import type { StorageProvider } from "../storage/types.js"; @@ -15,6 +15,7 @@ import { workFolderService } from "./work-folders.js"; import { workFolderPaths, workFolderTransport, type WorkTreeEntry } from "./work-folder-transport.js"; import { workFolderRepositoryService } from "./work-folder-repositories.js"; import { startWorkFolderCheckpointer } from "./work-folder-checkpointer.js"; +import { assertWorkFolderAccess } from "./work-folder-access.js"; function signature(entry: WorkTreeEntry | undefined) { return entry ? JSON.stringify([entry.kind, entry.sha256, entry.executable]) : "missing"; @@ -28,10 +29,43 @@ function repoName(value: string, id: string) { export async function prepareSandboxWorkFolders(input: { db: Db; companyId: string; runId: string; agentId: string; responsibleUserId: string | null; taskId: string | null; projectId: string | null; target: AdapterSandboxExecutionTarget; + primaryWorkspaceId?: string | null; primaryBranchName?: string | null; storage?: StorageProvider; sandboxKey?: string; }) { const { db, target } = input; if (!target.runner || !target.leaseId) throw new Error("Sandbox file transport is unavailable"); + async function assertBindings() { + const memberships: Array<{ companyId: string; membershipRole: string | null; status: string }> = []; + const deny = () => { throw new Error("Sandbox work-folder access is no longer authorized; working files were retained"); }; + const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId))); + if (!run || run.agentId !== input.agentId || run.responsibleUserId !== input.responsibleUserId) deny(); + const [agent] = await db.select({ id: agents.id }).from(agents).where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId))); + if (!agent) deny(); + if (input.taskId) { + const [task] = await db.select({ id: issues.id }).from(issues).where(and(eq(issues.id, input.taskId), eq(issues.companyId, input.companyId))); + if (!task) deny(); + } + if (input.projectId) { + const [project] = await db.select({ id: projects.id }).from(projects).where(and(eq(projects.id, input.projectId), eq(projects.companyId, input.companyId))); + if (!project) deny(); + } + if (input.responsibleUserId) { + const [membership] = await db.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, input.companyId), + eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, input.responsibleUserId), eq(companyMemberships.status, "active"))); + if (!membership || membership.membershipRole === "viewer") deny(); + if (membership) memberships.push({ companyId: membership.companyId, membershipRole: membership.membershipRole, status: membership.status }); + } + for (const [scope, ownerId] of [["task", input.taskId], ["agent", input.agentId], ["project", input.projectId]] as const) { + if (!ownerId) continue; + await assertWorkFolderAccess(db, { type: "agent", source: "agent_jwt", companyId: input.companyId, + agentId: input.agentId, runId: input.runId, onBehalfOfUserId: input.responsibleUserId, onBehalfOfMemberships: memberships }, + { companyId: input.companyId, scope, ownerId }, true); + } + } + // Host-side transfers do not go through HTTP authorization middleware. Check + // the authoritative bindings here too, including after membership revocation. + // An ended heartbeat may still flush; its immutable identity must still match. + await assertBindings(); const storage = input.storage ?? createStorageProviderFromConfig(loadConfig()); const svc = workFolderService(db, storage); const transport = workFolderTransport(target.runner); @@ -77,11 +111,11 @@ export async function prepareSandboxWorkFolders(input: { const [seeded] = await db.select().from(workFileOperations).where(and(eq(workFileOperations.folderId, folders.task.id), eq(workFileOperations.operationId, operationId))); if (seeded) continue; - const original = (asset.originalFilename ?? attachment.id).split(/[\\/]/).at(-1)!.replace(/[\x00-\x1f\x7f]/g, "_") || attachment.id; - let filename = original; - try { await svc.get(folders.task, filename); filename = `${original}-${attachment.id}`; } catch (error) { - if ((error as { status?: number }).status !== 404) throw error; - } + const original = (asset.originalFilename ?? "attachment").split(/[\\/]/).at(-1)!.replace(/[\x00-\x1f\x7f]/g, "_").slice(0, 180) || "attachment"; + // The ID makes the destination independent of concurrent uploads and + // earlier seeding attempts. Even dot/reserved filenames become safe. + const extension = path.posix.extname(original); + const filename = `${original.slice(0, original.length - extension.length)}-${attachment.id}${extension}`; const result = await storage.getObject({ objectKey: asset.objectKey }); try { await svc.write(folders.task, { path: filename, body: result.stream, contentType: asset.contentType, operationId, onlyIfMissing: true }); } finally { result.stream.destroy(); } @@ -103,7 +137,7 @@ export async function prepareSandboxWorkFolders(input: { if (stat.isDirectory()) { if (relative) await svc.write(folder, { path: relative, kind: "directory", operationId: `import:${relative}`, onlyIfMissing: true }); for (const name of (await fs.readdir(path.join(root, relative))).sort()) { - if ([".codex", ".claude", ".cache", ".config", ".local", ".git", ".paperclip-runtime"].includes(name)) continue; + if ([".codex", ".claude", ".cache", ".config", ".local", ".git", ".paperclip-runtime", ".ssh", ".aws", ".azure", ".netrc", ".git-credentials", ".npmrc", ".npm", "node_modules", ".venv"].includes(name)) continue; await visit(relative ? `${relative}/${name}` : name); } } else if (stat.isFile()) { @@ -192,6 +226,7 @@ export async function prepareSandboxWorkFolders(input: { const names = new Set(existing.map((binding) => binding.name)); const resolveGitAuth = createGitRemoteAuthProvider(db, input.companyId, { responsibleUserId: input.responsibleUserId, agentId: input.agentId, issueId: input.taskId, heartbeatRunId: input.runId }); for (const workspace of workspaces.filter((entry) => entry.repoUrl)) { + const primary = input.primaryWorkspaceId ? workspace.id === input.primaryWorkspaceId : workspace.isPrimary; let binding = existing.find((entry) => entry.workspaceId === workspace.id); if (!binding) { const baseName = repoName(workspace.repoUrl!.split(/[/:]/).at(-1) ?? workspace.name, workspace.id); @@ -222,6 +257,18 @@ export async function prepareSandboxWorkFolders(input: { const checkout = await target.runner!.execute({ command: "git", args: ["-C", temporary, "checkout", binding.repoRef, "--"], bypassSession: true, timeoutMs: 60_000 }); if (checkout.exitCode !== 0 || checkout.timedOut) throw new Error(`Required repository ${binding.name} ref could not be checked out`); } + if (primary && input.primaryBranchName) { + const branch = input.primaryBranchName; + const valid = await target.runner!.execute({ command: "git", args: ["check-ref-format", "--branch", branch], bypassSession: true, timeoutMs: 10_000 }); + if (valid.exitCode !== 0 || valid.stdout.trim() !== branch) throw new Error(`Required repository ${binding.name} branch is invalid`); + // Honor the task's existing branch policy on the initial clone. + // Restores and warm starts keep the saved HEAD and index untouched. + const checkout = await target.runner!.execute({ command: "git", args: ["-C", temporary, "checkout", branch, "--"], bypassSession: true, timeoutMs: 60_000 }); + if (checkout.exitCode !== 0) { + const create = await target.runner!.execute({ command: "git", args: ["-C", temporary, "checkout", "-b", branch], bypassSession: true, timeoutMs: 60_000 }); + if (create.exitCode !== 0 || create.timedOut) throw new Error(`Required repository ${binding.name} task branch could not be created`); + } + } } else { const init = await target.runner!.execute({ command: "git", args: ["-C", temporary, "init"], bypassSession: true, timeoutMs: 10_000 }); if (init.exitCode !== 0) throw new Error(`Repository ${binding.name} could not be restored`); @@ -236,7 +283,7 @@ export async function prepareSandboxWorkFolders(input: { } await db.update(taskRepositoryBindings).set({ setupComplete: true, retiredAt: null }).where(eq(taskRepositoryBindings.id, binding.id)); bindings.push({ binding, root }); - manifest.repositories.push({ bindingId: binding.id, workspaceId: workspace.id, name: binding.name, primary: workspace.isPrimary }); + manifest.repositories.push({ bindingId: binding.id, workspaceId: workspace.id, name: binding.name, primary }); await saveState("starting"); } for (const old of existing) if (!workspaces.some((workspace) => workspace.id === old.workspaceId)) { @@ -263,6 +310,7 @@ export async function prepareSandboxWorkFolders(input: { } const checkpointer = startWorkFolderCheckpointer({ async checkpoint() { + await assertBindings(); await saveState("saving"); for (const scope of WORK_FOLDER_SCOPES) await outgoing(scope); for (const { binding, root } of bindings) await repositories.checkpoint(binding, root); @@ -280,6 +328,7 @@ export async function prepareSandboxWorkFolders(input: { if (run?.refreshRequested) { // The agent has stopped. The successful final flush above protects its // edits before accepting incoming shared files at this safe boundary. + await assertBindings(); for (const scope of WORK_FOLDER_SCOPES) await incoming(scope); await db.update(workFolderRuns).set({ refreshRequested: false, baselines, updatedAt: new Date() }) .where(eq(workFolderRuns.runId, input.runId)); diff --git a/server/src/services/work-folder-repositories.ts b/server/src/services/work-folder-repositories.ts index 65f9f576b6..70910a0bb2 100644 --- a/server/src/services/work-folder-repositories.ts +++ b/server/src/services/work-folder-repositories.ts @@ -56,8 +56,9 @@ export function workFolderRepositoryService(db: Db, storage: StorageProvider, tr for (let offset = 0; offset < published.length; offset += 1000) { await tx.update(workFolderObjects).set({ deleteAfter: null }).where(inArray(workFolderObjects.objectKey, published.slice(offset, offset + 1000))); } - await tx.update(taskRepositoryBindings).set({ checkpointKey, checkpointSha256: digest, checkpointAt: new Date() }) - .where(and(eq(taskRepositoryBindings.id, binding.id), eq(taskRepositoryBindings.companyId, binding.companyId))); + const updated = await tx.update(taskRepositoryBindings).set({ checkpointKey, checkpointSha256: digest, checkpointAt: new Date() }) + .where(and(eq(taskRepositoryBindings.id, binding.id), eq(taskRepositoryBindings.companyId, binding.companyId))).returning({ id: taskRepositoryBindings.id }); + if (!updated.length) throw new Error("Repository owner was deleted during checkpoint"); }); knownByBinding.set(binding.id, new Set(files.flatMap((file) => file.objectKey ? [file.objectKey] : []))); binding.checkpointKey = checkpointKey; diff --git a/tests/runner-e2e/deployed-stack.test.ts b/tests/runner-e2e/deployed-stack.test.ts new file mode 100644 index 0000000000..37b785863e --- /dev/null +++ b/tests/runner-e2e/deployed-stack.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { isStagingOrigin } from "./deployed-stack.js"; + +describe("deployed stack target", () => { + it("requires an explicit HTTPS staging tenant and rejects credential-bearing URLs", () => { + expect(isStagingOrigin("https://work-folders-qa.staging.paperclip.app")).toBe(true); + for (const value of ["http://localhost:3100", "https://tenant.paperclip.app", "https://staging.paperclip.app.attacker.test", + "https://token@tenant.staging.paperclip.app", "https://tenant.staging.paperclip.app/path", "https://tenant.staging.paperclip.app?secret=x"]) { + expect(isStagingOrigin(value), value).toBe(false); + } + }); +}); diff --git a/tests/runner-e2e/deployed-stack.ts b/tests/runner-e2e/deployed-stack.ts new file mode 100644 index 0000000000..36573c546a --- /dev/null +++ b/tests/runner-e2e/deployed-stack.ts @@ -0,0 +1,73 @@ +import fs from "node:fs"; +import assert from "node:assert/strict"; + +// A separate target contract deliberately has no local-server fallback. +export function isStagingOrigin(value: string) { + try { + const url = new URL(value); + return url.protocol === "https:" && url.hostname.endsWith(".staging.paperclip.app") + && !url.username && !url.password && url.pathname === "/" && !url.search && !url.hash; + } catch { return false; } +} +export interface DeployedStack { + baseURL: string; stackId: string; commit: string; appImage: string; migratorVersion: string; sandboxImage: string; + companyId: string; taskId: string; agentId: string; projectId: string; userId: string; + profiles: Array<{ id: string; adapterType: string; engine: string; model: string; qualification: string; agentId: string }>; +} + +export function loadDeployedStack(): DeployedStack { + const filename = process.env.PAPERCLIP_DEPLOYED_STACK_MANIFEST; + if (!filename) throw new Error("PAPERCLIP_DEPLOYED_STACK_MANIFEST is required"); + const manifest = JSON.parse(fs.readFileSync(filename, "utf8")) as DeployedStack; + assert(manifest && typeof manifest === "object", "Invalid manifest"); + for (const key of ["baseURL", "stackId", "commit", "appImage", "migratorVersion", "sandboxImage", "companyId", "taskId", "agentId", "projectId", "userId"] as const) { + assert(typeof manifest[key] === "string" && manifest[key].length > 0, `Missing manifest ${key}`); + } + assert(isStagingOrigin(manifest.baseURL), "Use the dedicated HTTPS staging tenant origin"); + assert(/^[a-f0-9]{40}$/.test(manifest.commit), "Expected full commit SHA"); + assert(/@sha256:[a-f0-9]{64}$/.test(manifest.sandboxImage), "Expected immutable sandbox image"); + const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i; + for (const key of ["companyId", "taskId", "agentId", "projectId"] as const) assert(uuid.test(manifest[key]), `Invalid ${key}`); + assert(Array.isArray(manifest.profiles) && manifest.profiles.length >= 7, "The seven baseline profiles are required"); + for (const profile of manifest.profiles) { + for (const key of ["id", "adapterType", "engine", "model", "qualification", "agentId"] as const) { + assert(typeof profile[key] === "string" && profile[key].length > 0, `Missing profile ${key}`); + } + assert(uuid.test(profile.agentId), "Invalid profile agent ID"); + } + if (new Set(manifest.profiles.map((profile) => profile.id)).size !== manifest.profiles.length) { + throw new Error("Duplicate deployed profile IDs"); + } + return manifest; +} + +export class DeployedStackApi { + private readonly token: string; + constructor(readonly stack: DeployedStack) { + const filename = process.env.PAPERCLIP_DEPLOYED_STACK_AUTH; + if (!filename || (fs.statSync(filename).mode & 0o077) !== 0) { + throw new Error("PAPERCLIP_DEPLOYED_STACK_AUTH must name a private (0600) credentials file"); + } + const auth = JSON.parse(fs.readFileSync(filename, "utf8")); + assert(typeof auth?.baseURL === "string" && typeof auth.boardApiToken === "string" && auth.boardApiToken.length > 0, "Invalid stack credentials"); + if (new URL(auth.baseURL).origin !== new URL(stack.baseURL).origin) throw new Error("Credentials belong to another stack"); + this.token = auth.boardApiToken; + } + // Node fetch keeps credentials out of Playwright traces, request attachments, + // and serialized reporter configuration. Error bodies may contain run secrets. + async request(path: string, options: RequestInit = {}): Promise { + if (!path.startsWith("/api/") || path.includes("\\") || path.includes("..")) throw new Error("Invalid tenant API path"); + const headers = new Headers(options.headers); + headers.set("Authorization", `Bearer ${this.token}`); + return fetch(new URL(path, this.stack.baseURL), { + ...options, headers, redirect: "error", signal: AbortSignal.timeout(60_000), + }); + } + async json(path: string, method = "GET", body?: unknown): Promise { + const response = await this.request(path, { method, + ...(body === undefined ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }), + }); + if (!response.ok) throw new Error(`${method} ${path.split("?")[0]} returned ${response.status}; body withheld`); + return response.json() as Promise; + } +} diff --git a/tests/runner-e2e/deployed-work-folders.spec.ts b/tests/runner-e2e/deployed-work-folders.spec.ts new file mode 100644 index 0000000000..0608a3fb90 --- /dev/null +++ b/tests/runner-e2e/deployed-work-folders.spec.ts @@ -0,0 +1,91 @@ +import { randomUUID } from "node:crypto"; +import { test, expect } from "@playwright/test"; +import type { EnvironmentCapabilities } from "../../packages/shared/src/environment-support.js"; +import type { WorkFolderListing, WorkFolderSyncStatus } from "../../packages/shared/src/work-folders.js"; +import { QUALIFIED_ACPX_PROFILES } from "../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js"; +import { pollUntil } from "./api.js"; +import { DeployedStackApi, loadDeployedStack } from "./deployed-stack.js"; + +const stack = loadDeployedStack(); +const api = new DeployedStackApi(stack); +const folder = (scope: string, ownerId: string) => `/api/companies/${stack.companyId}/work-folders/${scope}/${encodeURIComponent(ownerId)}`; +test.describe.configure({ mode: "serial" }); + +test("deployed candidate and complete supported adapter inventory", async ({}, info) => { + const health = await api.json<{ commit: string }>("/api/health"); + expect(health.commit).toBe(stack.commit); + const capabilities = await api.json(`/api/companies/${stack.companyId}/environments/capabilities`); + expect(capabilities.sandboxProviders.daytona?.supportsRunExecution).toBe(true); + const adapters = await api.json>("/api/adapters"); + const required: string[] = []; + for (const adapter of adapters.filter((entry) => !entry.disabled)) { + if (capabilities.adapters.find((entry) => entry.adapterType === adapter.type)?.drivers.sandbox !== "supported") continue; + if (adapter.type === "paperclip_runner") { + required.push("paperclip_runner:codex", "paperclip_runner:opencode", + ...Object.keys(QUALIFIED_ACPX_PROFILES).map((name) => `paperclip_runner:acpx:${name}`)); + } else { + required.push(`${adapter.type}:cli`); + if (adapter.capabilities.supportsAcp) required.push(`${adapter.type}:acp`); + } + } + const configured = new Set(stack.profiles.map((profile) => `${profile.adapterType}:${profile.engine}`)); + expect(required.filter((key) => !configured.has(key)), "Every exposed sandbox adapter/engine requires a qualified fixture").toEqual([]); + for (const profile of stack.profiles) { + const agent = await api.json<{ adapterType: string; adapterConfig: Record }>(`/api/agents/${profile.agentId}`); + expect(agent.adapterType).toBe(profile.adapterType); + expect(agent.adapterConfig.model).toBe(profile.model); + } + await info.attach("deployed-candidate-and-inventory", { contentType: "application/json", body: Buffer.from(JSON.stringify({ stack, required }, null, 2)) }); +}); + +for (const [scope, owner] of [["task", stack.taskId], ["agent", stack.agentId], ["project", stack.projectId], ["user", stack.userId]]) { + test(`${scope} nested empty executable files, retry, trash and restoration`, async () => { + const base = folder(scope!, owner!); + const filename = `acceptance/${randomUUID()}/empty.sh`; + const key = randomUUID(); + const write = () => api.request(`${base}/content?path=${encodeURIComponent(filename)}`, { + method: "PUT", headers: { "Content-Type": "application/octet-stream", "X-File-Executable": "true", "Idempotency-Key": key }, body: "", + }); + expect((await write()).ok).toBe(true); + expect((await write()).ok).toBe(true); + const listing = await api.json(base); + const file = listing.files.find((entry) => entry.path === filename)!; + expect(file).toMatchObject({ byteSize: 0, executable: true, deletedAt: null }); + const download = await api.request(`${base}/content?path=${encodeURIComponent(filename)}`); + expect(download.status).toBe(200); expect((await download.arrayBuffer()).byteLength).toBe(0); + await api.json(`${base}/operations`, "POST", { action: "delete", path: filename }); + expect((await api.request(`${base}/content?path=${encodeURIComponent(filename)}`)).status).toBe(404); + const trash = await api.json(`${base}?trash=true`); + expect(trash.files.some((entry) => entry.id === file.id)).toBe(true); + await api.json(`${base}/operations`, "POST", { action: "restore", fileId: file.id }); + expect((await api.request(`${base}/content?path=${encodeURIComponent(filename)}`)).status).toBe(200); + }); +} + +for (const profile of stack.profiles) { + test(`${profile.id} creates durable work from the actual sandbox home`, async ({}, info) => { + const nonce = randomUUID(); + const issue = await api.json<{ id: string; identifier: string }>(`/api/companies/${stack.companyId}/issues`, "POST", { + title: `Work folder acceptance ${profile.id} ${nonce}`, projectId: stack.projectId, + assigneeAgentId: profile.agentId, status: "todo", + description: [ + "Perform this sandbox acceptance task using real filesystem tools.", + "Verify cwd equals the operating-system HOME and task, agent, user, project, repos, .codex, .cache are directories beneath it.", + "Verify repos contains at least two independent Git checkouts. Fail the task with the actual error if either assertion fails.", + `Write exactly '${nonce}' without a newline into $HOME/task/acceptance.txt and $HOME/agent/acceptance-${nonce}.txt.`, + "Then complete this task successfully. Do not print credentials or modify unrelated files.", + ].join("\n"), + }); + const base = folder("task", issue.id); + await pollUntil({ label: `${profile.id} completed run and durable task file`, deadlineAt: Date.now() + 840_000, + intervalMs: 5_000, + load: async () => ({ issue: await api.json<{ status: string }>(`/api/issues/${issue.id}`), + saves: await api.json(`${base}/sync`) }), + accept: (state) => state.issue.status === "done" && state.saves.some((save) => !save.active && save.state === "saved" && save.lastSavedAt !== null), + reject: (state) => state.saves.some((save) => save.state === "failed") ? "Work-folder save failed" : undefined, + }); + const content = await api.request(`${base}/content?path=acceptance.txt`); + expect(content.status).toBe(200); expect(await content.text()).toBe(nonce); + await info.attach("task", { contentType: "application/json", body: Buffer.from(JSON.stringify({ profile: profile.id, ...issue })) }); + }); +} diff --git a/tests/runner-e2e/playwright.deployed.config.ts b/tests/runner-e2e/playwright.deployed.config.ts new file mode 100644 index 0000000000..eba8da98f8 --- /dev/null +++ b/tests/runner-e2e/playwright.deployed.config.ts @@ -0,0 +1,16 @@ +import path from "node:path"; +import { defineConfig } from "@playwright/test"; +import { loadDeployedStack } from "./deployed-stack.js"; + +const stack = loadDeployedStack(); +const output = process.env.PAPERCLIP_DEPLOYED_STACK_EVIDENCE; +if (!output || !path.isAbsolute(output)) throw new Error("PAPERCLIP_DEPLOYED_STACK_EVIDENCE must be an absolute output directory"); + +export default defineConfig({ + testDir: ".", testMatch: "deployed-work-folders.spec.ts", + fullyParallel: false, workers: 1, retries: 0, timeout: 900_000, + use: { baseURL: stack.baseURL, trace: "off", video: "off" }, + // No webServer: every operation reaches the deployed tenant and database. + outputDir: path.join(output, "results"), + reporter: [["list"], ["json", { outputFile: path.join(output, "results.json") }]], +}); diff --git a/ui/src/pages/CliAuth.tsx b/ui/src/pages/CliAuth.tsx index ae706db6be..6f49ec9cb2 100644 --- a/ui/src/pages/CliAuth.tsx +++ b/ui/src/pages/CliAuth.tsx @@ -4,7 +4,6 @@ import { Link, useParams, useSearchParams } from "@/lib/router"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { accessApi } from "../api/access"; -import { authApi } from "../api/auth"; import { queryKeys } from "../lib/queryKeys"; export function CliAuthPage() { @@ -18,11 +17,6 @@ export function CliAuthPage() { [challengeId, token], ); - const sessionQuery = useQuery({ - queryKey: queryKeys.auth.session, - queryFn: () => authApi.getSession(), - retry: false, - }); const challengeQuery = useQuery({ queryKey: ["cli-auth-challenge", challengeId, token], queryFn: () => accessApi.getCliAuthChallenge(challengeId, token), @@ -49,7 +43,7 @@ export function CliAuthPage() { return
Invalid CLI auth URL.
; } - if (sessionQuery.isLoading || challengeQuery.isLoading) { + if (challengeQuery.isLoading) { return
Loading CLI auth challenge...
; } @@ -102,7 +96,9 @@ export function CliAuthPage() { ); } - if (challenge.requiresSignIn || !sessionQuery.data) { + // The server evaluates both local sessions and Cloud tenant identity. + // Cloud sign-in does not create a separate Better Auth session cookie. + if (challenge.requiresSignIn) { return (