Harden work-folder recovery and add deployed staging acceptance tools
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
a13bb6b395
commit
6c7d1bbbf0
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/<agent-id>`
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:<full SHA>`, 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 <new output directory>");
|
||||
console.log(JSON.stringify(buildPreviewMigrator(process.argv[2])));
|
||||
}
|
||||
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Response> {
|
||||
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<T>(path: string, method = "GET", body?: unknown): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<EnvironmentCapabilities>(`/api/companies/${stack.companyId}/environments/capabilities`);
|
||||
expect(capabilities.sandboxProviders.daytona?.supportsRunExecution).toBe(true);
|
||||
const adapters = await api.json<Array<{ type: string; disabled: boolean; capabilities: { supportsAcp: boolean } }>>("/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<string, unknown> }>(`/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<WorkFolderListing>(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<WorkFolderListing>(`${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<WorkFolderSyncStatus[]>(`${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 })) });
|
||||
});
|
||||
}
|
||||
|
|
@ -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") }]],
|
||||
});
|
||||
|
|
@ -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 <div className="mx-auto max-w-xl py-10 text-sm text-destructive">Invalid CLI auth URL.</div>;
|
||||
}
|
||||
|
||||
if (sessionQuery.isLoading || challengeQuery.isLoading) {
|
||||
if (challengeQuery.isLoading) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading CLI auth challenge...</div>;
|
||||
}
|
||||
|
||||
|
|
@ -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 (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<Card className="block p-6">
|
||||
|
|
|
|||
Loading…
Reference in New Issue