Merge master into canonical Runner D delivery

Resolve the migration sequence against current master while preserving the audited Runner D data migration. Type the native backend factory mock so the merged TypeScript checks retain its two-argument contract.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dev AgentandPaperclip committed 2026-08-19 21:17:29 +00:00
commit 83a1c438e0
227 files changed
+64329 -3014

No files matched your search

@@ -163,6 +163,9 @@ Mimic this register; do not invent a "professional" tone.
- **"WHATS NEXT" is forward-looking themes**, not a literal sprint list. 3–5
bullets is the right size. Pull these from active goals, in-flight projects,
and recent issues the team is working on — do not invent themes.
- **Highlights follow the changelog skill's delta rule**: a feature the
previous announcement already introduced appears only for what changed
this release, and does not headline twice for follow-through work.
- **"What's on my mind"** is dotta's personal/strategic thinking — docs gaps,
philosophical positioning ("we're the human control plane for ai labor"),
invitations ("if you've ever wanted to write about how you use Paperclip,
@@ -168,6 +168,14 @@ Guidelines:
- write from the user perspective
- keep highlights short and concrete
- spell out upgrade actions for breaking changes
- **describe deltas, not repeats**: read the previous stable's notes
(`releases/v<last-stable>.md`) before writing. When they already
introduced a feature, this release's entry covers only what changed —
a default flip, a hardening, a completion — phrased against the prior
release ("last release introduced X; this release makes it the
default"), never re-describing the feature as if it debuted. A theme
that headlined the previous release does not headline again for
follow-through work; demote it to Improvements.
### Inline PR and contributor attribution
+6 -11
View File
@@ -6,19 +6,14 @@ updates:
interval: weekly
day: monday
time: "06:00"
open-pull-requests-limit: 10
# Each dependency update gets its own pull request, including major
# version bumps. A grouped major PR cannot merge when one member has
# a blocked upgrade, so it hides the other ready majors. Separate PRs
# let each major land on its own. The limit holds the initial burst
# of pending majors plus the regular minor and patch updates.
open-pull-requests-limit: 20
labels:
- "dependencies"
groups:
# Bundle all major version bumps into a single weekly PR so they
# stay visible without flooding the PR list. Minor/patch updates
# still get individual PRs.
major-updates:
applies-to: version-updates
update-types:
- "major"
patterns:
- "*"
- package-ecosystem: github-actions
directory: "/"
+1 -1
View File
@@ -59,7 +59,7 @@
},
"devDependencies": {
"@types/node": "^22.20.1",
"tsx": "^4.23.1",
"tsx": "^4.23.12",
"typescript": "^5.7.3"
}
}
+3
View File
@@ -139,6 +139,9 @@ describe("secrets CLI helpers", () => {
delete process.env.AWS_DEFAULT_REGION;
delete process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID;
delete process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID;
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
delete process.env.AWS_SESSION_TOKEN;
});
afterEach(() => {
File diff suppressed because it is too large. Load diff
+42
View File
@@ -5,12 +5,52 @@ import { expandHomePrefix } from "../config/home.js";
export const DEFAULT_WORKTREE_HOME = "~/.paperclip-worktrees";
export const WORKTREE_SEED_MODES = ["minimal", "full"] as const;
export const WORKTREE_SEED_MANIFEST = "seed-manifest.json";
export const WORKTREE_SEED_PENDING_MARKER = "seed-pending";
export const WORKTREE_SEED_COMPLETE_MARKER = "seed-complete";
export const WORKTREE_SEED_LOCK_MARKER = "seed.lock";
export type WorktreeSeedMode = (typeof WORKTREE_SEED_MODES)[number];
export const WORKTREE_SEED_PHASES = [
"pending",
"source_validation",
"snapshot",
"restore",
"migrations",
"execution_quarantine",
"routine_pause",
"workspace_rebind",
"post_restore_validation",
"complete",
] as const;
export type WorktreeSeedPhase = (typeof WORKTREE_SEED_PHASES)[number];
export type WorktreeSeedState = "pending" | "running" | "verified" | "failed";
export type WorktreeSeedManifest = {
version: 2;
source: {
instanceId: string;
configPath: string;
};
snapshotAt: string | null;
seedMode: WorktreeSeedMode;
migrationRevision: string | null;
targetInstanceId: string;
phase: WorktreeSeedPhase;
state: WorktreeSeedState;
attemptId: string;
startedAt: string | null;
finishedAt: string | null;
diagnostics: Array<{
phase: WorktreeSeedPhase;
status: "started" | "succeeded" | "failed";
at: string;
message?: string;
}>;
};
export type WorktreeSeedPlan = {
mode: WorktreeSeedMode;
excludedTables: string[];
@@ -54,6 +94,7 @@ export type WorktreeUiBranding = {
};
export type WorktreeSeedMarkerPaths = {
manifest: string;
pending: string;
complete: string;
lock: string;
@@ -62,6 +103,7 @@ export type WorktreeSeedMarkerPaths = {
export function resolveWorktreeSeedMarkerPaths(configPath: string): WorktreeSeedMarkerPaths {
const configDir = path.dirname(path.resolve(configPath));
return {
manifest: path.resolve(configDir, WORKTREE_SEED_MANIFEST),
pending: path.resolve(configDir, WORKTREE_SEED_PENDING_MARKER),
complete: path.resolve(configDir, WORKTREE_SEED_COMPLETE_MARKER),
lock: path.resolve(configDir, WORKTREE_SEED_LOCK_MARKER),
+1009 -113
View File
File diff suppressed because it is too large. Load diff
+53 -10
View File
@@ -335,6 +335,8 @@ Every local install keeps runtime state directly under the selected instance roo
storage/ # local_disk uploads
backups/ # automatic DB backups
logs/
runtime-services/ # managed local-service registry
runtime-service-logs/ # append-only managed-service stdout/stderr
secrets/master.key # local_encrypted master key
workspaces/<agent-id>/ # default agent workspaces
projects/ # project execution workspaces
@@ -477,6 +479,8 @@ Seed modes:
Seeded worktree instances quarantine copied live execution by default for both `minimal` and `full` seeds. During restore, Paperclip disables copied agent timer heartbeats, resets copied `running` agents to `idle`, blocks and unassigns copied agent-owned `in_progress` issues, and unassigns copied agent-owned `todo`/`in_review` issues. This keeps a freshly booted worktree from starting agents for work already owned by the source instance. Pass `--preserve-live-work` only when you intentionally want the isolated worktree to resume copied assignments.
The same quarantine stops copied project/execution-workspace runtime desired states and clears copied runtime process claims. Without this reset, booting the cloned Paperclip database could restart a source workspace's dev service from the isolated instance, creating duplicate runners, port reassignment, and stale public URLs.
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
`pnpm dev` now fails fast in a linked git worktree when `.paperclip/.env` is missing, instead of silently booting against the default instance/port. If that happens, run `paperclipai worktree init` in the worktree first.
@@ -485,25 +489,29 @@ After `worktree init`, both the server and the CLI auto-load the repo-local `.pa
Seeding a worktree database is the heaviest part of `worktree init`. That work can be deferred so a worktree is cheap to create and only pays the seed cost the first time it is actually used — the CLI/dev-time analog of the server's lazy runtime provisioning (see the board-operator guide's "Lazy runtime provisioning" section).
Seeding state is tracked with two marker files under the worktree's `.paperclip/` directory:
Seeding state is tracked in `.paperclip/seed-manifest.json`. The versioned manifest records only non-secret evidence: source instance id/config path, target instance id, seed mode, snapshot time, migration revision, attempt timestamps, current phase, terminal state, and a bounded phase diagnostic history. It never stores database credentials or auth credential material. The legacy `seed-pending` and `seed-complete` files remain read-only compatibility signals for worktrees created before the manifest shipped.
- `seed-pending` — the isolated database has not been seeded yet (a **lean** worktree). Written by `worktree init` before any seed runs.
- `seed-complete` — the database was seeded; the pending marker is removed.
The default `worktree init` still seeds eagerly. A lean worktree (created without an eager seed) has a `pending` manifest until something seeds it on demand:
The default `worktree init` still seeds eagerly and writes `seed-complete` immediately. A lean worktree (created without an eager seed) keeps its `seed-pending` marker until something seeds it on demand:
- `pnpm paperclipai worktree ensure-seeded` performs the deferred seed **exactly once**. It is lock-guarded and idempotent: only a complete `verified` manifest short-circuits it, so it is safe to call repeatedly and from concurrent processes. Managed workspaces derive the source exclusively from the control-plane-provided base project workspace; manual worktrees must pass `--from-config`.
- `paperclipai run` calls `ensureWorktreeSeeded` automatically before doctor/boot. Managed runs transparently seed a lean worktree from their registered base workspace; an unmanaged lean worktree must first run `worktree ensure-seeded --from-config <source-config>`.
- Managed Paperclip git worktrees default to the repository's `scripts/provision-worktree.sh` when the strategy omits `provisionCommand`, so the isolated config and pending manifest cannot be silently skipped. Runtime startup also runs `scripts/provision-worktree-runtime.sh` automatically when no explicit runtime provision command is configured and the manifest is not verified. Explicitly configured provision commands still take precedence.
- The built-in deferred seed is recorded as its own terminal `workspace_seed` operation. A zero exit code is not enough for success: the operation succeeds only when `.paperclip/seed-manifest.json` contains complete verified evidence; failed, missing, or malformed manifests produce a failed operation with the seed phase in metadata.
- Worktrees created before lazy seeding shipped may have neither marker. Paperclip adopts them only after their configured database proves a compatible migration journal and the core Paperclip schema; otherwise managed startup creates a pending manifest and performs the normal verified seed. Manual markerless worktrees must provide `--from-config` so the source remains explicit.
- `pnpm paperclipai worktree ensure-seeded` performs the deferred seed **exactly once**. It is lock-guarded and idempotent: a present `seed-complete` marker or a missing `seed-pending` marker short-circuits it, so it is safe to call repeatedly and from concurrent processes. It reads the source instance from the `seed-pending` marker unless you pass `--from-config`.
- `paperclipai run` calls `ensureWorktreeSeeded` automatically before doctor/boot, so `run` transparently seeds a lean worktree on first launch.
- Managed git-worktree runtime startup also runs `scripts/provision-worktree-runtime.sh` automatically when a legacy workspace policy has no explicit runtime provision command and the worktree is still `seed-pending`. An explicitly configured runtime provision command always takes precedence.
- Worktrees created before lazy seeding shipped have neither marker; they are treated as already-seeded for backward compatibility (never re-cloned).
Both `minimal` and `full` modes use the same terminal data-validation contract. Source validation accepts a migration journal that is a prefix of the checkout's journal and records the source revision in seed diagnostics; it rejects a source that is ahead of the checkout because that would require a downgrade. After restore, Paperclip applies pending migrations and requires the target journal to be current. Both validations also read an auth user with an instance administrator role, active company membership, and representative cloned company/issue pair. Authenticated instances additionally require that administrator to have a non-empty credential account. `local_trusted` instances accept the implicit local Board user without an account row because that mode intentionally has no human login flow. Restore, migrations, execution quarantine, routine pausing, workspace rebinding, and post-restore validation all run under the seed lock. An interruption leaves the exact active phase in terminal `failed` state; it cannot produce readiness evidence.
**Seed-pending guard.** `pnpm dev` (the dev-runner) refuses to boot a worktree whose database is still `seed-pending` and points you at the fix:
The seed process must own the target embedded PostgreSQL lifecycle for that entire sequence. It refuses to restore into a target postmaster that is already running, suppresses the embedded provider's process-global exit hooks, and stops its owned target only after validation or failure cleanup. A shutdown detected during restore is recorded as a target-database shutdown diagnostic rather than a generic restore failure.
The seed manifest never grants source-path authority. Its source path and instance are diagnostic assertions derived from the realpath-canonical registered source. Deferred seeding resolves registration independently before taking the seed lock; under that lock it replaces stale source diagnostics from the registered value and then revalidates the manifest before any backup, service stop, spawn, or database mutation. Missing registration, invalid registered paths, source/target identity collisions, target-instance mismatches, and company mismatches fail closed.
**Unverified-seed guard.** `pnpm dev` (the dev-runner) refuses to boot a worktree whose manifest is pending, running, failed, malformed, or missing required verification evidence and points you at the fix:
```
[paperclip] this worktree database is seed-pending. Run `pnpm paperclipai worktree ensure-seeded` before `pnpm dev`.
```
This guard (`isWorktreeSeedPending` in `server/src/dev-runner-worktree.ts`) prevents `pnpm dev` from starting the app against an empty, unseeded database — run `worktree ensure-seeded` once and re-run `pnpm dev`.
This guard (`isWorktreeSeedPending` in `server/src/dev-runner-worktree.ts`) prevents `pnpm dev` from starting the app against an empty or partially restored database — run `worktree ensure-seeded` once and re-run `pnpm dev`.
Provisioned git worktrees also pause seeded routines that still have enabled schedule triggers in the isolated worktree database by default. This prevents copied daily/cron routines from firing unexpectedly inside the new workspace instance during development without disabling webhook/API-only routines.
@@ -528,6 +536,27 @@ paperclipai worktree env
eval "$(paperclipai worktree env)"
```
### Workspace login handoff and readiness
Opening a managed workspace board no longer depends on knowing which cloned password is current. `Open workspace` asks the main control plane for a short-lived, single-use ticket; the isolated workspace verifies it and creates its own instance-scoped Better Auth session.
- **Issue** — `POST /api/execution-workspaces/{id}/login-handoff` (board actors only). The ticket is bound to the caller's user id and email, the execution workspace id, the workspace's company id, the isolated instance id, the live runtime origin, a nonce, and a ~90 s expiry. Nothing in the request body influences that binding; only the landing path is caller-supplied and it is reduced to a same-origin path before signing.
- **Exchange** — `GET /api/auth/{workspace-handoff}/exchange?ticket=…` on the workspace itself, registered as a Better Auth plugin so session creation and cookie signing use Better Auth's own path. It verifies the signature, expiry, origin, instance, workspace, company, the cloned user's email, and an active membership **in that company**, records the nonce so a replay loses, and answers with an HTTP redirect — which is what keeps the ticket out of browser history. Request logs redact the `ticket` parameter.
- **Fallback** — direct email/password sign-in still works and the UI labels it accurately as *snapshot-local credentials*. A rejected ticket redirects to `/auth?workspaceHandoffError=<reason>` rather than failing opaquely.
Key material is derived, never shared. The control plane keeps a root secret (`PAPERCLIP_WORKSPACE_HANDOFF_SECRET`, or a domain-separated derivation from the instance's existing signing secret when that is unset) and injects only per-workspace values into the guest process:
| Variable | Purpose |
| --- | --- |
| `PAPERCLIP_WORKSPACE_HANDOFF_KEY` | Per-workspace ticket verification key. A guest cannot mint a ticket for a sibling workspace. |
| `PAPERCLIP_WORKSPACE_READINESS_TOKEN` | Bearer token the control plane presents to read this workspace's protected readiness. |
| `PAPERCLIP_EXECUTION_WORKSPACE_ID` | Execution workspace the guest was provisioned for, used for identity checks. |
| `PAPERCLIP_EXECUTION_WORKSPACE_COMPANY_ID` | Company whose board the guest represents. Scopes both the membership check and the readiness probes, so "some company in the clone is fine" cannot pass for the one being opened. |
Protected `/api/health` on a cloned workspace additionally carries a `workspace` block — `state`, `databaseReady`, `cloneDataReady`, `authHandoffReady`, `seedState`, `seedPhase`, `instanceId`, `executionWorkspaceId`, `failurePhase`. Public health stays redacted. Managed runtime start will not publish `running / healthy` unless that block agrees and names this exact instance and workspace, and runtime-service work products are refreshed from the live runtime row so a port change cannot leave a stale user-facing URL.
The workspace UI surfaces `Provisioning database`, `Validating clone`, `Ready`, `Degraded`, `Repairing`, and `Repair failed`, each with one safe action (open, start, repair, or read the log).
### Worktree CLI Reference
**`npx paperclipai worktree init [options]`** — Create repo-local config/env and an isolated instance for the current worktree.
@@ -609,6 +638,7 @@ For an already-created worktree where you want to keep the existing repo-local c
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `full`) |
| `--yes` | Skip the destructive confirmation prompt |
| `--allow-live-target` | Override the guard that requires the target worktree DB to be stopped first |
| `--backup-target` | Retain a recoverable full target-DB backup before reseeding |
Examples:
@@ -628,6 +658,8 @@ npx paperclipai worktree reseed \
--seed-mode full
```
Managed workspace repair uses this same verified full-reseed contract through `POST /api/execution-workspaces/:id/runtime-commands/repair`. The exclusive, audited operation stops managed services, writes a recoverable pre-repair database backup under the isolated instance's backup directory, performs the full seed/migration/quarantine/rebinding sequence, and restarts only after terminal manifest and service-health validation. It preserves the worktree filesystem. On failure, services remain stopped while the database backup, seed manifest, bounded phase diagnostics, and operation log are retained for inspection; repair never retries itself in a loop.
**`npx paperclipai worktree:make <name> [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step.
| Option | Description |
@@ -673,6 +705,11 @@ Heavier setup that is only needed by a managed runtime service can use `workspac
Managed runtime control actions (`start`, `stop`, `restart`, and job `run`) are mutually exclusive per execution workspace. An overlapping control is rejected with `409 workspace_runtime_control_in_progress` instead of racing the active operation, and authorization is still checked first, so the conflict never widens who may control a workspace.
Local managed runtime services write stdout and stderr directly to append-only
files under the instance's `runtime-service-logs/` directory. The child inherits
the file descriptors rather than supervisor-owned pipes, so request-logging
servers remain responsive and adoptable when the control plane restarts.
Every managed control reaches a terminal operation state. Each one stamps the owning server process and pid on its `workspace_operations` row and heartbeats while it runs, and each one carries a wall-clock ceiling (30 minutes for lifecycle controls, 4 hours for workspace jobs) so a hung provider or listener fails the operation rather than leaving it active. When a start fails part-way, Paperclip tears the workspace's runtime services down through the ordinary stop path and records a stopped desired state, so the lane is retryable and a startup reconcile will not resurrect a service that never came up.
Recovery of stranded controls is bounded and cannot steal a live operation. A `running` control is only terminalized when its owning process is gone, when the owning request in this process no longer exists, or after 60 seconds without a heartbeat; the terminalizing write is a compare-and-swap on `updated_at`, so an owner that heartbeats concurrently keeps its operation. Recovery runs on server startup and before each managed control, appends reconciliation evidence to the workspace-operation log, and stays inside the requested workspace's scope.
@@ -842,6 +879,12 @@ Environment overrides:
stale-backup warning threshold
- `PAPERCLIP_DB_BACKUP_ALERT_FILE=/path/to/failure-marker` lets external cron
wrappers surface the last failed backup in `/api/health`
- `PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS=<days>` sets how long the
terminal-workspace reaper waits after an issue tree becomes terminal before it
archives the execution workspace and deletes the worktree. A person can reopen
the work inside this window. The default is `7`. A value of `0` disables the
cooldown and restores immediate reaping. A negative or non-numeric value falls
back to the default.
Without `PAPERCLIP_DB_BACKUP_ALERT_FILE`, health checks look for
`db-backup-to-s3.failure` in the backup directory, beside the backup directory,
+1 -1
View File
@@ -71,7 +71,7 @@
"connections:ingest-app-definitions": "node scripts/ingest-app-definitions.mjs"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@playwright/test": "^1.62.1",
"cross-env": "^10.1.0",
"esbuild": "^0.28.1",
"typescript": "^5.7.3",
@@ -2594,6 +2594,42 @@ describe("ACPX engine remote managed-home seam (PR 2: per-adapter home seed)", (
expect(stageArgs.assets ?? []).toEqual([]);
expect(sessionInputs[0]?.cwd).toBe(remoteCwd);
});
it("test_remote_seam_surfaces_referenced_project_staging_failure_reason", async () => {
// A referenced project that failed to stage into the sandbox is a first-class
// run outcome. The run result carries the failure reason for each dropped
// project, and the run log gains one stderr line per failure that names the
// project id and the reason. The run stays successful (per-project isolation).
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const { result, logs } = await runExecutor(
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
{
authToken: "real-run-jwt",
executionTarget,
prepareRemoteManagedHome: async (input) => {
const stagedRuntime = await input.stage([]);
// Inject a per-project staging failure the coordinator would record on a
// real sandbox extract failure.
stagedRuntime.additionalSourceFailures = [
{ projectId: "proj-x", error: "extract failed: boom" },
];
return { stagedRuntime };
},
},
);
// The run result carries the failure with its reason.
expect(result.referencedProjectStagingFailures).toEqual([
{ projectId: "proj-x", error: "extract failed: boom" },
]);
// The run log gains one stderr line that names the project id and the reason.
const failureLine = logs.find(
(entry) => entry.stream === "stderr" && entry.text.includes("proj-x"),
);
expect(failureLine?.text).toBe(
"[paperclip] Referenced project proj-x failed to stage; the run continues without it: extract failed: boom\n",
);
});
});
describe("ACPX engine remote session-lifecycle re-staging (PR 3: stage once / reuse on compatible resume)", () => {
@@ -3349,14 +3385,27 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () =
expect(turnSpan!.parent).toBe(runRootSpan);
expect(turnSpan!.ended).toBe(true);
// The settlement sync-back opens the `sandbox.syncBack` span. It runs at
// teardown, so it parents to the run root span, not to the bring-up span.
const syncBackSpan = spans.find((span) => span.name === "sandbox.syncBack");
expect(syncBackSpan).toBeTruthy();
expect(syncBackSpan!.parent).toBe(runRootSpan);
expect(syncBackSpan!.ended).toBe(true);
// A codex bring-up over the remote sandbox lane crosses all 7 boundaries.
// Each boundary span parents to the sandbox bring-up span, not to the run
// root or the turn span. The `stage.sync` step also opens three host
// sub-step spans — `snapshot.git`, `snapshot.baseline`, and `pack` — around
// its git enumeration, baseline content-hash walk, and workspace tarball
// build, so those nest one level deeper.
// root or the turn span. The `stage.sync` step also opens the two pre-task
// sub-step spans — `snapshot.git` and `snapshot.baseline` — plus the
// `stage.workspace` inbound task span, and the `pack` span nests one level
// deeper under `stage.workspace`.
const childNames = spans
.filter((span) => span !== runRootSpan && span !== startupSpan && span !== turnSpan)
.filter(
(span) =>
span !== runRootSpan &&
span !== startupSpan &&
span !== turnSpan &&
span !== syncBackSpan,
)
.map((span) => span.name)
.sort();
expect(childNames).toEqual(
@@ -3370,32 +3419,44 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () =
"snapshot.baseline",
"snapshot.git",
"stage.sync",
"stage.workspace",
"workspace.resolve",
],
);
// The three host sub-step spans nest under the `stage.sync` step span (that
// host work runs inside the step), not directly under the bring-up span.
// The two pre-task sub-step spans and the `stage.workspace` task span nest
// under the `stage.sync` step span (that host work runs inside the step), not
// directly under the bring-up span. The `pack` span nests under
// `stage.workspace`, because the host builds the tarball inside the task.
const stageSyncSpan = spans.find((span) => span.name === "stage.sync");
const stageWorkspaceSpan = spans.find((span) => span.name === "stage.workspace");
const packSpan = spans.find((span) => span.name === "pack");
const snapshotGitSpan = spans.find((span) => span.name === "snapshot.git");
const snapshotBaselineSpan = spans.find((span) => span.name === "snapshot.baseline");
expect(stageSyncSpan).toBeTruthy();
expect(stageWorkspaceSpan).toBeTruthy();
expect(packSpan).toBeTruthy();
expect(snapshotGitSpan).toBeTruthy();
expect(snapshotBaselineSpan).toBeTruthy();
expect(packSpan!.parent).toBe(stageSyncSpan);
expect(stageWorkspaceSpan!.parent).toBe(stageSyncSpan);
expect(packSpan!.parent).toBe(stageWorkspaceSpan);
expect(snapshotGitSpan!.parent).toBe(stageSyncSpan);
expect(snapshotBaselineSpan!.parent).toBe(stageSyncSpan);
expect(packSpan!.ended).toBe(true);
// Every boundary step span parents to the sandbox bring-up span and ends.
// The three `stage.sync` sub-step spans are the exceptions: they parent to
// `stage.sync` above.
const stageSyncChildren = new Set([packSpan, snapshotGitSpan, snapshotBaselineSpan]);
// The `stage.sync` sub-step spans and the run-parented `sandbox.syncBack`
// span are the exceptions: they parent above.
const nonStartupChildren = new Set([
packSpan,
stageWorkspaceSpan,
snapshotGitSpan,
snapshotBaselineSpan,
syncBackSpan,
]);
for (const span of spans) {
if (span === runRootSpan || span === startupSpan || span === turnSpan) continue;
if (stageSyncChildren.has(span)) continue;
if (nonStartupChildren.has(span)) continue;
expect(span.parent, `span "${span.name}" must parent to the startup span`).toBe(startupSpan);
expect(span.ended, `span "${span.name}" must end`).toBe(true);
}
@@ -4139,6 +4200,78 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () =
expect(execParents.size).toBe(4);
});
it("test_sync_back_runs_inside_sandbox_syncback_span", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const localCwd = path.join(root, "worktree");
const codexHome = path.join(root, "codex-home");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(codexHome, { recursive: true });
const executionTarget = await remoteSandboxTarget(root);
const { traceContext, spans } = createRecordingStartupTrace();
// The settlement `syncBack` step runs the managed-home teardown. The teardown
// issues one exec through the same host-to-sandbox seam the run uses. The
// recorder captures the exec parent, so the test proves the teardown runs
// inside the run-parented `sandbox.syncBack` span.
let teardownExecFired = false;
const execute = createAcpxEngineExecutor({
prepareRemoteManagedHome: async (input) => {
const stagedRuntime = await input.stage([]);
return {
stagedRuntime,
teardown: async () => {
if (!teardownExecFired) {
teardownExecFired = true;
issueSandboxExecFromStore(traceContext);
}
},
};
},
createRuntime: () => buildRuntime() as never,
});
const result = await execute({
runId: "run-sync-back-span",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: {
agent: "codex",
agentCommand: "node ./fake-acp.js",
stateDir,
cwd: localCwd,
env: { CODEX_HOME: codexHome },
},
context: {},
authToken: "real-run-jwt",
executionTarget,
startupTraceContext: traceContext,
onLog: async () => {},
onMeta: async () => {},
onEvent: async () => {},
} as never);
expect(result.exitCode).toBe(0);
expect(teardownExecFired).toBe(true);
// One `sandbox.syncBack` span opens, ends, and parents to `task.run`. The
// settlement runs the teardown after the turn, so the run parent is
// `task.run` at that time.
const runSpan = spans.find((span) => span.name === "task.run");
expect(runSpan).toBeTruthy();
const syncBackSpans = spans.filter((span) => span.name === "sandbox.syncBack");
expect(syncBackSpans).toHaveLength(1);
const syncBackSpan = syncBackSpans[0]!;
expect(syncBackSpan.ended).toBe(true);
expect(syncBackSpan.parent).toBe(runSpan);
// The teardown exec parents to `sandbox.syncBack`, so the sync-back copy-back
// runs inside the span, not unparented at teardown.
const syncBackExec = spans.find(
(span) => span.name === "sandbox.exec" && span.parent === syncBackSpan,
);
expect(syncBackExec, "the teardown exec must parent to sandbox.syncBack").toBeTruthy();
});
it("test_no_sandbox_exec_parents_to_http_root", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
@@ -3326,7 +3326,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
let resumedSession = false;
let clearSession = false;
let referencedProjectStagingFailuresField:
| { referencedProjectStagingFailures: Array<{ projectId: string }> }
| { referencedProjectStagingFailures: Array<{ projectId: string; error: string }> }
| Record<string, never> = {};
const recordTeardownError = async (step: string, teardownErr: unknown) => {
const reason = teardownErr instanceof Error ? teardownErr.message : String(teardownErr);
@@ -3432,9 +3432,19 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
// is a failure to report.
const referencedProjectStagingFailures = (
prepared.stagedRuntime?.additionalSourceFailures ?? []
).map((failure) => ({ projectId: failure.projectId }));
).map((failure) => ({ projectId: failure.projectId, error: failure.error }));
referencedProjectStagingFailuresField =
referencedProjectStagingFailures.length > 0 ? { referencedProjectStagingFailures } : {};
// Write one run-log line per failure, so a reader of the run log alone sees
// each dropped referenced project and its reason. The run continues without
// the failed project (per-project failure isolation). Goes to stderr: the
// acpx stdout log stream carries machine-parseable JSON event payloads.
for (const failure of referencedProjectStagingFailures) {
await ctx.onLog(
"stderr",
`[paperclip] Referenced project ${failure.projectId} failed to stage; the run continues without it: ${failure.error}\n`,
);
}
// State the effective wall-clock timeout and its source up front so a
// later timeout is diagnosable from the run log alone. Goes to stderr:
// the acpx stdout log stream carries JSON acpx.* event payloads and must
@@ -4226,9 +4236,15 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
stopTransport: () => timedPhase("stop_transport", async () => {
await stopRunTransport(prepared);
}),
// The site sync-back (the managed-home copy-back).
// The site sync-back (the managed-home copy-back). The run-parented span
// runner wraps the restore in a `sandbox.syncBack` span. The runner also
// publishes the run parent into the runtime-parent store while the restore
// runs, so the host mints a `traceparent` for the provider spans, and the
// per-task restore spans parent to `sandbox.syncBack`.
syncBack: () => timedPhase("sync_back", async () => {
await syncBackManagedHome(prepared);
await runRuntimeSpan("sandbox.syncBack", async () => {
await syncBackManagedHome(prepared);
});
}),
// The staging lease releases as the run's final act, AFTER the coordinator
// reproduces the result, in the run root `finally` below. This step stays a
@@ -325,6 +325,8 @@ export interface SitePlan {
/** One referenced project whose staging into the sandbox failed. */
export interface ReferencedProjectStagingFailure {
readonly projectId: string;
/** The failure reason. A reader of the run sees why the project dropped. */
readonly error: string;
}
/**
@@ -140,6 +140,23 @@ describe("sandbox run site", () => {
expect(scopeOf("agent_bridge")).toBe("per_run");
});
it("test_sandbox_site_place_workspace_carries_referenced_project_failure_reason", async () => {
// A referenced project that fails to stage carries its failure reason back on
// the placed-workspace result, so a reader of the run learns why it dropped.
const failedStaged = {
runtimeRootDir: "/remote/fail/.paperclip-runtime/acpx",
additionalSourceDirs: {},
additionalSourceFailures: [{ projectId: "proj-x", error: "extract failed: boom" }],
} as unknown as PreparedAdapterExecutionTargetRuntime;
const { site } = makeSite({ stage: async () => failedStaged });
const placed = await site.placeWorkspace(makeContext("s"));
expect(placed).toEqual({
referencedProjectStagingFailures: [{ projectId: "proj-x", error: "extract failed: boom" }],
});
});
it("test_sandbox_site_preserves_bridge_overlap_and_callback_sequencing", async () => {
const events: string[] = [];
let releasePaperclip!: () => void;
@@ -181,6 +198,46 @@ describe("sandbox run site", () => {
expect(transport.launchEnv).toEqual(processLaunchEnv);
});
it("test_sandbox_site_session_new_starts_only_after_the_sync_barrier_settles", async () => {
// The staging step wraps the inbound sync coordinator. Hold it open with a
// gate, so the sync barrier stays unsettled. `placeWorkspace` awaits staging,
// so it stays pending while the gate is held.
let releaseStaging!: () => void;
const stagingGate = new Promise<void>((resolve) => {
releaseStaging = resolve;
});
const freshStaged = makeStagedRuntime("fresh");
const { site, bridgeCalls } = makeSite({
stage: async () => {
await stagingGate;
return freshStaged;
},
});
// Drive the real run order: the engine awaits `placeWorkspace`, then starts the
// transport. `startProcessSessionBridge` models the ACP `session/new` startup.
let placed = false;
const run = (async () => {
await site.placeWorkspace(makeContext("s"));
placed = true;
await site.startTransport(makeContext("s"));
})();
run.catch(() => undefined);
// The sync barrier is still open, so `placeWorkspace` has not resolved and the
// process-session bridge that starts `session/new` has not started.
await new Promise<void>((resolve) => setTimeout(resolve, 50));
expect(placed).toBe(false);
expect(bridgeCalls).not.toContain("process-session:start");
// Settle the sync barrier. `placeWorkspace` resolves, then the transport starts
// `session/new`.
releaseStaging();
await run;
expect(placed).toBe(true);
expect(bridgeCalls).toContain("process-session:start");
});
it("test_sandbox_site_borrow_yields_files_and_runtime_is_still_created", async () => {
const cachedRuntime = makeStagedRuntime("cached");
const stagedRuntimes = new Map<string, StagedRuntimeStoreEntry>([
@@ -320,7 +320,7 @@ export function createSandboxRunSite(options: SandboxRunSiteOptions): SandboxRun
}
return {
referencedProjectStagingFailures: (staged.stagedRuntime.additionalSourceFailures ?? []).map(
(failure) => ({ projectId: failure.projectId }),
(failure) => ({ projectId: failure.projectId, error: failure.error }),
),
};
},
@@ -109,6 +109,11 @@ export const SANDBOX_STARTUP_SPAN_ATTRS = {
transferWallMs: `${SANDBOX_STARTUP_SPAN_ATTR_PREFIX}transfer.wall_ms`,
/** The number of serial guard round trips before one transfer. */
transferGuardCount: `${SANDBOX_STARTUP_SPAN_ATTR_PREFIX}transfer.guard.count`,
/** The transfer direction: `inbound` for an upload to the sandbox, `outbound`
* for a download from the sandbox. The parent span carries operation identity,
* so the transfer span never carries an operation label. The value stays in a
* closed set, so the attribute cardinality is bounded. */
transferDirection: `${SANDBOX_STARTUP_SPAN_ATTR_PREFIX}transfer.direction`,
} as const;
/** The closed value set for the `outcome` attribute. */
@@ -552,6 +552,62 @@ describe("command managed runtime", () => {
expect(native.syncOut).toBeTypeOf("function");
});
it("base64 fallback client reports allowConcurrentSyncOperations true", () => {
// A runner with no native sync uses the base64 fallback, which always
// permits concurrent sync operations.
const base = makeSpawnRunner().runner;
const client = createCommandManagedRuntimeClient({ runner: base, commandCwd: "/", timeoutMs: 1 });
expect(client.allowConcurrentSyncOperations).toBe(true);
});
it("undeclared native runner reports allowConcurrentSyncOperations false", () => {
// A native runner (both sync verbs) that never opted into concurrency keeps
// the flag off.
const base = makeSpawnRunner().runner;
const undeclaredNative: CommandManagedRuntimeRunner = {
...base,
syncIn: async () => ({ operations: [] }),
syncOut: async () => ({ operations: [] }),
};
const client = createCommandManagedRuntimeClient({
runner: undeclaredNative,
commandCwd: "/",
timeoutMs: 1,
});
expect(client.allowConcurrentSyncOperations).toBe(false);
});
it("declared native runner reports allowConcurrentSyncOperations true", () => {
// A native runner that verified the opt-in carries the flag through to the
// client.
const base = makeSpawnRunner().runner;
const declaredNative: CommandManagedRuntimeRunner = {
...base,
allowConcurrentSyncOperations: true,
syncIn: async () => ({ operations: [] }),
syncOut: async () => ({ operations: [] }),
};
const client = createCommandManagedRuntimeClient({
runner: declaredNative,
commandCwd: "/",
timeoutMs: 1,
});
expect(client.allowConcurrentSyncOperations).toBe(true);
});
it("base64 fallback ignores a runner opt-in without both sync verbs", () => {
// A runner that sets the opt-in but exposes only one sync verb still uses
// the fallback, which permits concurrency independent of the runner flag.
const base = makeSpawnRunner().runner;
const onlyIn: CommandManagedRuntimeRunner = {
...base,
allowConcurrentSyncOperations: true,
syncIn: async () => ({ operations: [] }),
};
const client = createCommandManagedRuntimeClient({ runner: onlyIn, commandCwd: "/", timeoutMs: 1 });
expect(client.allowConcurrentSyncOperations).toBe(true);
});
it("test_client_syncIn_delegates_to_native_runner_with_zero_execute_calls", async () => {
// With a native runner, `client.syncIn` forwards `files` + `postUploadCommands`
// to the runner and issues ZERO `execute` round-trips (the provider owns the
@@ -18,7 +18,45 @@ import type { RunProcessResult } from "./server-utils.js";
import type { RuntimeProgressSink, RuntimeStatusSink } from "./runtime-progress.js";
import type { RuntimeSpanRunner } from "./acpx-engine/startup-timing.js";
/**
* Input for a duplex channel open. The caller supplies only the command line
* the sandbox runs as the channel child process. The runner adds the lease
* scope from its own closure. This type is separate from the worker manager's
* `DuplexChannelOpenInput`, which also carries the lease scope fields.
*/
export interface DuplexChannelOpenInput {
command: string;
}
/**
* A persistent bidirectional channel to one long-lived command in the sandbox.
* The caller writes raw input bytes, reads streamed output, and stops or closes
* the channel. This is the cross-layer channel type: the runner returns it, and
* the sandbox driver adapts the worker manager's host session to it.
*/
export interface CommandManagedDuplexChannel {
/** Writes raw input bytes to the channel. */
write(data: string): void;
/** Registers the one data listener. The channel streams each raw chunk in order. */
onData(listener: (chunk: string) => void): void;
/** Registers the one exit listener. The channel calls it one time with the exit. */
onExit(listener: (exit: { exitCode: number | null }) => void): void;
/** Stops the child process. Safe to call more than one time. */
stop(): void;
/** Closes the channel and releases the route. Safe to call more than one time. */
close(): Promise<void>;
}
export interface CommandManagedRuntimeRunner {
/**
* True when the provider verified the concurrent-sync opt-in. A native runner
* carries the value from the effective capability snapshot
* (`concurrentSyncOperations`). The client copies it onto the prepared sync
* client only on the native path; the base64 fallback ignores it and always
* permits concurrency. The default is false, so an undeclared native provider
* never permits concurrent sync operations.
*/
allowConcurrentSyncOperations?: boolean;
/**
* True only when `execute({ stdin })` can surface useful in-flight progress
* for a single stdin-backed command. Provider-backed sandbox runners usually
@@ -67,6 +105,14 @@ export interface CommandManagedRuntimeRunner {
syncIn?(operations: SandboxSyncOperation[]): Promise<SandboxSyncResult>;
/** Optional native outbound file transfer. See {@link syncIn}. */
syncOut?(operations: SandboxSyncOperation[]): Promise<SandboxSyncResult>;
/**
* Optional persistent duplex channel. Present only when the sandbox provider's
* effective capability grants `duplexCommandStream`. The runner opens one
* bidirectional channel to a long-lived command in the sandbox. The SSH runner
* and every provider without the capability omit the member, so a caller gates
* on its presence in the same style as {@link syncIn}/{@link syncOut}.
*/
openDuplexChannel?(input: DuplexChannelOpenInput): Promise<CommandManagedDuplexChannel>;
}
export interface CommandManagedRuntimeSpec {
@@ -446,6 +492,13 @@ export function createCommandManagedRuntimeClient(input: {
const nativeSyncIn = input.runner.syncIn;
const nativeSyncOut = input.runner.syncOut;
const hasNativeBoth = Boolean(nativeSyncIn && nativeSyncOut);
// The base64 fallback always permits concurrent sync operations. A native
// runner permits them only when the provider verified the opt-in; an
// undeclared native provider keeps concurrency off. One flag serves both sync
// directions.
client.allowConcurrentSyncOperations = hasNativeBoth
? input.runner.allowConcurrentSyncOperations === true
: true;
client.syncIn = async (operations) => {
assertPostUploadCommandsConfined(operations);
if (hasNativeBoth) {
@@ -0,0 +1,201 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
DEFAULT_MAX_DUPLEX_FRAME_BYTES,
DUPLEX_FRAME_VERSION,
DuplexFrameDecoder,
decodeDuplexLine,
encodeDuplexFrame,
type DuplexDecodeResult,
type DuplexFrame,
} from "./duplex-frame-codec.js";
// One expected decode result in the fixture: either a decoded frame or the code
// of a protocol error. The codec must never throw on the read path.
type ExpectedResult = { frame: DuplexFrame } | { error: string };
interface Vector {
name: string;
category: "valid" | "invalid" | "partial" | "oversized" | "versionMismatch";
bytes: string;
splitByteOffsets?: number[];
maxFrameBytes?: number;
roundTrip?: boolean;
expected: ExpectedResult[];
}
interface Fixture {
frameVersion: number;
defaultMaxFrameBytes: number;
vectors: Vector[];
}
const fixturePath = fileURLToPath(
new URL("./duplex-frame-vectors.json", import.meta.url),
);
const fixture = JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture;
// Cut one UTF-8 byte stream into chunks at the byte offsets. The decoder must
// keep partial bytes between chunks, so the split can fall inside a multi-byte
// character.
function toChunks(bytes: string, offsets: number[] | undefined): Buffer[] {
const buffer = Buffer.from(bytes, "utf8");
if (!offsets || offsets.length === 0) return [buffer];
const bounds = [0, ...offsets, buffer.length];
const chunks: Buffer[] = [];
for (let i = 0; i < bounds.length - 1; i += 1) {
chunks.push(buffer.subarray(bounds[i], bounds[i + 1]));
}
return chunks;
}
function pushAll(decoder: DuplexFrameDecoder, chunks: Buffer[]): DuplexDecodeResult[] {
const results: DuplexDecodeResult[] = [];
for (const chunk of chunks) results.push(...decoder.push(chunk));
return results;
}
function assertMatches(results: DuplexDecodeResult[], expected: ExpectedResult[]): void {
expect(results).toHaveLength(expected.length);
results.forEach((result, index) => {
const want = expected[index];
if ("frame" in want) {
expect(result.ok).toBe(true);
if (result.ok) expect(result.frame).toEqual(want.frame);
} else {
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.code).toBe(want.error);
}
});
}
describe("duplex frame codec fixture", () => {
it("the fixture version matches the codec version", () => {
expect(fixture.frameVersion).toBe(DUPLEX_FRAME_VERSION);
expect(fixture.defaultMaxFrameBytes).toBe(DEFAULT_MAX_DUPLEX_FRAME_BYTES);
});
it("every category has at least one vector", () => {
const categories = new Set(fixture.vectors.map((vector) => vector.category));
for (const category of ["valid", "invalid", "partial", "oversized", "versionMismatch"]) {
expect(categories).toContain(category);
}
});
for (const vector of fixture.vectors) {
it(`decodes the ${vector.name} vector`, () => {
const decoder = new DuplexFrameDecoder(
vector.maxFrameBytes ? { maxFrameBytes: vector.maxFrameBytes } : undefined,
);
const chunks = toChunks(vector.bytes, vector.splitByteOffsets);
const results = pushAll(decoder, chunks);
assertMatches(results, vector.expected);
});
}
});
describe("round-trip", () => {
const validVectors = fixture.vectors.filter((vector) => vector.roundTrip);
it("has round-trip vectors for every frame type", () => {
const types = new Set(
validVectors.flatMap((vector) =>
vector.expected.flatMap((result) =>
"frame" in result ? [result.frame.type] : [],
),
),
);
for (const type of ["request", "response", "ready", "heartbeat", "close", "error"]) {
expect(types).toContain(type);
}
});
for (const vector of validVectors) {
it(`re-encodes the ${vector.name} frame to the same value`, () => {
const want = vector.expected[0];
expect("frame" in want).toBe(true);
if (!("frame" in want)) return;
const encoded = encodeDuplexFrame(want.frame);
// Encode writes exactly one line: it ends with one newline and holds no
// interior newline, so one frame stays on one line.
expect(encoded.endsWith("\n")).toBe(true);
expect(encoded.slice(0, -1)).not.toContain("\n");
const decoded = decodeDuplexLine(encoded.slice(0, -1));
expect(decoded.ok).toBe(true);
if (decoded.ok) expect(decoded.frame).toEqual(want.frame);
});
}
});
describe("streaming decoder behavior", () => {
it("emits nothing until a full line arrives, then the complete frame", () => {
const decoder = new DuplexFrameDecoder();
const line = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" });
const bytes = Buffer.from(line, "utf8");
const first = decoder.push(bytes.subarray(0, 3));
expect(first).toHaveLength(0);
const second = decoder.push(bytes.subarray(3));
expect(second).toHaveLength(1);
expect(second[0].ok).toBe(true);
});
it("keeps a multi-byte UTF-8 sequence valid across a chunk boundary", () => {
const decoder = new DuplexFrameDecoder();
const frame: DuplexFrame = {
version: DUPLEX_FRAME_VERSION,
type: "request",
id: "req-emoji",
method: "POST",
path: "/x",
query: "",
headers: {},
body: "😀",
};
const bytes = Buffer.from(encodeDuplexFrame(frame), "utf8");
// Split inside the four-byte emoji, right before a continuation byte.
let cut = -1;
for (let i = 1; i < bytes.length; i += 1) {
if ((bytes[i] & 0xc0) === 0x80) {
cut = i;
break;
}
}
expect(cut).toBeGreaterThan(0);
const results = [
...decoder.push(bytes.subarray(0, cut)),
...decoder.push(bytes.subarray(cut)),
];
expect(results).toHaveLength(1);
expect(results[0].ok).toBe(true);
if (results[0].ok) expect(results[0].frame).toEqual(frame);
});
it("rejects an oversized frame with a protocol error, then resynchronizes", () => {
const decoder = new DuplexFrameDecoder({ maxFrameBytes: 32 });
const flood = `${"z".repeat(100)}\n`;
const good = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" });
const results = decoder.push(Buffer.from(flood + good, "utf8"));
expect(results).toHaveLength(2);
expect(results[0].ok).toBe(false);
if (!results[0].ok) expect(results[0].error.code).toBe("frame_too_large");
expect(results[1].ok).toBe(true);
});
it("rejects a version-mismatch frame with a protocol error", () => {
const decoder = new DuplexFrameDecoder();
const results = decoder.push(
Buffer.from(`${JSON.stringify({ version: 999, type: "heartbeat" })}\n`, "utf8"),
);
expect(results).toHaveLength(1);
expect(results[0].ok).toBe(false);
if (!results[0].ok) expect(results[0].error.code).toBe("version_mismatch");
});
it("never throws on a malformed read; it returns a protocol error", () => {
const decoder = new DuplexFrameDecoder();
expect(() => decoder.push(Buffer.from("this is not json\n", "utf8"))).not.toThrow();
const results = decoder.push(Buffer.from("still not json\n", "utf8"));
expect(results[0].ok).toBe(false);
});
});
@@ -0,0 +1,338 @@
/**
* Versioned frame codec for the sandbox duplex channel.
*
* The channel carries newline-delimited JSON frames. One frame is one line. The
* host and the generated gateway each hold a copy of this codec. A shared
* fixture file (`duplex-frame-vectors.json`) proves the two copies stay wire
* compatible: both copies decode the same bytes to the same frames.
*
* The codec has two sides:
* - encode: turn a frame object into one line of JSON with a trailing newline.
* - decode: turn a byte stream into frames. The streaming decoder keeps partial
* bytes between chunks, so a frame split across chunks and a multi-byte UTF-8
* sequence split across chunks both decode correctly.
*
* The decoder never throws on the read path. A malformed, oversized, or
* version-mismatch frame becomes a protocol-error result, not an exception. This
* keeps one bad frame from crashing the read loop.
*/
/** The wire version this codec reads and writes. */
export const DUPLEX_FRAME_VERSION = 1;
/**
* The default maximum size of one frame, in bytes. The decoder rejects a longer
* frame with a `frame_too_large` protocol error. The value matches the per-chunk
* character bound of the host duplex route.
*/
export const DEFAULT_MAX_DUPLEX_FRAME_BYTES = 1_000_000;
const NEWLINE_BYTE = 0x0a;
const EMPTY = Buffer.alloc(0);
/** The frame type strings. One value goes in the `type` field of every frame. */
export const DUPLEX_FRAME_TYPES = {
request: "request",
response: "response",
ready: "ready",
heartbeat: "heartbeat",
close: "close",
error: "error",
} as const;
/** The outcome of a response frame. A loss response carries a non-completed outcome. */
export type DuplexResponseOutcome = "completed" | "indeterminate" | "unavailable";
/** A request frame. The gateway forwards it to the host API path. */
export interface DuplexRequestFrame {
version: number;
type: "request";
id: string;
method: string;
path: string;
query: string;
headers: Record<string, string>;
body: string;
}
/** A response frame. The host returns it for one request id. */
export interface DuplexResponseFrame {
version: number;
type: "response";
id: string;
status: number;
headers: Record<string, string>;
body: string;
outcome: DuplexResponseOutcome;
}
/**
* The READY control frame. The gateway sends it one time after it validates its
* local listener address. The `address` field carries that validated address.
*/
export interface DuplexReadyFrame {
version: number;
type: "ready";
address: string;
}
/** The heartbeat control frame. Each side sends it on an interval to prove liveness. */
export interface DuplexHeartbeatFrame {
version: number;
type: "heartbeat";
}
/** The orderly close control frame. A side sends it to end the channel cleanly. */
export interface DuplexCloseFrame {
version: number;
type: "close";
}
/**
* The protocol-error control frame. A peer sends it to report a bad frame. This
* frame is distinct from a decode-time protocol error: the decoder produces a
* {@link DuplexProtocolError} result, while a peer sends this frame on the wire.
*/
export interface DuplexErrorFrame {
version: number;
type: "error";
code: string;
message?: string;
}
/** Any frame the codec reads or writes. */
export type DuplexFrame =
| DuplexRequestFrame
| DuplexResponseFrame
| DuplexReadyFrame
| DuplexHeartbeatFrame
| DuplexCloseFrame
| DuplexErrorFrame;
/** The reason the decoder rejected one line. */
export type DuplexProtocolErrorCode =
| "malformed_frame"
| "unknown_type"
| "version_mismatch"
| "frame_too_large";
/** A decode-time protocol error. The read path returns it; it never throws. */
export interface DuplexProtocolError {
code: DuplexProtocolErrorCode;
message: string;
}
/** One decode result: a valid frame, or a protocol error. */
export type DuplexDecodeResult =
| { ok: true; frame: DuplexFrame }
| { ok: false; error: DuplexProtocolError };
const RESPONSE_OUTCOMES: ReadonlySet<string> = new Set<DuplexResponseOutcome>([
"completed",
"indeterminate",
"unavailable",
]);
function ok(frame: DuplexFrame): DuplexDecodeResult {
return { ok: true, frame };
}
function fail(code: DuplexProtocolErrorCode, message: string): DuplexDecodeResult {
return { ok: false, error: { code, message } };
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isStringRecord(value: unknown): value is Record<string, string> {
if (!isPlainObject(value)) return false;
for (const entry of Object.values(value)) {
if (typeof entry !== "string") return false;
}
return true;
}
/**
* Encode one frame to a single line of JSON with a trailing newline. `JSON.stringify`
* escapes any newline inside a string value, so the returned line holds no
* interior newline. This keeps one frame on one line.
*/
export function encodeDuplexFrame(frame: DuplexFrame): string {
return `${JSON.stringify(frame)}\n`;
}
/**
* Decode one line (no trailing newline) to a frame or a protocol error. The
* streaming decoder calls this for each complete line. It is exported so a
* caller with its own line splitter can reuse the same validation.
*
* The check order matters. A parseable frame with the wrong version becomes a
* `version_mismatch`, so the version check runs before the type check.
*/
export function decodeDuplexLine(line: string | Buffer): DuplexDecodeResult {
const text = typeof line === "string" ? line : line.toString("utf8");
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return fail("malformed_frame", "frame is not valid JSON");
}
if (!isPlainObject(parsed)) {
return fail("malformed_frame", "frame is not a JSON object");
}
if (parsed.version !== DUPLEX_FRAME_VERSION) {
return fail(
"version_mismatch",
`frame version ${String(parsed.version)} is not ${DUPLEX_FRAME_VERSION}`,
);
}
return validateFrame(parsed);
}
function validateFrame(frame: Record<string, unknown>): DuplexDecodeResult {
switch (frame.type) {
case "request":
return validateRequest(frame);
case "response":
return validateResponse(frame);
case "ready":
return validateReady(frame);
case "heartbeat":
return validateHeartbeat(frame);
case "close":
return validateClose(frame);
case "error":
return validateError(frame);
default:
return fail("unknown_type", `unknown frame type ${JSON.stringify(frame.type)}`);
}
}
function validateRequest(frame: Record<string, unknown>): DuplexDecodeResult {
if (
typeof frame.id !== "string" ||
typeof frame.method !== "string" ||
typeof frame.path !== "string" ||
typeof frame.query !== "string" ||
typeof frame.body !== "string" ||
!isStringRecord(frame.headers)
) {
return fail("malformed_frame", "request frame has a missing or wrong-typed field");
}
return ok(frame as unknown as DuplexRequestFrame);
}
function validateResponse(frame: Record<string, unknown>): DuplexDecodeResult {
if (
typeof frame.id !== "string" ||
typeof frame.status !== "number" ||
typeof frame.body !== "string" ||
!isStringRecord(frame.headers) ||
typeof frame.outcome !== "string" ||
!RESPONSE_OUTCOMES.has(frame.outcome)
) {
return fail("malformed_frame", "response frame has a missing or wrong-typed field");
}
return ok(frame as unknown as DuplexResponseFrame);
}
function validateReady(frame: Record<string, unknown>): DuplexDecodeResult {
if (typeof frame.address !== "string") {
return fail("malformed_frame", "ready frame has a missing or wrong-typed address");
}
return ok(frame as unknown as DuplexReadyFrame);
}
function validateHeartbeat(frame: Record<string, unknown>): DuplexDecodeResult {
return ok(frame as unknown as DuplexHeartbeatFrame);
}
function validateClose(frame: Record<string, unknown>): DuplexDecodeResult {
return ok(frame as unknown as DuplexCloseFrame);
}
function validateError(frame: Record<string, unknown>): DuplexDecodeResult {
if (typeof frame.code !== "string") {
return fail("malformed_frame", "error frame has a missing or wrong-typed code");
}
if (frame.message !== undefined && typeof frame.message !== "string") {
return fail("malformed_frame", "error frame has a wrong-typed message");
}
return ok(frame as unknown as DuplexErrorFrame);
}
/** Options for a {@link DuplexFrameDecoder}. */
export interface DuplexFrameDecoderOptions {
/** The maximum size of one frame, in bytes. Defaults to {@link DEFAULT_MAX_DUPLEX_FRAME_BYTES}. */
maxFrameBytes?: number;
}
/**
* A streaming decoder for a byte stream of newline-delimited JSON frames.
*
* Call `push` with each chunk. The decoder keeps the bytes of an incomplete
* frame between calls, so a frame that spans two chunks decodes on the chunk
* that completes it. The decoder buffers raw bytes and decodes UTF-8 only on a
* complete line, so a multi-byte sequence split across chunks stays valid. The
* newline byte `0x0A` never appears inside a multi-byte UTF-8 sequence, so a
* split on that byte is always safe.
*
* The decoder enforces the maximum frame size. It rejects an oversized frame
* with a `frame_too_large` protocol error, then discards bytes up to the next
* newline to resynchronize. It never throws on the read path.
*/
export class DuplexFrameDecoder {
private buffer: Buffer = EMPTY;
private discarding = false;
private readonly maxFrameBytes: number;
constructor(options: DuplexFrameDecoderOptions = {}) {
this.maxFrameBytes = options.maxFrameBytes ?? DEFAULT_MAX_DUPLEX_FRAME_BYTES;
}
/** Feed one chunk. Return the frames and protocol errors that complete on it. */
push(chunk: Buffer | Uint8Array | string): DuplexDecodeResult[] {
const incoming =
typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk);
this.buffer =
this.buffer.length === 0 ? incoming : Buffer.concat([this.buffer, incoming]);
const results: DuplexDecodeResult[] = [];
for (;;) {
if (this.discarding) {
// Drop the tail of an oversized frame until the next newline.
const newlineIndex = this.buffer.indexOf(NEWLINE_BYTE);
if (newlineIndex === -1) {
this.buffer = EMPTY;
break;
}
this.buffer = this.buffer.subarray(newlineIndex + 1);
this.discarding = false;
continue;
}
const newlineIndex = this.buffer.indexOf(NEWLINE_BYTE);
if (newlineIndex === -1) {
// No complete line yet. Reject an incomplete frame that already passed
// the size bound, then resynchronize at the next newline.
if (this.buffer.length > this.maxFrameBytes) {
results.push(fail("frame_too_large", "frame exceeds the maximum size"));
this.discarding = true;
this.buffer = EMPTY;
}
break;
}
const line = this.buffer.subarray(0, newlineIndex);
this.buffer = this.buffer.subarray(newlineIndex + 1);
if (line.length === 0) continue; // Skip a blank line.
if (line.length > this.maxFrameBytes) {
results.push(fail("frame_too_large", "frame exceeds the maximum size"));
continue;
}
results.push(decodeDuplexLine(line));
}
return results;
}
}
@@ -0,0 +1,358 @@
{
"frameVersion": 1,
"defaultMaxFrameBytes": 1000000,
"description": "Shared wire-compatibility vectors for the duplex frame codec. Every codec copy decodes the same bytes. bytes is a UTF-8 byte stream; splitByteOffsets cuts it into chunks; expected lists the ordered decode results (frame or protocol-error code).",
"vectors": [
{
"name": "valid-request",
"category": "valid",
"bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 1,
"type": "request",
"id": "req-1",
"method": "GET",
"path": "/api/issues/PAP-1",
"query": "expand=comments",
"headers": {
"accept": "application/json",
"content-type": "application/json"
},
"body": ""
}
}
]
},
{
"name": "valid-response-completed",
"category": "valid",
"bytes": "{\"version\":1,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"ok\\\":true}\",\"outcome\":\"completed\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 1,
"type": "response",
"id": "req-1",
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"ok\":true}",
"outcome": "completed"
}
}
]
},
{
"name": "valid-response-indeterminate",
"category": "valid",
"bytes": "{\"version\":1,\"type\":\"response\",\"id\":\"req-2\",\"status\":409,\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"error\\\":\\\"outcome_indeterminate\\\"}\",\"outcome\":\"indeterminate\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 1,
"type": "response",
"id": "req-2",
"status": 409,
"headers": {
"content-type": "application/json"
},
"body": "{\"error\":\"outcome_indeterminate\"}",
"outcome": "indeterminate"
}
}
]
},
{
"name": "valid-response-unavailable",
"category": "valid",
"bytes": "{\"version\":1,\"type\":\"response\",\"id\":\"req-3\",\"status\":503,\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"error\\\":\\\"bridge_unavailable\\\"}\",\"outcome\":\"unavailable\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 1,
"type": "response",
"id": "req-3",
"status": 503,
"headers": {
"content-type": "application/json"
},
"body": "{\"error\":\"bridge_unavailable\"}",
"outcome": "unavailable"
}
}
]
},
{
"name": "valid-ready",
"category": "valid",
"bytes": "{\"version\":1,\"type\":\"ready\",\"address\":\"127.0.0.1:47215\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 1,
"type": "ready",
"address": "127.0.0.1:47215"
}
}
]
},
{
"name": "valid-heartbeat",
"category": "valid",
"bytes": "{\"version\":1,\"type\":\"heartbeat\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 1,
"type": "heartbeat"
}
}
]
},
{
"name": "valid-close",
"category": "valid",
"bytes": "{\"version\":1,\"type\":\"close\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 1,
"type": "close"
}
}
]
},
{
"name": "valid-error",
"category": "valid",
"bytes": "{\"version\":1,\"type\":\"error\",\"code\":\"malformed_frame\",\"message\":\"peer reported a malformed frame\"}\n",
"roundTrip": true,
"expected": [
{
"frame": {
"version": 1,
"type": "error",
"code": "malformed_frame",
"message": "peer reported a malformed frame"
}
}
]
},
{
"name": "invalid-not-json",
"category": "invalid",
"bytes": "{not valid json\n",
"expected": [
{
"error": "malformed_frame"
}
]
},
{
"name": "invalid-json-array",
"category": "invalid",
"bytes": "[1,2,3]\n",
"expected": [
{
"error": "malformed_frame"
}
]
},
{
"name": "invalid-json-number",
"category": "invalid",
"bytes": "42\n",
"expected": [
{
"error": "malformed_frame"
}
]
},
{
"name": "invalid-unknown-type",
"category": "invalid",
"bytes": "{\"version\":1,\"type\":\"teleport\"}\n",
"expected": [
{
"error": "unknown_type"
}
]
},
{
"name": "invalid-missing-id",
"category": "invalid",
"bytes": "{\"version\":1,\"type\":\"request\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"body\":\"\"}\n",
"expected": [
{
"error": "malformed_frame"
}
]
},
{
"name": "invalid-bad-headers",
"category": "invalid",
"bytes": "{\"version\":1,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"accept\":7},\"body\":\"{\\\"ok\\\":true}\",\"outcome\":\"completed\"}\n",
"expected": [
{
"error": "malformed_frame"
}
]
},
{
"name": "partial-request-split",
"category": "partial",
"bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"\"}\n",
"splitByteOffsets": [
98
],
"expected": [
{
"frame": {
"version": 1,
"type": "request",
"id": "req-1",
"method": "GET",
"path": "/api/issues/PAP-1",
"query": "expand=comments",
"headers": {
"accept": "application/json",
"content-type": "application/json"
},
"body": ""
}
}
]
},
{
"name": "partial-utf8-split",
"category": "partial",
"bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-utf8\",\"method\":\"POST\",\"path\":\"/api/issues/PAP-1/comments\",\"query\":\"\",\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"text\\\":\\\"héllo 😀 wörld\\\"}\"}\n",
"splitByteOffsets": [
177
],
"expected": [
{
"frame": {
"version": 1,
"type": "request",
"id": "req-utf8",
"method": "POST",
"path": "/api/issues/PAP-1/comments",
"query": "",
"headers": {
"content-type": "application/json"
},
"body": "{\"text\":\"héllo 😀 wörld\"}"
}
}
]
},
{
"name": "partial-two-frames",
"category": "partial",
"bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"\"}\n{\"version\":1,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"ok\\\":true}\",\"outcome\":\"completed\"}\n",
"splitByteOffsets": [
7,
199,
343
],
"expected": [
{
"frame": {
"version": 1,
"type": "request",
"id": "req-1",
"method": "GET",
"path": "/api/issues/PAP-1",
"query": "expand=comments",
"headers": {
"accept": "application/json",
"content-type": "application/json"
},
"body": ""
}
},
{
"frame": {
"version": 1,
"type": "response",
"id": "req-1",
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"ok\":true}",
"outcome": "completed"
}
}
]
},
{
"name": "partial-no-trailing-newline",
"category": "partial",
"bytes": "{\"version\":1,\"type\":\"heartbeat\"}",
"expected": []
},
{
"name": "oversized-complete-line",
"category": "oversized",
"maxFrameBytes": 64,
"bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"}\n",
"expected": [
{
"error": "frame_too_large"
}
]
},
{
"name": "oversized-flood-then-resync",
"category": "oversized",
"maxFrameBytes": 64,
"splitByteOffsets": [
40
],
"bytes": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy\n{\"version\":1,\"type\":\"heartbeat\"}\n",
"expected": [
{
"error": "frame_too_large"
},
{
"frame": {
"version": 1,
"type": "heartbeat"
}
}
]
},
{
"name": "version-mismatch-higher",
"category": "versionMismatch",
"bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"\"}\n",
"expected": [
{
"error": "version_mismatch"
}
]
},
{
"name": "version-mismatch-missing",
"category": "versionMismatch",
"bytes": "{\"type\":\"heartbeat\"}\n",
"expected": [
{
"error": "version_mismatch"
}
]
}
]
}
@@ -115,6 +115,8 @@ export interface EffectiveSandboxCapabilities {
readonly persistentProcessSessions: boolean;
readonly independentControlCommands: boolean;
readonly incrementalSessionOutput: boolean;
readonly concurrentSyncOperations: boolean;
readonly duplexCommandStream: boolean;
}
export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWorkspaceMetadata {
@@ -254,6 +256,8 @@ function parseEffectiveSandboxCapabilities(value: unknown): EffectiveSandboxCapa
persistentProcessSessions: parsed.persistentProcessSessions === true,
independentControlCommands: parsed.independentControlCommands === true,
incrementalSessionOutput: parsed.incrementalSessionOutput === true,
concurrentSyncOperations: parsed.concurrentSyncOperations === true,
duplexCommandStream: parsed.duplexCommandStream === true,
};
}
@@ -11,6 +11,7 @@ import {
createRemoteGitExportRef,
deleteLocalGitRef,
fetchGitBundleIntoLocalRef,
integrateImportedGitHead,
isMissingGitPrerequisiteError,
readGitWorkspaceSnapshot,
runLocalGit,
@@ -422,6 +423,117 @@ describe("git workspace sync", () => {
await deleteLocalGitRef({ localDir: host, ref: importedRef });
}
});
it("creates the concurrent-history merge commit with a deterministic identity", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-merge-identity-"));
cleanupDirs.push(rootDir);
// No repo-local user.name/user.email on purpose: execution hosts are
// containers without git config, where commit-tree cannot auto-detect an
// identity. Setup commits pass their identity inline so only the merge
// commit under test depends on the sync-supplied identity.
const setupIdentity = ["-c", "user.name=Setup", "-c", "user.email=setup@paperclip.dev"];
const repo = path.join(rootDir, "repo");
await mkdir(repo, { recursive: true });
await git(repo, ["init"]);
await git(repo, ["checkout", "-b", "main"]);
await writeFile(path.join(repo, "tracked.txt"), "base\n", "utf8");
await git(repo, ["add", "tracked.txt"]);
await git(repo, [...setupIdentity, "commit", "-m", "base"]);
const baseHead = await git(repo, ["rev-parse", "HEAD"]);
await writeFile(path.join(repo, "local.txt"), "local\n", "utf8");
await git(repo, ["add", "local.txt"]);
await git(repo, [...setupIdentity, "commit", "-m", "local advance"]);
const currentHead = await git(repo, ["rev-parse", "HEAD"]);
await git(repo, ["checkout", "-b", "imported", baseHead]);
await writeFile(path.join(repo, "imported.txt"), "imported\n", "utf8");
await git(repo, ["add", "imported.txt"]);
await git(repo, [...setupIdentity, "commit", "-m", "sandbox change"]);
const importedHead = await git(repo, ["rev-parse", "HEAD"]);
await git(repo, ["checkout", "main"]);
// Ambient identity env vars would override the `-c` flags and make the
// assertion machine-dependent, so clear them for the call under test.
const identityEnvKeys = ["GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "EMAIL"];
const savedEnv = new Map(identityEnvKeys.map((key) => [key, process.env[key]]));
for (const key of identityEnvKeys) delete process.env[key];
try {
await integrateImportedGitHead({ localDir: repo, importedHead });
} finally {
for (const [key, value] of savedEnv) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
const parents = (await git(repo, ["rev-list", "--parents", "-1", "HEAD"])).split(" ");
expect(parents.slice(1)).toEqual([currentHead, importedHead]);
expect(await git(repo, ["log", "-1", "--format=%an|%ae|%cn|%ce"]))
.toBe("Paperclip|noreply@paperclip.ing|Paperclip|noreply@paperclip.ing");
expect(await git(repo, ["log", "-1", "--format=%s"]))
.toBe(`Paperclip remote git sync merge ${importedHead.slice(0, 12)}`);
const mergedTree = await git(repo, ["ls-tree", "--name-only", "HEAD"]);
expect(mergedTree).toContain("local.txt");
expect(mergedTree).toContain("imported.txt");
});
it("grafts an imported head onto the current head when histories share no ancestor", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-graft-"));
cleanupDirs.push(rootDir);
const setupIdentity = ["-c", "user.name=Setup", "-c", "user.email=setup@paperclip.dev"];
const repo = path.join(rootDir, "repo");
await mkdir(repo, { recursive: true });
await git(repo, ["init"]);
await git(repo, ["checkout", "-b", "main"]);
await writeFile(path.join(repo, "tracked.txt"), "base\n", "utf8");
await git(repo, ["add", "tracked.txt"]);
await git(repo, [...setupIdentity, "commit", "-m", "base"]);
const baseHead = await git(repo, ["rev-parse", "HEAD"]);
await writeFile(path.join(repo, "local.txt"), "local\n", "utf8");
await git(repo, ["add", "local.txt"]);
await git(repo, [...setupIdentity, "commit", "-m", "local advance"]);
const currentHead = await git(repo, ["rev-parse", "HEAD"]);
// The shape a depth-1 shallow clone produces after `git commit --amend`:
// a parentless root commit that shares no ancestor with the host history.
const importedTree = await git(repo, ["rev-parse", `${baseHead}^{tree}`]);
const importedHead = await git(repo, [...setupIdentity, "commit-tree", importedTree, "-m", "sandbox rewrite"]);
await integrateImportedGitHead({ localDir: repo, importedHead });
const parents = (await git(repo, ["rev-list", "--parents", "-1", "HEAD"])).split(" ");
expect(parents.slice(1)).toEqual([currentHead]);
// The imported tree is taken wholesale: no base exists to merge against.
expect(await git(repo, ["rev-parse", "HEAD^{tree}"])).toBe(importedTree);
expect(await git(repo, ["log", "-1", "--format=%s"])).toBe("sandbox rewrite");
const body = await git(repo, ["log", "-1", "--format=%B"]);
expect(body).toContain(`Paperclip remote git sync graft ${importedHead.slice(0, 12)}`);
expect(body).toContain("shares no ancestor");
});
it("does not graft when merge-base fails for a reason other than missing ancestry", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-no-graft-"));
cleanupDirs.push(rootDir);
const setupIdentity = ["-c", "user.name=Setup", "-c", "user.email=setup@paperclip.dev"];
const repo = path.join(rootDir, "repo");
await mkdir(repo, { recursive: true });
await git(repo, ["init"]);
await git(repo, ["checkout", "-b", "main"]);
await writeFile(path.join(repo, "tracked.txt"), "base\n", "utf8");
await git(repo, ["add", "tracked.txt"]);
await git(repo, [...setupIdentity, "commit", "-m", "base"]);
const currentHead = await git(repo, ["rev-parse", "HEAD"]);
// A well-formed sha the repository does not hold: merge-base fails with an
// object error (exit 128), not the no-ancestor signal (exit 1). The graft
// must not fire, and the integration keeps its loud failure.
const missingHead = "0123456789abcdef0123456789abcdef01234567";
await expect(integrateImportedGitHead({ localDir: repo, importedHead: missingHead }))
.rejects.toThrow(/Failed to merge concurrent remote git histories/);
expect(await git(repo, ["rev-parse", "HEAD"])).toBe(currentHead);
});
});
describe("sanitizeGitRemoteUrl", () => {
@@ -42,6 +42,23 @@ export function setExpensiveWorkspaceGitExecutor(executor: ExpensiveWorkspaceGit
export const GIT_ARCHIVE_EXCLUDES = [".git", ".git/*"] as const;
/**
* Identity flags for commits the sync machinery itself creates (the merge
* commits that reconcile concurrent histories). Execution hosts are often
* containers with no git config and no resolvable hostname, so git cannot
* auto-detect an identity there and `commit-tree` hard-fails with "Author
* identity unknown" — which fails the whole run at finalize. Passing the
* identity per invocation keeps every deployment working without host
* configuration; `GIT_AUTHOR_*` / `GIT_COMMITTER_*` environment variables
* still take precedence over `-c` when an operator sets them.
*/
export const GIT_SYNC_COMMIT_IDENTITY_ARGS = [
"-c",
"user.name=Paperclip",
"-c",
"user.email=noreply@paperclip.ing",
] as const;
function shellQuote(value: string) {
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
}
@@ -416,6 +433,51 @@ export function buildRemoteGitDeltaBundleScript(input: {
].filter(Boolean).join("\n");
}
/**
* Preserve imported work whose history does not connect to the local one.
*
* The dominant real-world cause is a history rewrite inside a transported
* workspace: transported clones are depth-1 shallow, so the boundary commit
* reads as parentless there and `git commit --amend` rewrites it into a root
* commit that shares no ancestor with the host history. A tree merge is
* impossible without a common ancestor, and failing the integration would
* discard the run's work. Instead, squash-graft the imported tree onto the
* current head as a single commit that reuses the imported head's message,
* with a trailer recording the graft. Concurrent local-only commits keep
* their place in history as the graft's ancestry; the imported tree is taken
* wholesale because no base exists to merge against. The caller advances the
* branch ref to the returned commit.
*/
export async function createUnrelatedHistoryGraftCommit(input: {
localDir: string;
currentHead: string;
importedHead: string;
syncLabel: string;
}): Promise<string> {
const importedTree = (await runLocalGit(input.localDir, ["rev-parse", `${input.importedHead}^{tree}`], {
timeout: 10_000,
maxBuffer: 16 * 1024,
})).stdout.trim();
const importedMessage = (await runLocalGit(input.localDir, ["log", "-1", "--format=%B", input.importedHead], {
timeout: 10_000,
maxBuffer: 256 * 1024,
})).stdout;
const message = [
importedMessage.trim(),
"",
`(${input.syncLabel} graft ${input.importedHead.slice(0, 12)}: imported history shares no ancestor with ${input.currentHead.slice(0, 12)})`,
].join("\n");
const graftCommit = await runLocalGit(
input.localDir,
[...GIT_SYNC_COMMIT_IDENTITY_ARGS, "commit-tree", importedTree, "-p", input.currentHead, "-m", message],
{
timeout: 60_000,
maxBuffer: 64 * 1024,
},
);
return graftCommit.stdout.trim();
}
export async function integrateImportedGitHead(input: {
localDir: string;
importedHead: string;
@@ -433,10 +495,18 @@ export async function integrateImportedGitHead(input: {
if (!currentHead || currentHead === input.importedHead) return;
const headRef = snapshot.branchName ? `refs/heads/${snapshot.branchName}` : "HEAD";
// `git merge-base` exits 1 when the commits share no ancestor — the only
// outcome that authorizes the graft fallback below. Every other failure
// (timeout, missing object, repository error) must keep failing the
// integration instead of silently rewriting the tip.
let noCommonAncestor = false;
const mergeBase = await runLocalGit(input.localDir, ["merge-base", currentHead, input.importedHead], {
timeout: 10_000,
maxBuffer: 16 * 1024,
}).catch(() => null);
}).catch((error: unknown) => {
noCommonAncestor = (error as { code?: unknown } | null)?.code === 1;
return null;
});
const mergeBaseHead = mergeBase?.stdout.trim() ?? "";
if (mergeBaseHead === input.importedHead) {
@@ -456,6 +526,28 @@ export async function integrateImportedGitHead(input: {
}
}
if (noCommonAncestor) {
// No common ancestor — merging is impossible and failing here would
// discard the imported work. Graft it onto the current head instead;
// see createUnrelatedHistoryGraftCommit.
const graftCommit = await createUnrelatedHistoryGraftCommit({
localDir: input.localDir,
currentHead,
importedHead: input.importedHead,
syncLabel: "Paperclip remote git sync",
});
try {
await runLocalGit(input.localDir, ["update-ref", headRef, graftCommit, currentHead], {
timeout: 10_000,
maxBuffer: 16 * 1024,
});
return;
} catch (error) {
if (isConcurrentRefUpdateError(error) && attempt < 4) continue;
throw error;
}
}
let mergedTree;
try {
mergedTree = await runLocalGit(input.localDir, ["merge-tree", "--write-tree", currentHead, input.importedHead], {
@@ -476,6 +568,7 @@ export async function integrateImportedGitHead(input: {
const mergeCommit = await runLocalGit(
input.localDir,
[
...GIT_SYNC_COMMIT_IDENTITY_ARGS,
"commit-tree",
mergedTreeId,
"-p",
+26
View File
@@ -84,6 +84,32 @@ export type {
RuntimeStatusUpdate,
} from "./runtime-progress.js";
export { inferOpenAiCompatibleBiller } from "./billing.js";
export {
ADAPTER_LOGIN_PANEL_MODES,
ADAPTER_LOGIN_SANDBOX_TRANSPORTS,
ADAPTER_LOGIN_TIMEOUT_POLICIES,
ADAPTER_LOGIN_COMPLETION_CLAIMS,
assertValidAdapterLoginCapability,
validateAdapterLoginCapability,
} from "./login-capability.js";
export type {
AdapterLoginPanelMode,
AdapterLoginSandboxTransport,
AdapterLoginTimeoutPolicy,
AdapterLoginCompletionClaim,
AdapterLoginPrompt,
AdapterLoginCompletionContext,
AdapterLoginCapability,
} from "./login-capability.js";
export { raceLoginRunnerExit } from "./login-runner-lifecycle.js";
export type {
LoginRunnerOutcome,
LoginRunnerResult,
LoginRunnerLog,
LoginRunnerLifecycleOptions,
LoginRunnerDisposable,
LoginRunnerRaceResult,
} from "./login-runner-lifecycle.js";
// Keep the root adapter-utils entry browser-safe because the UI imports it.
// The sandbox callback bridge stays available via its dedicated subpath export.
export type {
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import {
assertValidAdapterLoginCapability,
validateAdapterLoginCapability,
type AdapterLoginCapability,
} from "./login-capability.js";
// A well-formed login capability. Each test starts from a clone of this value
// and then breaks one field, so each case checks exactly one rule.
function validCapability(): AdapterLoginCapability {
return {
panelMode: "displayed_code",
sandboxTransport: "streamed_exec",
timeoutPolicy: "caller_bounded",
getCommand: () => "vendor login",
parsePrompt: () => null,
};
}
describe("assertValidAdapterLoginCapability", () => {
it("accepts a well-formed capability with only the required fields", () => {
expect(() => assertValidAdapterLoginCapability(validCapability(), "vendor")).not.toThrow();
});
it("accepts a well-formed capability with every optional member set", () => {
const capability: AdapterLoginCapability = {
panelMode: "submitted_browser_code",
sandboxTransport: "pseudo_terminal",
timeoutPolicy: "fixed",
getCommand: () => "vendor setup-token",
parsePrompt: () => null,
captureCredential: () => null,
onComplete: async () => {},
completionClaim: "storedSessionId",
};
expect(() => assertValidAdapterLoginCapability(capability, "vendor")).not.toThrow();
});
it("rejects a non-object capability", () => {
expect(() => assertValidAdapterLoginCapability(null, "vendor")).toThrow(/must be an object/);
expect(() => assertValidAdapterLoginCapability("displayed_code", "vendor")).toThrow(
/must be an object/,
);
});
it("rejects an unknown panelMode", () => {
const bad = { ...validCapability(), panelMode: "hidden_code" };
expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/panelMode/);
});
it("rejects an unknown sandboxTransport", () => {
const bad = { ...validCapability(), sandboxTransport: "http" };
expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/sandboxTransport/);
});
it("rejects an unknown timeoutPolicy", () => {
const bad = { ...validCapability(), timeoutPolicy: "unbounded" };
expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/timeoutPolicy/);
});
it("rejects a missing getCommand", () => {
const bad = { ...validCapability(), getCommand: "vendor login" };
expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/getCommand/);
});
it("rejects a missing parsePrompt", () => {
const bad = { ...validCapability(), parsePrompt: undefined };
expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/parsePrompt/);
});
it("rejects a non-function captureCredential", () => {
const bad = { ...validCapability(), captureCredential: "token" };
expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/captureCredential/);
});
it("rejects a non-function onComplete", () => {
const bad = { ...validCapability(), onComplete: true };
expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/onComplete/);
});
it("rejects an unknown completionClaim", () => {
const bad = { ...validCapability(), completionClaim: "storedToken" };
expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/completionClaim/);
});
it("names the adapter in the error text", () => {
const bad = { ...validCapability(), panelMode: "hidden_code" };
expect(() => assertValidAdapterLoginCapability(bad, "codex_local")).toThrow(/codex_local/);
});
});
describe("validateAdapterLoginCapability", () => {
it("accepts a module with no login capability", () => {
expect(() => validateAdapterLoginCapability({ type: "vendor_local" })).not.toThrow();
});
it("accepts a module with a well-formed login capability", () => {
expect(() =>
validateAdapterLoginCapability({ type: "vendor_local", loginCapability: validCapability() }),
).not.toThrow();
});
it("rejects a module with a malformed login capability", () => {
const bad = { ...validCapability(), panelMode: "hidden_code" };
expect(() =>
validateAdapterLoginCapability({ type: "vendor_local", loginCapability: bad }),
).toThrow(/vendor_local/);
});
});
@@ -0,0 +1,169 @@
// The adapter login capability. An adapter declares this optional capability to
// describe how the server drives an interactive sandbox login. The module holds
// the capability types, the fixed value sets, and one runtime validator. The
// validator fails closed: it rejects a malformed capability shape with a clear
// error, so the loader never accepts a partial capability.
//
// Security (secret handling): the capability data holds no secret. The scalar
// fields carry only a fixed, non-secret value. The function members are code,
// not data; a member can return a runtime secret (for example a minted token),
// but the capability object never stores a secret. API-key-only vendors declare
// no login capability.
/**
* The login panel mode. `displayed_code` shows a one-time code that the user
* enters in the browser. `submitted_browser_code` asks the user to paste a code
* from the browser back into the login flow.
*/
export const ADAPTER_LOGIN_PANEL_MODES = ["displayed_code", "submitted_browser_code"] as const;
export type AdapterLoginPanelMode = (typeof ADAPTER_LOGIN_PANEL_MODES)[number];
/**
* The sandbox transport that the login command needs. `streamed_exec` runs the
* command over the streamed exec channel. `pseudo_terminal` runs the command on
* a real pseudo-terminal, because a pipe emits no login prompt.
*/
export const ADAPTER_LOGIN_SANDBOX_TRANSPORTS = ["streamed_exec", "pseudo_terminal"] as const;
export type AdapterLoginSandboxTransport = (typeof ADAPTER_LOGIN_SANDBOX_TRANSPORTS)[number];
/**
* The host-side timeout policy. `caller_bounded` lets the caller set the
* timeout. `fixed` binds the timeout to a fixed adapter value.
*/
export const ADAPTER_LOGIN_TIMEOUT_POLICIES = ["caller_bounded", "fixed"] as const;
export type AdapterLoginTimeoutPolicy = (typeof ADAPTER_LOGIN_TIMEOUT_POLICIES)[number];
/**
* The optional completion claim that the login flow records on success.
* `storedSessionId` marks that the flow stored a session identifier.
*/
export const ADAPTER_LOGIN_COMPLETION_CLAIMS = ["storedSessionId"] as const;
export type AdapterLoginCompletionClaim = (typeof ADAPTER_LOGIN_COMPLETION_CLAIMS)[number];
/**
* The normalized login prompt. `url` is the validated authorization URL. `code`
* is the one-time code to show, present only in `displayed_code` mode. A prompt
* carries no credential secret.
*/
export interface AdapterLoginPrompt {
url: string;
code?: string;
}
/**
* The completion context. The server passes it to the completion hook. It
* carries the non-secret completion state. It carries no credential byte.
*/
export interface AdapterLoginCompletionContext {
/** The session identifier that the flow stored, when it stored one. */
storedSessionId?: string;
}
/**
* The optional adapter login capability. It declares how the server drives an
* interactive sandbox login for the adapter. The capability data holds no
* secret. An adapter with no interactive login (for example an API-key-only
* vendor) declares no capability.
*/
export interface AdapterLoginCapability {
/** The login panel mode. */
panelMode: AdapterLoginPanelMode;
/** The sandbox transport that the login command needs. */
sandboxTransport: AdapterLoginSandboxTransport;
/** The host-side timeout policy. */
timeoutPolicy: AdapterLoginTimeoutPolicy;
/** Returns the fixed, non-secret login command. */
getCommand: () => string;
/**
* Parses the authorization prompt from the login output. Returns null when the
* output holds no prompt yet. The parser keeps every input byte out of its
* result and out of every thrown error.
*/
parsePrompt: (output: string) => AdapterLoginPrompt | null;
/**
* Captures the minted credential from the login output. Returns the raw
* credential bytes, or null when the output holds no credential yet. Only a
* flow that prints the credential to the terminal sets it. The result is a
* runtime secret; the capability data never stores it.
*/
captureCredential?: (output: string) => Buffer | null;
/**
* Runs after a successful login. It records the non-secret completion state.
* It carries no credential byte.
*/
onComplete?: (ctx: AdapterLoginCompletionContext) => Promise<void>;
/** The optional completion claim that the flow records on success. */
completionClaim?: AdapterLoginCompletionClaim;
}
function isOneOf<T extends readonly string[]>(values: T, candidate: unknown): candidate is T[number] {
return typeof candidate === "string" && (values as readonly string[]).includes(candidate);
}
/**
* Validates one login capability. The function fails closed: it throws a clear
* error for a malformed shape. It checks each scalar field against its fixed
* value set, checks each required function member, and checks each optional
* member only when the member is present. `adapterType` names the adapter in the
* error text.
*/
export function assertValidAdapterLoginCapability(
value: unknown,
adapterType: string,
): asserts value is AdapterLoginCapability {
const prefix = `Adapter "${adapterType}" declares an invalid login capability`;
if (typeof value !== "object" || value === null) {
throw new Error(`${prefix}: the capability must be an object.`);
}
const cap = value as Record<string, unknown>;
if (!isOneOf(ADAPTER_LOGIN_PANEL_MODES, cap.panelMode)) {
throw new Error(
`${prefix}: "panelMode" must be one of ${ADAPTER_LOGIN_PANEL_MODES.join(", ")}.`,
);
}
if (!isOneOf(ADAPTER_LOGIN_SANDBOX_TRANSPORTS, cap.sandboxTransport)) {
throw new Error(
`${prefix}: "sandboxTransport" must be one of ${ADAPTER_LOGIN_SANDBOX_TRANSPORTS.join(", ")}.`,
);
}
if (!isOneOf(ADAPTER_LOGIN_TIMEOUT_POLICIES, cap.timeoutPolicy)) {
throw new Error(
`${prefix}: "timeoutPolicy" must be one of ${ADAPTER_LOGIN_TIMEOUT_POLICIES.join(", ")}.`,
);
}
if (typeof cap.getCommand !== "function") {
throw new Error(`${prefix}: "getCommand" must be a function.`);
}
if (typeof cap.parsePrompt !== "function") {
throw new Error(`${prefix}: "parsePrompt" must be a function.`);
}
if (cap.captureCredential !== undefined && typeof cap.captureCredential !== "function") {
throw new Error(`${prefix}: "captureCredential" must be a function when present.`);
}
if (cap.onComplete !== undefined && typeof cap.onComplete !== "function") {
throw new Error(`${prefix}: "onComplete" must be a function when present.`);
}
if (
cap.completionClaim !== undefined &&
!isOneOf(ADAPTER_LOGIN_COMPLETION_CLAIMS, cap.completionClaim)
) {
throw new Error(
`${prefix}: "completionClaim" must be one of ${ADAPTER_LOGIN_COMPLETION_CLAIMS.join(", ")} when present.`,
);
}
}
/**
* Validates the optional login capability of an adapter module. The function is
* a no-op when the module declares no login capability. It throws a clear error
* when the module declares a malformed capability, so the loader fails closed.
*/
export function validateAdapterLoginCapability(mod: {
type?: unknown;
loginCapability?: unknown;
}): void {
if (mod.loginCapability === undefined) return;
const adapterType = typeof mod.type === "string" && mod.type.length > 0 ? mod.type : "unknown";
assertValidAdapterLoginCapability(mod.loginCapability, adapterType);
}
@@ -0,0 +1,151 @@
import { describe, expect, it, vi } from "vitest";
import {
raceLoginRunnerExit,
type LoginRunnerDisposable,
type LoginRunnerOutcome,
type LoginRunnerRaceResult,
type LoginRunnerResult,
} from "./login-runner-lifecycle.js";
describe("LoginRunnerOutcome", () => {
it("names the four terminal outcomes", () => {
const outcomes: LoginRunnerOutcome[] = ["success", "failure", "timeout", "cancelled"];
expect(new Set(outcomes).size).toBe(4);
});
});
describe("LoginRunnerResult", () => {
it("carries the fixed status fields", () => {
const result: LoginRunnerResult = { outcome: "success", exitCode: 0, promptSurfaced: true };
expect(result).toEqual({ outcome: "success", exitCode: 0, promptSurfaced: true });
});
});
describe("raceLoginRunnerExit", () => {
it("resolves with the exit kind when the work resolves first", async () => {
const raced = await raceLoginRunnerExit(Promise.resolve({ exitCode: 0 }), 10_000, undefined);
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "exit", exitCode: 0 });
});
it("passes a non-zero exit code through", async () => {
const raced = await raceLoginRunnerExit(Promise.resolve({ exitCode: 7 }), 10_000, undefined);
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "exit", exitCode: 7 });
});
it("passes a null exit code through", async () => {
const raced = await raceLoginRunnerExit(Promise.resolve({ exitCode: null }), 10_000, undefined);
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "exit", exitCode: null });
});
it("resolves with the timeout kind when the work hangs past the timeout", async () => {
const hang = new Promise<{ exitCode: number | null }>(() => {});
const raced = await raceLoginRunnerExit(hang, 5, undefined);
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "timeout" });
});
it("resolves with the cancelled kind when the signal aborts", async () => {
const hang = new Promise<{ exitCode: number | null }>(() => {});
const controller = new AbortController();
const racePromise = raceLoginRunnerExit(hang, 10_000, controller.signal);
controller.abort();
expect(await racePromise).toEqual<LoginRunnerRaceResult>({ kind: "cancelled" });
});
it("resolves with the cancelled kind when the signal is already aborted", async () => {
const hang = new Promise<{ exitCode: number | null }>(() => {});
const controller = new AbortController();
controller.abort();
const raced = await raceLoginRunnerExit(hang, 10_000, controller.signal);
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "cancelled" });
});
it("returns cancelled for a pre-aborted signal even when the work can resolve", async () => {
const controller = new AbortController();
controller.abort();
const raced = await raceLoginRunnerExit(
Promise.resolve({ exitCode: 0 }),
10_000,
controller.signal,
);
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "cancelled" });
});
it("consumes a late work rejection after a pre-aborted signal cancels the race", async () => {
let rejectWork: (error: Error) => void = () => {};
const work = new Promise<{ exitCode: number | null }>((_resolve, reject) => {
rejectWork = reject;
});
const controller = new AbortController();
controller.abort();
const raced = await raceLoginRunnerExit(work, 10_000, controller.signal);
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "cancelled" });
// The pre-aborted signal already cancelled the race. The work then rejects.
// The helper consumes the late rejection, so it never becomes an unhandled
// rejection.
expect(() => rejectWork(new Error("late"))).not.toThrow();
await Promise.resolve();
});
it("rejects when the work rejects", async () => {
const error = new Error("the driver errored");
await expect(raceLoginRunnerExit(Promise.reject(error), 10_000, undefined)).rejects.toBe(error);
});
it("does not double-settle when the signal aborts after the exit", async () => {
const controller = new AbortController();
const raced = await raceLoginRunnerExit(
Promise.resolve({ exitCode: 0 }),
10_000,
controller.signal,
);
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "exit", exitCode: 0 });
// The race already settled. A late abort must not throw or change the result.
expect(() => controller.abort()).not.toThrow();
});
it("removes the abort listener after it settles on the exit", async () => {
const controller = new AbortController();
const removeSpy = vi.spyOn(controller.signal, "removeEventListener");
await raceLoginRunnerExit(Promise.resolve({ exitCode: 0 }), 10_000, controller.signal);
expect(removeSpy).toHaveBeenCalledWith("abort", expect.any(Function));
});
it("consumes a late work rejection after the timeout settles the race", async () => {
let rejectWork: (error: Error) => void = () => {};
const work = new Promise<{ exitCode: number | null }>((_resolve, reject) => {
rejectWork = reject;
});
const raced = await raceLoginRunnerExit(work, 5, undefined);
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "timeout" });
// The race already settled on the timeout. The work then rejects. The helper
// consumes the late rejection, so it never becomes an unhandled rejection.
expect(() => rejectWork(new Error("late"))).not.toThrow();
await Promise.resolve();
});
});
describe("login-runner lifecycle ordering", () => {
it("disposes a driver one time", async () => {
const dispose = vi.fn(async () => {});
const driver: LoginRunnerDisposable = { dispose };
await driver.dispose();
expect(dispose).toHaveBeenCalledTimes(1);
});
it("races to a terminal state, then disposes the driver", async () => {
const events: string[] = [];
const driver: LoginRunnerDisposable = {
dispose: vi.fn(async () => {
events.push("dispose");
}),
};
const work = Promise.resolve({ exitCode: 0 }).then((value) => {
events.push("exit");
return value;
});
const raced = await raceLoginRunnerExit(work, 10_000, undefined);
await driver.dispose();
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "exit", exitCode: 0 });
expect(events).toEqual(["exit", "dispose"]);
});
});
@@ -0,0 +1,116 @@
// The shared login-runner lifecycle contract. Both login runners use these
// types: the Codex device-login runner and the Claude setup-token runner. The
// module defines the runner outcome union, the result base type, the fixed-log
// callback type, the timeout and cancellation options, the dispose contract, and
// the timeout race helper. The module holds types and one pure helper only. It
// does not change runner behavior.
//
// Security (secret handling): the outcome and the result carry only a fixed,
// non-secret status. They never carry a URL, a code, or a token byte. The log
// callback receives only fixed status lines. Each runner keeps this rule.
/**
* The terminal outcome of a login runner. A runner reports exactly one value.
* The value is a fixed, non-secret status.
*/
export type LoginRunnerOutcome = "success" | "failure" | "timeout" | "cancelled";
/**
* The base result of a login runner. Every runner returns at least these three
* fields. A runner can extend the base with its own non-secret status fields. The
* result never carries a URL, a code, or a token byte.
*/
export interface LoginRunnerResult {
outcome: LoginRunnerOutcome;
exitCode: number | null;
promptSurfaced: boolean;
}
/**
* A non-leaking progress sink. It receives only fixed status lines. A runner
* never passes a URL, a code, or a token byte to this sink.
*/
export type LoginRunnerLog = (line: string) => void;
/**
* The shared lifecycle options. Both runners set the host-side timeout, accept an
* optional cancellation signal, and accept an optional log sink. Each runner
* option type extends this base with its own callbacks.
*/
export interface LoginRunnerLifecycleOptions {
/** The host-side timeout in milliseconds. */
timeoutMs: number;
/** An optional cancellation signal. */
signal?: AbortSignal;
/** A non-leaking progress sink. It receives only fixed status lines. */
log?: LoginRunnerLog;
}
/**
* The dispose contract. A login driver releases its resources through `dispose`.
* The runner always disposes the driver one time for every terminal state. The
* method must be safe to call one time on every path.
*/
export interface LoginRunnerDisposable {
/** Releases the driver resources. */
dispose(): Promise<void>;
}
/**
* The result of the timeout race. The `exit` kind carries the command exit code.
* The `timeout` kind and the `cancelled` kind each mark a terminal state with no
* exit code.
*/
export type LoginRunnerRaceResult =
| { kind: "exit"; exitCode: number | null }
| { kind: "timeout" }
| { kind: "cancelled" };
/**
* Races the streaming `work` against the timeout and the cancellation signal. The
* work result resolves the race with an `exit` status; the timeout resolves it
* with a `timeout` status; the signal resolves it with a `cancelled` status. A
* pre-aborted signal resolves the race with a `cancelled` status at once, before
* the helper starts the timer or listens to the work. A work error rejects the
* race, so the caller can convert it to a fixed, non-secret error. A late work
* rejection after the race already settled is consumed here, so it never becomes
* an unhandled rejection. The helper clears the timer and removes the signal
* listener on the first settle.
*/
export function raceLoginRunnerExit(
work: Promise<{ exitCode: number | null }>,
timeoutMs: number,
signal: AbortSignal | undefined,
): Promise<LoginRunnerRaceResult> {
return new Promise<LoginRunnerRaceResult>((resolve, reject) => {
// The signal is already aborted. Resolve with the cancelled status now. The
// "abort" event does not fire again for an already-aborted signal, so a late
// listener never runs. The helper still consumes a late work rejection below.
if (signal?.aborted) {
work.then(
() => {},
() => {},
);
resolve({ kind: "cancelled" });
return;
}
let settled = false;
const cleanup = () => {
clearTimeout(timer);
if (signal) signal.removeEventListener("abort", onAbort);
};
const finish = (run: () => void) => {
if (settled) return;
settled = true;
cleanup();
run();
};
const timer = setTimeout(() => finish(() => resolve({ kind: "timeout" })), timeoutMs);
const onAbort = () => finish(() => resolve({ kind: "cancelled" }));
if (signal) signal.addEventListener("abort", onAbort, { once: true });
work.then(
(value) => finish(() => resolve({ kind: "exit", exitCode: value.exitCode })),
(error) => finish(() => reject(error)),
);
});
}
File diff suppressed because it is too large. Load diff
@@ -27,6 +27,11 @@ import {
type RuntimeStatusSink,
} from "./runtime-progress.js";
import { isRelativePathOrDescendant, shouldExcludePath } from "./exclude-patterns.js";
import {
scheduleSyncOperations,
SYNC_OPERATION_CONCURRENCY_LIMIT,
type SyncOperationTask,
} from "./sync-operation-schedule.js";
import type { RuntimeSpanRunner } from "./acpx-engine/startup-timing.js";
const execFile = promisify(execFileCallback);
@@ -102,11 +107,18 @@ export interface SandboxManagedRuntimeAssetProvision {
/**
* Context passed to an asset's `restore` contribution during teardown.
* `assetDir` is the asset's directory inside the sandbox and `readFile` reads
* a file back from the sandbox as raw bytes.
* a file back from the sandbox as raw bytes. `tempDir` is a host scratch
* directory that belongs to this restore task alone. The shared scheduler can
* run restore tasks at the same time, so a task must not share scratch space
* with another task. A restore that needs a host temporary file writes it under
* `tempDir`. The coordinator removes the directory after the task settles. The
* sandbox coordinator always sets `tempDir`. A serial runtime that never runs
* restore tasks at the same time can omit it.
*/
export interface SandboxManagedRuntimeAssetRestoreContext {
assetDir: string;
readFile: (remotePath: string) => Promise<Buffer>;
tempDir?: string;
}
export interface SandboxManagedRuntimeAsset {
@@ -244,6 +256,15 @@ export interface SandboxManagedRuntimeClient {
listFiles(remotePath: string): Promise<string[]>;
remove(remotePath: string): Promise<void>;
run(command: string, options: { timeoutMs: number }): Promise<void>;
/**
* True when the orchestrator may run this client's sync operations
* concurrently. The base64 fallback always sets it true. A native provider
* takes the value from the verified `concurrentSyncOperations` opt-in; an
* undeclared native provider keeps it false. One flag serves both `syncIn` and
* `syncOut`. `createCommandManagedRuntimeClient` always sets it on a prepared
* client; it is optional here so a test mock can omit it.
*/
allowConcurrentSyncOperations?: boolean;
/**
* Optional native inbound transfer. Present only when the sandbox provider
* advertises both `environmentSyncIn` and `environmentSyncOut`; otherwise the
@@ -358,9 +379,18 @@ function buildWorkspaceTarExtractCommand(input: {
remoteTar: string;
wipeExceptNames: string[] | null;
}): string {
// The wipe must also preserve any in-flight sync scratch tarball at the
// workspace root. A concurrent referenced-project upload stages a scratch
// tarball named `.paperclip-upload-<uuid>.tar` there. Without this preserve
// term the wipe unlinks the in-flight tarball and the later extract fails.
// The static pattern must agree with the daytona scratch prefix
// `SCRATCH_PREFIX` in
// `packages/plugins/sandbox-providers/daytona/src/file-sync.ts:80`.
// The term is a static literal; `preserveFindArgs` shell-quotes it, so the
// shell passes it to `find -name` as a pattern (Security Conditions C1/C3).
const wipe = input.wipeExceptNames
? ` && find ${shellQuote(input.workspaceRemoteDir)} -mindepth 1 -maxdepth 1 ` +
`${preserveFindArgs(input.wipeExceptNames)} -exec rm -rf -- {} +`
`${preserveFindArgs([...input.wipeExceptNames, ".paperclip-upload-*"])} -exec rm -rf -- {} +`
: "";
return (
`mkdir -p ${shellQuote(input.workspaceRemoteDir)}${wipe} && ` +
@@ -384,6 +414,29 @@ function buildUniqueStagingPath(input: { targetPath: string; suffix: string }):
return `${input.targetPath}${input.suffix}.${randomUUID()}`;
}
// The workspace stages under `<runtimeRootDir>/workspace-upload.tar` and, for a
// git-backed workspace, under `<runtimeRootDir>/git-workspace-upload.tar`. Each
// asset stages under `<runtimeRootDir>/<key>-upload.tar`, so an asset key equal to
// one of these stems resolves to the same remote archive path. Reserve the stems.
const RESERVED_RUNTIME_ASSET_KEYS = new Set(["workspace", "git-workspace"]);
// Reject an asset key before any path is built from it. An asset key becomes a
// remote directory (`<runtimeRootDir>/<key>`), a remote archive name
// (`<key>-upload.tar`), and a host temp file (`<key>.tar`). A path separator or
// `..` in the key escapes those roots. A reserved stem makes the asset archive
// share a path with the workspace archive; under concurrent sync the asset task
// and the workspace task then write or upload the same archive at the same time,
// which fails extraction nondeterministically or puts asset bytes in the
// workspace. Fail closed on both cases.
function assertRuntimeAssetKeyIsSafe(key: string): void {
if (key.length === 0 || key.includes("/") || key.includes("\\") || key.includes("..")) {
throw new Error(`sandbox runtime asset key is not a simple path segment: ${key}`);
}
if (RESERVED_RUNTIME_ASSET_KEYS.has(key)) {
throw new Error(`sandbox runtime asset key collides with a reserved runtime archive name: ${key}`);
}
}
export function parseSandboxRemoteExecutionSpec(value: unknown): SandboxRemoteExecutionSpec | null {
const parsed = asObject(value);
const transport = asString(parsed.transport).trim();
@@ -715,13 +768,24 @@ export async function prepareSandboxManagedRuntime(input: {
const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey);
const syncWorkspace = input.syncWorkspace !== false;
// Wrap a host-side staging sub-step in its own span when the caller injects a
// runtime span runner. Mirrors the `pack` span below: the runner defaults to a
// no-op, so a caller with no injected runner keeps the current control flow,
// and a throwing runner never changes it (see `createRuntimeSpanRunner`). Each
// span parents under the `stage.sync` step, so the two pre-`pack` operations —
// the git enumeration and the baseline content-hash walk — stop showing up as
// a hidden gap at the head of the step.
// Reject any unsafe asset key before an archive path or an asset directory is
// built from it. This runs before the git snapshot work so a bad key fails fast.
for (const asset of input.assets ?? []) {
assertRuntimeAssetKeyIsSafe(asset.key);
}
// Wrap a host-side staging sub-step or one scheduler task in its own span when
// the caller injects a runtime span runner. The runner defaults to a no-op, so
// a caller with no injected runner keeps the current control flow, and a
// throwing runner never changes it (see `createRuntimeSpanRunner`). The two
// pre-`pack` operations — the git enumeration and the baseline content-hash
// walk — parent under the `stage.sync` step, so they stop showing up as a
// hidden gap at the head of the step. Each inbound task (`stage.workspace`,
// `stage.asset.<key>`, `stage.project.<id>`) and each outbound restore task
// (`restore.workspace`, `restore.asset.<key>`) opens its own span, so two
// concurrent tasks produce overlapping spans and the `pack` span nests under
// `stage.workspace`. The outbound spans parent under the run's `sandbox.syncBack`
// span, because the teardown runs the restore inside that span.
const runStepSpan = <T>(name: string, work: () => Promise<T>): Promise<T> =>
input.runtimeSpan ? input.runtimeSpan(name, work) : work();
@@ -851,167 +915,192 @@ export async function prepareSandboxManagedRuntime(input: {
await upload.finish(params.progressBytes, params.progressBytes);
};
// Build the ordered inbound operation task list. Each task stages one inbound
// operation from start to end: it packs the bytes, confines the mappings, and
// uploads them. The shared scheduler starts the tasks. When the sync client
// permits concurrency, the scheduler keeps at most SYNC_OPERATION_CONCURRENCY_LIMIT
// tasks active. When the client does not permit concurrency, the scheduler runs
// one task at a time in this order. The scheduler settles every started task
// before it returns, so no upload outlives the coordinator and ACP never starts
// before the barrier.
const inboundTasks: Array<SyncOperationTask<void>> = [];
// A required-flag list, one entry per task and index-aligned with inboundTasks.
// The workspace and each asset are required operations: a rejection is fatal
// after the barrier. Each referenced project is a nonfatal operation: its task
// records its own failure and never rejects.
const inboundTaskIsRequired: boolean[] = [];
if (syncWorkspace) {
// A git-backed workspace and a plain workspace both stage through ONE
// confined `syncIn` operation. A git-backed workspace carries TWO host tars —
// the git-history clone and the working-tree overlay — as two `file` mappings
// on the SAME operation, with their extract commands as ordered
// `postUploadCommands`. One operation shares one mkdir, one confine guard, one
// `uploadFiles`, and one rename exec, so the second `syncIn` round trip is
// removed. Build the whole merged file set and command list BEFORE the confine
// guard runs (inside `stageConfinedSyncIn`); never append a mapping after it.
const workspaceFiles: SandboxSyncFileMapping[] = [];
const workspacePostUploadCommands: SandboxPostUploadCommand[] = [];
let workspaceUploadBytes = 0;
inboundTaskIsRequired.push(true);
inboundTasks.push(() =>
runStepSpan("stage.workspace", async () => {
// A git-backed workspace and a plain workspace both stage through ONE
// confined `syncIn` operation. A git-backed workspace carries TWO host tars —
// the git-history clone and the working-tree overlay — as two `file` mappings
// on the SAME operation, with their extract commands as ordered
// `postUploadCommands`. One operation shares one mkdir, one confine guard, one
// `uploadFiles`, and one rename exec, so the second `syncIn` round trip is
// removed. Build the whole merged file set and command list BEFORE the confine
// guard runs (inside `stageConfinedSyncIn`); never append a mapping after it.
const workspaceFiles: SandboxSyncFileMapping[] = [];
const workspacePostUploadCommands: SandboxPostUploadCommand[] = [];
let workspaceUploadBytes = 0;
// Build both host tarballs (the git-history tar and the workspace-overlay
// tar) inside one host span named `pack`. This span makes the host pack
// time visible under the `stage.sync` step, where it is otherwise a hidden
// gap with no span. The transfer (`stageConfinedSyncIn`) runs after this
// span, so `pack` measures only the host tar-build cost. The runner
// defaults to a no-op, so a caller with no injected runner keeps the
// current behavior and control flow.
await runStepSpan("pack", async () => {
// 1. git-history tar (git-backed workspace only). Both tar targets live under
// `runtimeRootDir` (`.paperclip-runtime/<adapterKey>`). The git extract
// wipes the target tree EXCEPT `.paperclip-runtime`, so the overlay tar,
// which sits under `.paperclip-runtime`, survives to run its own extract.
if (gitSnapshot) {
await emitRuntimeStatus(input.onRuntimeProgress, "git_sync", "Syncing git history to sandbox");
const gitTarPath = path.join(tempDir, "git-workspace.tar");
const remoteGitTar = path.posix.join(runtimeRootDir, "git-workspace-upload.tar");
await withShallowGitWorkspaceClone({
localDir: input.workspaceLocalDir,
snapshot: gitSnapshot,
}, async (cloneDir) => {
// Build both host tarballs (the git-history tar and the workspace-overlay
// tar) inside one host span named `pack`. This span makes the host pack
// time visible under the `stage.sync` step, where it is otherwise a hidden
// gap with no span. The transfer (`stageConfinedSyncIn`) runs after this
// span, so `pack` measures only the host tar-build cost. The runner
// defaults to a no-op, so a caller with no injected runner keeps the
// current behavior and control flow.
await runStepSpan("pack", async () => {
// 1. git-history tar (git-backed workspace only). Both tar targets live under
// `runtimeRootDir` (`.paperclip-runtime/<adapterKey>`). The git extract
// wipes the target tree EXCEPT `.paperclip-runtime`, so the overlay tar,
// which sits under `.paperclip-runtime`, survives to run its own extract.
if (gitSnapshot) {
await emitRuntimeStatus(input.onRuntimeProgress, "git_sync", "Syncing git history to sandbox");
const gitTarPath = path.join(tempDir, "git-workspace.tar");
const remoteGitTar = path.posix.join(runtimeRootDir, "git-workspace-upload.tar");
await withShallowGitWorkspaceClone({
localDir: input.workspaceLocalDir,
snapshot: gitSnapshot,
}, async (cloneDir) => {
await createTarballFromDirectory({
localDir: cloneDir,
archivePath: gitTarPath,
exclude: [".paperclip-runtime"],
});
});
workspaceFiles.push({ sourcePath: gitTarPath, targetPath: remoteGitTar, kind: "file", access: "rw", writablePath: workspaceRemoteDir });
workspacePostUploadCommands.push({
command: buildWorkspaceTarExtractCommand({
workspaceRemoteDir,
remoteTar: remoteGitTar,
wipeExceptNames: [".paperclip-runtime"],
}),
});
workspaceUploadBytes += (await fs.stat(gitTarPath)).size;
}
// 2. workspace-overlay tar. A git-backed overlay merges on top of the just
// extracted git tree (no wipe); a plain workspace wipes every child except
// the preserved names first. The extract runs AFTER the git extract.
await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing workspace to sandbox");
const workspaceTarPath = path.join(tempDir, "workspace.tar");
const workspaceArchiveDir = gitSnapshot ? path.join(tempDir, "workspace-overlay") : input.workspaceLocalDir;
if (gitSnapshot) {
await copySelectedWorkspaceEntries({
sourceDir: input.workspaceLocalDir,
targetDir: workspaceArchiveDir,
relativePaths: gitSnapshot.overlayPaths,
exclude: workspaceArchiveExclude,
});
}
await createTarballFromDirectory({
localDir: cloneDir,
archivePath: gitTarPath,
exclude: [".paperclip-runtime"],
localDir: workspaceArchiveDir,
archivePath: workspaceTarPath,
exclude: gitSnapshot ? undefined : workspaceArchiveExclude,
});
const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-upload.tar");
workspaceFiles.push({ sourcePath: workspaceTarPath, targetPath: remoteWorkspaceTar, kind: "file", access: "rw", writablePath: workspaceRemoteDir });
workspacePostUploadCommands.push({
command: buildWorkspaceTarExtractCommand({
workspaceRemoteDir,
remoteTar: remoteWorkspaceTar,
wipeExceptNames: gitSnapshot ? null : [...preservedNames],
}),
});
// 3. Optional remove-deleted-paths command runs LAST, after both extracts.
if (gitSnapshot && gitSnapshot.deletedPaths.length > 0) {
workspacePostUploadCommands.push({
command: buildRemoveDeletedPathsCommand({
remoteDir: workspaceRemoteDir,
deletedPaths: gitSnapshot.deletedPaths,
}),
});
}
workspaceUploadBytes += (await fs.stat(workspaceTarPath)).size;
});
workspaceFiles.push({ sourcePath: gitTarPath, targetPath: remoteGitTar, kind: "file", access: "rw", writablePath: workspaceRemoteDir });
workspacePostUploadCommands.push({
command: buildWorkspaceTarExtractCommand({
workspaceRemoteDir,
remoteTar: remoteGitTar,
wipeExceptNames: [".paperclip-runtime"],
}),
});
workspaceUploadBytes += (await fs.stat(gitTarPath)).size;
}
// 2. workspace-overlay tar. A git-backed overlay merges on top of the just
// extracted git tree (no wipe); a plain workspace wipes every child except
// the preserved names first. The extract runs AFTER the git extract.
await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing workspace to sandbox");
const workspaceTarPath = path.join(tempDir, "workspace.tar");
const workspaceArchiveDir = gitSnapshot ? path.join(tempDir, "workspace-overlay") : input.workspaceLocalDir;
if (gitSnapshot) {
await copySelectedWorkspaceEntries({
sourceDir: input.workspaceLocalDir,
targetDir: workspaceArchiveDir,
relativePaths: gitSnapshot.overlayPaths,
exclude: workspaceArchiveExclude,
// One confined `syncIn` for the whole merged workspace file set. The confine
// guard covers every mapping BEFORE any bytes upload (fail-closed): a source
// or target escape in EITHER tar mapping stops the upload of both.
await stageConfinedSyncIn({
files: workspaceFiles,
postUploadCommands: workspacePostUploadCommands,
sourceRoots: [tempDir],
targetRoots: [runtimeRootDir],
progressLabel: "workspace",
statusPhase: "config_sync",
progressBytes: workspaceUploadBytes,
});
}
await createTarballFromDirectory({
localDir: workspaceArchiveDir,
archivePath: workspaceTarPath,
exclude: gitSnapshot ? undefined : workspaceArchiveExclude,
});
const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-upload.tar");
workspaceFiles.push({ sourcePath: workspaceTarPath, targetPath: remoteWorkspaceTar, kind: "file", access: "rw", writablePath: workspaceRemoteDir });
workspacePostUploadCommands.push({
command: buildWorkspaceTarExtractCommand({
workspaceRemoteDir,
remoteTar: remoteWorkspaceTar,
wipeExceptNames: gitSnapshot ? null : [...preservedNames],
}),
});
// 3. Optional remove-deleted-paths command runs LAST, after both extracts.
if (gitSnapshot && gitSnapshot.deletedPaths.length > 0) {
workspacePostUploadCommands.push({
command: buildRemoveDeletedPathsCommand({
remoteDir: workspaceRemoteDir,
deletedPaths: gitSnapshot.deletedPaths,
}),
});
}
workspaceUploadBytes += (await fs.stat(workspaceTarPath)).size;
});
// One confined `syncIn` for the whole merged workspace file set. The confine
// guard covers every mapping BEFORE any bytes upload (fail-closed): a source
// or target escape in EITHER tar mapping stops the upload of both.
await stageConfinedSyncIn({
files: workspaceFiles,
postUploadCommands: workspacePostUploadCommands,
sourceRoots: [tempDir],
targetRoots: [runtimeRootDir],
progressLabel: "workspace",
statusPhase: "config_sync",
progressBytes: workspaceUploadBytes,
});
}),
);
}
for (const asset of input.assets ?? []) {
await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing runtime assets to sandbox");
const remoteAssetDir = path.posix.join(runtimeRootDir, asset.key);
const remoteAssetTar = path.posix.join(runtimeRootDir, `${asset.key}-upload.tar`);
// Every asset — default OR custom-provisioned (e.g. an adapter credential
// merge) — rides one `syncIn` operation: the asset tar plus any staged
// helper files as `files` mappings, and the extract/merge command as the
// ordered post-upload command. There is no native-diversion gate; a
// custom-provisioned asset's bytes now ride native `uploadFiles` and its
// command runs as a provider-executed post-upload command.
const assetTarPath = path.join(tempDir, `${asset.key}.tar`);
await createTarballFromDirectory({
localDir: asset.localDir,
archivePath: assetTarPath,
followSymlinks: asset.followSymlinks,
exclude: asset.exclude,
});
const files: SandboxSyncFileMapping[] = [
{ sourcePath: assetTarPath, targetPath: remoteAssetTar, kind: "file", access: "rw", writablePath: remoteAssetDir },
];
// Stage provision helper files (e.g. the merge scripts) into the temp dir
// and map them alongside the asset tar so they ride the same native upload.
for (const stageFile of asset.provision?.stageFiles ?? []) {
const safeName = stageFile.name;
if (/[\\/]|\.\.(\.|$)/.test(safeName) || safeName === "..") {
throw new Error(`provision stageFile.name must be a simple basename, got: ${safeName}`);
}
const stageBytes = typeof stageFile.contents === "string"
? Buffer.from(stageFile.contents)
: stageFile.contents;
const stageHostPath = path.join(tempDir, `${asset.key}.stage.${safeName}`);
await fs.writeFile(stageHostPath, stageBytes);
// A stage helper file (for example a merge script) is a read-only input
// that the provision command reads; the agent does not change it and does
// not keep it. So it is `access: "ro"` and never joins the writable set.
files.push({
sourcePath: stageHostPath,
targetPath: path.posix.join(runtimeRootDir, safeName),
kind: "file",
access: "ro",
});
}
const postUploadCommand = asset.provision?.postUploadCommand?.({
assetTarPath: remoteAssetTar,
assetDir: remoteAssetDir,
runtimeRootDir,
}) ?? buildDefaultExtractRuntimeAssetCommand({ remoteAssetDir, remoteAssetTar });
const assetTarSize = (await fs.stat(assetTarPath)).size;
await stageConfinedSyncIn({
files,
postUploadCommands: [{ command: postUploadCommand }],
sourceRoots: [tempDir],
targetRoots: [runtimeRootDir],
progressLabel: asset.key,
statusPhase: "config_sync",
progressBytes: assetTarSize,
});
inboundTaskIsRequired.push(true);
inboundTasks.push(() =>
runStepSpan(`stage.asset.${asset.key}`, async () => {
await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing runtime assets to sandbox");
const remoteAssetDir = path.posix.join(runtimeRootDir, asset.key);
const remoteAssetTar = path.posix.join(runtimeRootDir, `${asset.key}-upload.tar`);
// Every asset — default OR custom-provisioned (e.g. an adapter credential
// merge) — rides one `syncIn` operation: the asset tar plus any staged
// helper files as `files` mappings, and the extract/merge command as the
// ordered post-upload command. There is no native-diversion gate; a
// custom-provisioned asset's bytes now ride native `uploadFiles` and its
// command runs as a provider-executed post-upload command.
const assetTarPath = path.join(tempDir, `${asset.key}.tar`);
await createTarballFromDirectory({
localDir: asset.localDir,
archivePath: assetTarPath,
followSymlinks: asset.followSymlinks,
exclude: asset.exclude,
});
const files: SandboxSyncFileMapping[] = [
{ sourcePath: assetTarPath, targetPath: remoteAssetTar, kind: "file", access: "rw", writablePath: remoteAssetDir },
];
// Stage provision helper files (e.g. the merge scripts) into the temp dir
// and map them alongside the asset tar so they ride the same native upload.
for (const stageFile of asset.provision?.stageFiles ?? []) {
const safeName = stageFile.name;
if (/[\\/]|\.\.(\.|$)/.test(safeName) || safeName === "..") {
throw new Error(`provision stageFile.name must be a simple basename, got: ${safeName}`);
}
const stageBytes = typeof stageFile.contents === "string"
? Buffer.from(stageFile.contents)
: stageFile.contents;
const stageHostPath = path.join(tempDir, `${asset.key}.stage.${safeName}`);
await fs.writeFile(stageHostPath, stageBytes);
// A stage helper file (for example a merge script) is a read-only input
// that the provision command reads; the agent does not change it and does
// not keep it. So it is `access: "ro"` and never joins the writable set.
files.push({
sourcePath: stageHostPath,
targetPath: path.posix.join(runtimeRootDir, safeName),
kind: "file",
access: "ro",
});
}
const postUploadCommand = asset.provision?.postUploadCommand?.({
assetTarPath: remoteAssetTar,
assetDir: remoteAssetDir,
runtimeRootDir,
}) ?? buildDefaultExtractRuntimeAssetCommand({ remoteAssetDir, remoteAssetTar });
const assetTarSize = (await fs.stat(assetTarPath)).size;
await stageConfinedSyncIn({
files,
postUploadCommands: [{ command: postUploadCommand }],
sourceRoots: [tempDir],
targetRoots: [runtimeRootDir],
progressLabel: asset.key,
statusPhase: "config_sync",
progressBytes: assetTarSize,
});
}),
);
}
// Stage each referenced (additional) project as a plain, read-only tree in
@@ -1023,46 +1112,83 @@ export async function prepareSandboxManagedRuntime(input: {
// project's confinement or sync failure logs a warning and is skipped, and
// the run plus the other projects continue. Only a project that stages
// successfully appears in `additionalSourceDirs`.
for (const source of input.additionalSources ?? []) {
const { localPath, projectId } = source;
const label = `project-${projectId}`;
try {
if (!path.posix.isAbsolute(localPath)) {
throw new Error(`additional source localPath is not an absolute path: ${localPath}`);
}
if (
projectId.length === 0 ||
projectId.includes("/") ||
projectId.includes("\\") ||
projectId.includes("..")
) {
throw new Error(`additional source projectId is not a simple path segment: ${projectId}`);
}
const remoteProjectDir = path.posix.join(runtimeRootDir, label);
await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing referenced project to sandbox");
await stageConfinedSyncIn({
files: [{
sourcePath: localPath,
targetPath: remoteProjectDir,
kind: "directory",
exclude: additionalSourceExclude,
access: "ro",
}],
sourceRoots: [localPath],
targetRoots: [remoteProjectDir],
progressLabel: label,
statusPhase: "config_sync",
progressBytes: 0,
});
additionalSourceDirs[projectId] = remoteProjectDir;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(
`[paperclip] Failed to stage referenced project ${projectId}; skipping it. ${message}`,
);
// Record the failure as a first-class per-project outcome so the run can count it in the
// requested-vs-synced accounting instead of losing it to a warning line.
additionalSourceFailures.push({ projectId, error: message });
const additionalSourceList = input.additionalSources ?? [];
// Record each referenced-project failure in its input-order slot. Slot order
// keeps the failure list stable no matter the task completion order under
// concurrency.
const additionalFailureSlots: Array<AdditionalSourceStagingFailure | null> =
additionalSourceList.map(() => null);
additionalSourceList.forEach((source, sourceIndex) => {
inboundTaskIsRequired.push(false);
inboundTasks.push(() =>
runStepSpan(`stage.project.${source.projectId}`, async () => {
const { localPath, projectId } = source;
const label = `project-${projectId}`;
try {
if (!path.posix.isAbsolute(localPath)) {
throw new Error(`additional source localPath is not an absolute path: ${localPath}`);
}
if (
projectId.length === 0 ||
projectId.includes("/") ||
projectId.includes("\\") ||
projectId.includes("..")
) {
throw new Error(`additional source projectId is not a simple path segment: ${projectId}`);
}
const remoteProjectDir = path.posix.join(runtimeRootDir, label);
await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing referenced project to sandbox");
await stageConfinedSyncIn({
files: [{
sourcePath: localPath,
targetPath: remoteProjectDir,
kind: "directory",
exclude: additionalSourceExclude,
access: "ro",
}],
sourceRoots: [localPath],
targetRoots: [remoteProjectDir],
progressLabel: label,
statusPhase: "config_sync",
progressBytes: 0,
});
additionalSourceDirs[projectId] = remoteProjectDir;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// Record the failure as a first-class per-project outcome so the run can count it in the
// requested-vs-synced accounting and surface the reason on the run result and the run log.
// The structured slot carries the reason, so no `console.warn` line is needed here.
additionalFailureSlots[sourceIndex] = { projectId, error: message };
}
}),
);
});
// Run every inbound operation through the shared scheduler. It starts the
// tasks under the client's concurrency permission and the bound, and it
// settles every started task before it returns. This is the startup barrier:
// the coordinator returns only after every upload settles.
const inboundResults = await scheduleSyncOperations(
inboundTasks,
input.client.allowConcurrentSyncOperations === true,
SYNC_OPERATION_CONCURRENCY_LIMIT,
);
// Collect the referenced-project failures in stable input order.
for (const failure of additionalFailureSlots) {
if (failure) {
additionalSourceFailures.push(failure);
}
}
// Select a fatal failure in stable operation order. The workspace comes first,
// then each asset. A referenced-project task never rejects, so a nonfatal
// outcome never appears here. Raise the first required rejection, so two
// required failures raise the earlier one.
for (let index = 0; index < inboundResults.length; index += 1) {
const result = inboundResults[index];
if (inboundTaskIsRequired[index] && result.status === "rejected") {
throw result.reason;
}
}
});
@@ -1081,182 +1207,227 @@ export async function prepareSandboxManagedRuntime(input: {
additionalSourceFailures,
restoreWorkspace: async (onProgress?: RuntimeProgressSink) => {
const restoreSink = onProgress ?? input.onProgress;
if (!syncWorkspace) {
for (const asset of input.assets ?? []) {
if (!asset.restore) continue;
await asset.restore({
assetDir: path.posix.join(runtimeRootDir, asset.key),
readFile: async (remotePath) => toBuffer(await input.client.readFile(remotePath)),
});
}
return;
}
await withTempDir("paperclip-sandbox-restore-", async (tempDir) => {
let importedRef: string | null = null;
let importedHead: string | null = null;
let remoteWorkspaceStatus = "dirty";
try {
if (gitSnapshot) {
await emitRuntimeStatus(input.onRuntimeProgress, "export", "Exporting git changes from sandbox");
importedRef = createImportedGitRef("sandbox");
const remoteGitBundle = path.posix.join(runtimeRootDir, "git-delta.bundle");
const remoteWorkspaceStatusPath = path.posix.join(runtimeRootDir, "workspace-status.txt");
const exportRef = createRemoteGitExportRef("sandbox");
const localBundlePath = path.join(tempDir, "git-delta.bundle");
// Export the sandbox history and import it into the host workspace.
// The delta bundle assumes the host holds the bundle's boundary
// commit; when the host has been reset far enough that it does not,
// the import fails on a missing prerequisite. In that case re-export
// a full, self-contained bundle from the still-live sandbox rather
// than discard the completed run.
const exportAndImport = async (forceFullBundle: boolean): Promise<string> => {
await input.client.run(
`sh -c ${shellQuote(buildRemoteGitDeltaBundleScript({
remoteDir: workspaceRemoteDir,
baseSha: gitSnapshot.headCommit,
exportRef,
bundlePath: remoteGitBundle,
statusPath: forceFullBundle ? undefined : remoteWorkspaceStatusPath,
forceFullBundle,
}))}`,
{ timeoutMs: input.spec.timeoutMs },
);
const gitExport = makeTransferProgress(
restoreSink,
"Exporting git history",
"from",
undefined,
{ sink: input.onRuntimeProgress, phase: "export" },
);
const bundleBytes = await input.client.readFile(remoteGitBundle, gitExport.options);
const bundleBuffer = toBuffer(bundleBytes);
await gitExport.finish(bundleBuffer.byteLength, bundleBuffer.byteLength);
await input.client.remove(remoteGitBundle).catch(() => undefined);
if (!forceFullBundle) {
remoteWorkspaceStatus = await input.client.readFile(remoteWorkspaceStatusPath)
.then((bytes) => toBuffer(bytes).toString("utf8").trim())
.catch(() => "dirty");
remoteWorkspaceStatus = remoteWorkspaceStatus === "clean" ? "clean" : "dirty";
await input.client.remove(remoteWorkspaceStatusPath).catch(() => undefined);
// Build the ordered outbound restore task list. Each task runs one
// restore from start to end: the workspace restore, or one asset restore.
// The shared scheduler starts the tasks. When the sync client permits
// concurrency, the scheduler keeps at most SYNC_OPERATION_CONCURRENCY_LIMIT
// tasks active. When the client does not permit concurrency, the scheduler
// runs one task at a time in this order. The scheduler settles every
// started task before it returns, so lease teardown never starts while an
// outbound restore task still writes host data.
const outboundTasks: Array<SyncOperationTask<void>> = [];
// The workspace restore task runs only when the run syncs the workspace.
// The task exports the sandbox git history (git-backed workspace), reads
// the sandbox workspace back, and merges it into the host workspace root.
// The merge is the only outbound write inside the host workspace root.
// Every other task writes a disjoint host target: an asset restore writes
// its own store outside the workspace root. So the workspace task and the
// asset tasks share one parallel set. Keep any future asset that must write
// inside the host workspace root out of this set, and run it after the
// merge. Each task gets its own restore temp directory, so two concurrent
// tasks never share scratch state.
if (syncWorkspace) {
outboundTasks.push(() =>
runStepSpan("restore.workspace", async () => {
await withTempDir("paperclip-sandbox-restore-", async (tempDir) => {
let importedRef: string | null = null;
let importedHead: string | null = null;
let remoteWorkspaceStatus = "dirty";
try {
if (gitSnapshot) {
await emitRuntimeStatus(input.onRuntimeProgress, "export", "Exporting git changes from sandbox");
importedRef = createImportedGitRef("sandbox");
const remoteGitBundle = path.posix.join(runtimeRootDir, "git-delta.bundle");
const remoteWorkspaceStatusPath = path.posix.join(runtimeRootDir, "workspace-status.txt");
const exportRef = createRemoteGitExportRef("sandbox");
const localBundlePath = path.join(tempDir, "git-delta.bundle");
// Export the sandbox history and import it into the host workspace.
// The delta bundle assumes the host holds the bundle's boundary
// commit; when the host has been reset far enough that it does not,
// the import fails on a missing prerequisite. In that case re-export
// a full, self-contained bundle from the still-live sandbox rather
// than discard the completed run.
const exportAndImport = async (forceFullBundle: boolean): Promise<string> => {
await input.client.run(
`sh -c ${shellQuote(buildRemoteGitDeltaBundleScript({
remoteDir: workspaceRemoteDir,
baseSha: gitSnapshot.headCommit,
exportRef,
bundlePath: remoteGitBundle,
statusPath: forceFullBundle ? undefined : remoteWorkspaceStatusPath,
forceFullBundle,
}))}`,
{ timeoutMs: input.spec.timeoutMs },
);
const gitExport = makeTransferProgress(
restoreSink,
"Exporting git history",
"from",
undefined,
{ sink: input.onRuntimeProgress, phase: "export" },
);
const bundleBytes = await input.client.readFile(remoteGitBundle, gitExport.options);
const bundleBuffer = toBuffer(bundleBytes);
await gitExport.finish(bundleBuffer.byteLength, bundleBuffer.byteLength);
await input.client.remove(remoteGitBundle).catch(() => undefined);
if (!forceFullBundle) {
remoteWorkspaceStatus = await input.client.readFile(remoteWorkspaceStatusPath)
.then((bytes) => toBuffer(bytes).toString("utf8").trim())
.catch(() => "dirty");
remoteWorkspaceStatus = remoteWorkspaceStatus === "clean" ? "clean" : "dirty";
await input.client.remove(remoteWorkspaceStatusPath).catch(() => undefined);
}
await fs.writeFile(localBundlePath, bundleBuffer);
return fetchGitBundleIntoLocalRef({
localDir: input.workspaceLocalDir,
bundlePath: localBundlePath,
exportRef,
importedRef: importedRef!,
baseSha: gitSnapshot.headCommit,
});
};
try {
importedHead = await exportAndImport(false);
} catch (error) {
if (!isMissingGitPrerequisiteError(error)) throw error;
importedHead = await exportAndImport(true);
}
}
await emitRuntimeStatus(input.onRuntimeProgress, "restore", "Restoring workspace from sandbox");
const extractedDir = path.join(tempDir, "workspace");
if (nativeSyncOut) {
// Native outbound: the provider materializes the sandbox workspace into
// a fresh host directory. It is a clean destroy-then-replace into a
// temp dir the orchestrator just created, so it maps exactly to a
// generic directory file mapping; the host-side baseline merge below is
// unchanged.
const operations: SandboxSyncOperation[] = [{
operationId: nextSyncOperationId(),
files: [{
sourcePath: workspaceRemoteDir,
targetPath: extractedDir,
kind: "directory",
exclude: restoreExclude,
}],
}];
assertSyncOperationsConfined(operations, {
sourceRoots: [workspaceRemoteDir],
targetRoots: [extractedDir],
});
await fs.mkdir(extractedDir, { recursive: true });
const workspaceRestore = makeTransferProgress(
restoreSink,
"Restoring",
"from",
"workspace",
{ sink: input.onRuntimeProgress, phase: "restore" },
);
await input.client.syncOut!(operations);
await workspaceRestore.finish(0, 0);
} else {
const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-download.tar");
await input.client.run(
`sh -c ${shellQuote(createRemoteTarballFromDirectoryCommand({
remoteDir: workspaceRemoteDir,
archivePath: remoteWorkspaceTar,
exclude: restoreExclude,
}))}`,
{ timeoutMs: input.spec.timeoutMs },
);
const workspaceRestore = makeTransferProgress(
restoreSink,
"Restoring",
"from",
"workspace",
{ sink: input.onRuntimeProgress, phase: "restore" },
);
const archiveBytes = await input.client.readFile(remoteWorkspaceTar, workspaceRestore.options);
const archiveBuffer = toBuffer(archiveBytes);
await workspaceRestore.finish(archiveBuffer.byteLength, archiveBuffer.byteLength);
await input.client.remove(remoteWorkspaceTar).catch(() => undefined);
const localArchivePath = path.join(tempDir, "workspace.tar");
await fs.writeFile(localArchivePath, archiveBuffer);
await extractTarballToDirectory({
archivePath: localArchivePath,
localDir: extractedDir,
});
}
const gitHeadToIntegrate = importedHead;
await mergeDirectoryWithBaseline({
baseline: baselineSnapshot!,
sourceDir: extractedDir,
targetDir: input.workspaceLocalDir,
beforeApply: gitHeadToIntegrate
? async () => {
await integrateImportedGitHead({
localDir: input.workspaceLocalDir,
importedHead: gitHeadToIntegrate,
});
}
: undefined,
afterApply: gitSnapshot
? async () => {
await resetLocalGitIndexToHead({
localDir: input.workspaceLocalDir,
checkWorkingTreeClean: remoteWorkspaceStatus === "clean",
});
}
: undefined,
});
} finally {
await emitRuntimeStatus(input.onRuntimeProgress, "finalize", "Finalizing sandbox workspace");
if (importedRef) {
await deleteLocalGitRef({ localDir: input.workspaceLocalDir, ref: importedRef });
}
}
await fs.writeFile(localBundlePath, bundleBuffer);
return fetchGitBundleIntoLocalRef({
localDir: input.workspaceLocalDir,
bundlePath: localBundlePath,
exportRef,
importedRef: importedRef!,
baseSha: gitSnapshot.headCommit,
});
}),
);
}
// One restore task per asset that has a teardown contribution. Each task
// reads its asset back through `readFile` and writes a disjoint host
// target. Each task gets its own restore temp directory.
for (const asset of input.assets ?? []) {
if (!asset.restore) continue;
const assetRestore = asset.restore;
const assetKey = asset.key;
outboundTasks.push(() =>
runStepSpan(`restore.asset.${assetKey}`, async () => {
await withTempDir("paperclip-sandbox-restore-", async (tempDir) => {
await assetRestore({
assetDir: path.posix.join(runtimeRootDir, assetKey),
readFile: async (remotePath) => toBuffer(await input.client.readFile(remotePath)),
tempDir,
});
};
try {
importedHead = await exportAndImport(false);
} catch (error) {
if (!isMissingGitPrerequisiteError(error)) throw error;
importedHead = await exportAndImport(true);
}
}
await emitRuntimeStatus(input.onRuntimeProgress, "restore", "Restoring workspace from sandbox");
const extractedDir = path.join(tempDir, "workspace");
if (nativeSyncOut) {
// Native outbound: the provider materializes the sandbox workspace into
// a fresh host directory. It is a clean destroy-then-replace into a
// temp dir the orchestrator just created, so it maps exactly to a
// generic directory file mapping; the host-side baseline merge below is
// unchanged.
const operations: SandboxSyncOperation[] = [{
operationId: nextSyncOperationId(),
files: [{
sourcePath: workspaceRemoteDir,
targetPath: extractedDir,
kind: "directory",
exclude: restoreExclude,
}],
}];
assertSyncOperationsConfined(operations, {
sourceRoots: [workspaceRemoteDir],
targetRoots: [extractedDir],
});
await fs.mkdir(extractedDir, { recursive: true });
const workspaceRestore = makeTransferProgress(
restoreSink,
"Restoring",
"from",
"workspace",
{ sink: input.onRuntimeProgress, phase: "restore" },
);
await input.client.syncOut!(operations);
await workspaceRestore.finish(0, 0);
} else {
const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-download.tar");
await input.client.run(
`sh -c ${shellQuote(createRemoteTarballFromDirectoryCommand({
remoteDir: workspaceRemoteDir,
archivePath: remoteWorkspaceTar,
exclude: restoreExclude,
}))}`,
{ timeoutMs: input.spec.timeoutMs },
);
const workspaceRestore = makeTransferProgress(
restoreSink,
"Restoring",
"from",
"workspace",
{ sink: input.onRuntimeProgress, phase: "restore" },
);
const archiveBytes = await input.client.readFile(remoteWorkspaceTar, workspaceRestore.options);
const archiveBuffer = toBuffer(archiveBytes);
await workspaceRestore.finish(archiveBuffer.byteLength, archiveBuffer.byteLength);
await input.client.remove(remoteWorkspaceTar).catch(() => undefined);
const localArchivePath = path.join(tempDir, "workspace.tar");
await fs.writeFile(localArchivePath, archiveBuffer);
await extractTarballToDirectory({
archivePath: localArchivePath,
localDir: extractedDir,
});
}
const gitHeadToIntegrate = importedHead;
await mergeDirectoryWithBaseline({
baseline: baselineSnapshot!,
sourceDir: extractedDir,
targetDir: input.workspaceLocalDir,
beforeApply: gitHeadToIntegrate
? async () => {
await integrateImportedGitHead({
localDir: input.workspaceLocalDir,
importedHead: gitHeadToIntegrate,
});
}
: undefined,
afterApply: gitSnapshot
? async () => {
await resetLocalGitIndexToHead({
localDir: input.workspaceLocalDir,
checkWorkingTreeClean: remoteWorkspaceStatus === "clean",
});
}
: undefined,
});
}),
);
}
// Per-asset teardown/outbound contributions. Generic: an asset with
// no `restore` is a no-op. The contribution reads back from the
// sandbox (e.g. a refreshed credential) via the provided `readFile`.
for (const asset of input.assets ?? []) {
if (!asset.restore) continue;
await asset.restore({
assetDir: path.posix.join(runtimeRootDir, asset.key),
readFile: async (remotePath) => toBuffer(await input.client.readFile(remotePath)),
});
}
} finally {
await emitRuntimeStatus(input.onRuntimeProgress, "finalize", "Finalizing sandbox workspace");
if (importedRef) {
await deleteLocalGitRef({ localDir: input.workspaceLocalDir, ref: importedRef });
}
// Run every outbound restore through the shared scheduler. It starts the
// tasks under the client concurrency permission and the bound, and it
// settles every started task before it returns. This is the teardown
// barrier.
const outboundResults = await scheduleSyncOperations(
outboundTasks,
input.client.allowConcurrentSyncOperations === true,
SYNC_OPERATION_CONCURRENCY_LIMIT,
);
// Every outbound restore is a required operation: a rejection is fatal.
// The workspace comes first, then each asset in order. Raise the first
// rejection in stable task order, so two failures raise the earlier one.
for (const result of outboundResults) {
if (result.status === "rejected") {
throw result.reason;
}
});
}
},
};
}
+37 -2
View File
@@ -6,7 +6,11 @@ import os from "node:os";
import path from "node:path";
import { Transform } from "node:stream";
import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js";
import { readSanitizedOriginRemoteUrl } from "./git-workspace-sync.js";
import {
createUnrelatedHistoryGraftCommit,
GIT_SYNC_COMMIT_IDENTITY_ARGS,
readSanitizedOriginRemoteUrl,
} from "./git-workspace-sync.js";
import type { RunProcessResult } from "./server-utils.js";
import type { DirectorySnapshot } from "./workspace-restore-merge.js";
import { mergeDirectoryWithBaseline } from "./workspace-restore-merge.js";
@@ -931,10 +935,18 @@ async function integrateImportedGitHead(input: {
if (!currentHead || currentHead === input.importedHead) return;
const headRef = snapshot.branchName ? `refs/heads/${snapshot.branchName}` : "HEAD";
// `git merge-base` exits 1 when the commits share no ancestor — the only
// outcome that authorizes the graft fallback below. Every other failure
// (timeout, missing object, repository error) must keep failing the
// integration instead of silently rewriting the tip.
let noCommonAncestor = false;
const mergeBase = await runLocalGit(input.localDir, ["merge-base", currentHead, input.importedHead], {
timeout: 10_000,
maxBuffer: 16 * 1024,
}).catch(() => null);
}).catch((error: unknown) => {
noCommonAncestor = (error as { code?: unknown } | null)?.code === 1;
return null;
});
const mergeBaseHead = mergeBase?.stdout.trim() ?? "";
if (mergeBaseHead === input.importedHead) {
@@ -954,6 +966,28 @@ async function integrateImportedGitHead(input: {
}
}
if (noCommonAncestor) {
// No common ancestor — merging is impossible and failing here would
// discard the imported work. Graft it onto the current head instead;
// see createUnrelatedHistoryGraftCommit.
const graftCommit = await createUnrelatedHistoryGraftCommit({
localDir: input.localDir,
currentHead,
importedHead: input.importedHead,
syncLabel: "Paperclip SSH sync",
});
try {
await runLocalGit(input.localDir, ["update-ref", headRef, graftCommit, currentHead], {
timeout: 10_000,
maxBuffer: 16 * 1024,
});
return;
} catch (error) {
if (isConcurrentRefUpdateError(error) && attempt < 4) continue;
throw error;
}
}
let mergedTree;
try {
mergedTree = await runLocalGit(input.localDir, ["merge-tree", "--write-tree", currentHead, input.importedHead], {
@@ -974,6 +1008,7 @@ async function integrateImportedGitHead(input: {
const mergeCommit = await runLocalGit(
input.localDir,
[
...GIT_SYNC_COMMIT_IDENTITY_ARGS,
"commit-tree",
mergedTreeId,
"-p",
@@ -0,0 +1,166 @@
import { describe, expect, it } from "vitest";
import {
SYNC_OPERATION_CONCURRENCY_LIMIT,
scheduleSyncOperations,
} from "./sync-operation-schedule.js";
// A deferred task fake. The `task` thunk records that it started and returns a
// promise that the test resolves or rejects by hand. The fake lets a test hold
// a task open and check the exact moment a later task starts.
interface DeferredTask<T> {
readonly task: () => Promise<T>;
resolve(value: T): void;
reject(reason: unknown): void;
started(): boolean;
}
function makeDeferred<T>(): DeferredTask<T> {
let started = false;
let resolveFn!: (value: T) => void;
let rejectFn!: (reason: unknown) => void;
const promise = new Promise<T>((resolve, reject) => {
resolveFn = resolve;
rejectFn = reject;
});
return {
task: () => {
started = true;
return promise;
},
resolve: (value: T) => resolveFn(value),
reject: (reason: unknown) => rejectFn(reason),
started: () => started,
};
}
// Drain the microtask and timer queues so every pending worker step runs.
function flush(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
describe("scheduleSyncOperations", () => {
it("exposes the shared bound constant as 4", () => {
expect(SYNC_OPERATION_CONCURRENCY_LIMIT).toBe(4);
});
it("keeps at most `bound` tasks active in concurrent mode", async () => {
const deferreds = [makeDeferred<number>(), makeDeferred<number>(), makeDeferred<number>()];
const tasks = deferreds.map((deferred) => deferred.task);
const scheduled = scheduleSyncOperations(tasks, true, 2);
await flush();
// The bound is 2, so only the first two tasks start.
expect(deferreds[0].started()).toBe(true);
expect(deferreds[1].started()).toBe(true);
expect(deferreds[2].started()).toBe(false);
// One active task settles. A worker frees, so the third task starts.
deferreds[0].resolve(0);
await flush();
expect(deferreds[2].started()).toBe(true);
deferreds[1].resolve(1);
deferreds[2].resolve(2);
const results = await scheduled;
expect(results).toEqual([
{ status: "fulfilled", value: 0 },
{ status: "fulfilled", value: 1 },
{ status: "fulfilled", value: 2 },
]);
});
it("runs one task at a time in input order in serial mode", async () => {
const deferreds = [makeDeferred<number>(), makeDeferred<number>(), makeDeferred<number>()];
const tasks = deferreds.map((deferred) => deferred.task);
const scheduled = scheduleSyncOperations(tasks, false);
await flush();
// Serial mode holds one task active, so only the first task starts.
expect(deferreds[0].started()).toBe(true);
expect(deferreds[1].started()).toBe(false);
expect(deferreds[2].started()).toBe(false);
deferreds[0].resolve(0);
await flush();
// The next task starts only after the previous task settles.
expect(deferreds[1].started()).toBe(true);
expect(deferreds[2].started()).toBe(false);
deferreds[1].resolve(1);
await flush();
expect(deferreds[2].started()).toBe(true);
deferreds[2].resolve(2);
const results = await scheduled;
expect(results).toEqual([
{ status: "fulfilled", value: 0 },
{ status: "fulfilled", value: 1 },
{ status: "fulfilled", value: 2 },
]);
});
it("waits for every started task to settle when one rejects", async () => {
const first = makeDeferred<number>();
const second = makeDeferred<number>();
const tasks = [first.task, second.task];
const scheduled = scheduleSyncOperations(tasks, true, 2);
let settled = false;
void scheduled.then(() => {
settled = true;
});
await flush();
// Both tasks are active under the bound of 2.
expect(first.started()).toBe(true);
expect(second.started()).toBe(true);
// The first task rejects, but the second task stays open.
first.reject(new Error("first failed"));
await flush();
// The scheduler does not return before every started task settles.
expect(settled).toBe(false);
second.resolve(2);
const results = await scheduled;
expect(settled).toBe(true);
// The results keep input order, and the rejection carries its reason.
expect(results[0]).toEqual({ status: "rejected", reason: new Error("first failed") });
expect(results[1]).toEqual({ status: "fulfilled", value: 2 });
});
// One table proves settle-all and input-order for both call modes. Both future
// call sites share these proven cases.
const MODE_TABLE = [
{ name: "serial mode", concurrent: false, bound: SYNC_OPERATION_CONCURRENCY_LIMIT },
{ name: "concurrent mode", concurrent: true, bound: 2 },
];
for (const mode of MODE_TABLE) {
it(`returns settled results in input order in ${mode.name}`, async () => {
// The tasks settle out of order: index 2 first, then index 0, then a
// rejection at index 1. The result array must still keep input order.
const failure = new Error("index one failed");
const tasks: Array<() => Promise<string>> = [
() => Promise.resolve("zero"),
() => Promise.reject(failure),
() => Promise.resolve("two"),
];
const results = await scheduleSyncOperations(tasks, mode.concurrent, mode.bound);
expect(results).toEqual([
{ status: "fulfilled", value: "zero" },
{ status: "rejected", reason: failure },
{ status: "fulfilled", value: "two" },
]);
});
}
it("returns an empty result list for no tasks", async () => {
const results = await scheduleSyncOperations<number>([], true, 4);
expect(results).toEqual([]);
});
});
@@ -0,0 +1,79 @@
// A direction-agnostic scheduler for file-sync operations. Both sync directions
// share this one function. The scheduler starts operation tasks under a bound,
// keeps serial order when concurrency is off, and settles every started task
// before it returns. It stays pure: it imports no runtime or provider module.
// The maximum number of active sync operations when concurrency is on. A caller
// passes this as the bound. Four keeps a useful parallel width without a burst
// of open file handles.
export const SYNC_OPERATION_CONCURRENCY_LIMIT = 4;
// A sync operation. The scheduler calls the thunk to start the operation, so the
// scheduler controls the exact start time and can hold the bound.
export type SyncOperationTask<T> = () => Promise<T>;
// Resolve the number of active tasks the scheduler allows.
// Serial mode allows one active task. Concurrent mode allows the bound, with a
// floor of one so a bad bound never stalls the scheduler.
function resolveActiveLimit(concurrent: boolean, bound: number): number {
if (!concurrent) {
return 1;
}
const flooredBound = Math.floor(bound);
if (!Number.isFinite(flooredBound) || flooredBound < 1) {
return 1;
}
return flooredBound;
}
/**
* Run an ordered list of sync operation tasks and settle every started task.
*
* When `concurrent` is false, the scheduler runs the tasks one at a time in
* input order. When `concurrent` is true, the scheduler keeps at most `bound`
* tasks active. The scheduler always waits for every started task to settle
* before it returns. It returns the settled results in input order.
*
* @param tasks Ordered task list. The scheduler calls each thunk to start it.
* @param concurrent Turn concurrency on or off.
* @param bound Maximum active tasks when `concurrent` is true.
* @returns Settled results in input order, one per task.
*/
export async function scheduleSyncOperations<T>(
tasks: ReadonlyArray<SyncOperationTask<T>>,
concurrent: boolean,
bound: number = SYNC_OPERATION_CONCURRENCY_LIMIT,
): Promise<Array<PromiseSettledResult<T>>> {
const results = new Array<PromiseSettledResult<T>>(tasks.length);
const activeLimit = resolveActiveLimit(concurrent, bound);
// A shared cursor over the input list. Each worker takes the next task in
// input order. The cursor keeps serial order and holds the active bound.
let nextIndex = 0;
// One worker settles tasks until the list is empty. A worker catches its own
// task rejection, so a rejection never stops the other workers and never
// rejects the scheduler. This guarantees settle-all.
async function runWorker(): Promise<void> {
while (nextIndex < tasks.length) {
const currentIndex = nextIndex;
nextIndex += 1;
try {
const value = await tasks[currentIndex]();
results[currentIndex] = { status: "fulfilled", value };
} catch (reason) {
results[currentIndex] = { status: "rejected", reason };
}
}
}
// Start at most `activeLimit` workers, and never more than the task count.
const workerCount = Math.min(activeLimit, tasks.length);
const workers: Array<Promise<void>> = [];
for (let worker = 0; worker < workerCount; worker += 1) {
workers.push(runWorker());
}
await Promise.all(workers);
return results;
}
+12 -3
View File
@@ -114,10 +114,11 @@ export interface AdapterExecutionResult {
* Each referenced (mentioned) project that failed to stage into the remote sandbox for this run,
* by `projectId`. The run continues without a failed project (per-project failure isolation); this
* field carries the failure back so the server counts it in the requested-vs-synced observability
* instead of losing it to a warning line. Absent or empty on a local target, or when every staged
* referenced project succeeded.
* instead of losing it to a warning line. Each entry pairs the `projectId` with the failure
* `error`, so a reader of the run learns why the project dropped. Absent or empty on a local
* target, or when every staged referenced project succeeded.
*/
referencedProjectStagingFailures?: Array<{ projectId: string }>;
referencedProjectStagingFailures?: Array<{ projectId: string; error: string }>;
summary?: string | null;
clearSession?: boolean;
question?: {
@@ -503,6 +504,14 @@ export interface ServerAdapterModule {
* and provisioned in fresh remote environments such as sandboxes.
*/
getRuntimeCommandSpec?: (config: Record<string, unknown>) => AdapterRuntimeCommandSpec | null;
/**
* Optional: declare the interactive sandbox login capability. The server uses
* it to drive the login flow and to project the safe panel fields to the user
* interface. An adapter with no interactive login (for example an
* API-key-only vendor) omits it. The capability data holds no secret.
*/
loginCapability?: import("./login-capability.js").AdapterLoginCapability;
}
// ---------------------------------------------------------------------------
@@ -55,6 +55,7 @@
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "^0.66.0",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/shared": "workspace:*",
"picocolors": "^1.1.1"
},
"devDependencies": {
@@ -1,12 +1,7 @@
/**
* The canonical check code the Claude Test engine emits when a sandbox target
* has no ready authentication. The user interface reads this stable code to
* decide login eligibility. The user interface does not read the message text
* or the top-level status.
*
* The name is neutral. It carries no vendor name and no login-flow word, because
* a Test result can sync to public packages. The value matches the code the
* Codex Test engine emits, so the user interface can gate login from one code
* across adapters.
* The canonical check code the Claude Test engine emits when a sandbox target has
* no ready authentication. This module re-exports the one shared constant, so
* the adapters and the user interface gate login from the same code. See the
* source in the shared package for the full contract.
*/
export const ADAPTER_AUTH_MISSING_CHECK_CODE = "adapter_auth_missing";
export { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
@@ -113,7 +113,7 @@ describe("claude_local ACP startup fallback", () => {
it("trusts the Paperclip API URL when network access is allowlisted", async () => {
const paperclipApiUrl = "http://127.0.0.1:4310";
vi.stubEnv("PAPERCLIP_RUNTIME_API_URL", paperclipApiUrl);
vi.stubEnv("PAPERCLIP_API_URL", paperclipApiUrl);
const ctx = buildContext({ networkScope: "allowlist" });
await execute(ctx as never);
@@ -1,3 +1,10 @@
import {
raceLoginRunnerExit,
type LoginRunnerDisposable,
type LoginRunnerLifecycleOptions,
type LoginRunnerOutcome,
type LoginRunnerResult,
} from "@paperclipai/adapter-utils";
import {
parseSetupTokenCredential,
parseSetupTokenPrompt,
@@ -69,7 +76,7 @@ export const CLAUDE_SETUP_TOKEN_MAX_BUFFER_CHARS = 64 * 1024;
* directly; a caller injects a concrete driver. A production driver binds these
* methods to a PTY child that runs {@link CLAUDE_SETUP_TOKEN_COMMAND}.
*/
export interface SetupTokenPtyDriver {
export interface SetupTokenPtyDriver extends LoginRunnerDisposable {
/**
* Starts `command` in a PTY and streams the terminal output to `onData` in
* memory. Resolves with the child exit code when the child ends. A driver must
@@ -87,8 +94,6 @@ export interface SetupTokenPtyDriver {
* and safe to call more than one time.
*/
stop(): void;
/** Releases the driver resources. */
dispose(): Promise<void>;
}
/** Receives the parsed prompt one time in memory. The caller displays it. */
@@ -110,19 +115,20 @@ export type SetupTokenCodeProvider = (signal: AbortSignal) => Promise<string>;
*/
export type SetupTokenCredentialSink = (authBytes: Buffer) => Promise<void>;
export type SetupTokenOutcome = "success" | "failure" | "timeout" | "cancelled";
/** The setup-token runner outcome. It is a fixed, non-secret status. */
export type SetupTokenOutcome = LoginRunnerOutcome;
/** The runner result. It never carries a URL, a code, or a token byte. */
export interface SetupTokenLoginResult {
outcome: SetupTokenOutcome;
exitCode: number | null;
promptSurfaced: boolean;
/**
* The runner result. It never carries a URL, a code, or a token byte. It extends
* the base result with the two setup-token status fields.
*/
export interface SetupTokenLoginResult extends LoginRunnerResult {
codeSubmitted: boolean;
/** True when the runner bound the token and invoked `onCredential` one time. */
credentialDelivered: boolean;
}
export interface RunSetupTokenLoginOptions {
export interface RunSetupTokenLoginOptions extends LoginRunnerLifecycleOptions {
/** The login command. Defaults to {@link CLAUDE_SETUP_TOKEN_COMMAND}. */
command?: string;
/** Receives the parsed prompt one time in memory. The caller displays it. */
@@ -141,12 +147,6 @@ export interface RunSetupTokenLoginOptions {
* write. Defaults to {@link CODE_SUBMIT_SETTLE_MS}. A test sets it to 0.
*/
codeSubmitSettleMs?: number;
/** The host-side timeout in milliseconds. */
timeoutMs: number;
/** An optional cancellation signal. */
signal?: AbortSignal;
/** A non-leaking progress sink. It receives only fixed status lines. */
log?: (line: string) => void;
}
/**
@@ -168,45 +168,6 @@ function settleDelay(ms: number, signal: AbortSignal): Promise<void> {
});
}
type RaceResult =
| { kind: "exit"; exitCode: number | null }
| { kind: "timeout" }
| { kind: "cancelled" };
/**
* Races the streaming start against the timeout and the cancellation signal. The
* start result resolves the race; the timeout and the signal resolve the race
* with a terminal status. A driver error rejects the race, so the caller can
* convert it to a fixed, non-secret error. A late start rejection after the race
* already settled is consumed here, so it never becomes an unhandled rejection.
*/
function raceStart(
start: Promise<{ exitCode: number | null }>,
timeoutMs: number,
signal: AbortSignal,
): Promise<RaceResult> {
return new Promise<RaceResult>((resolve, reject) => {
let settled = false;
const cleanup = () => {
clearTimeout(timer);
signal.removeEventListener("abort", onAbort);
};
const finish = (run: () => void) => {
if (settled) return;
settled = true;
cleanup();
run();
};
const timer = setTimeout(() => finish(() => resolve({ kind: "timeout" })), timeoutMs);
const onAbort = () => finish(() => resolve({ kind: "cancelled" }));
signal.addEventListener("abort", onAbort, { once: true });
start.then(
(value) => finish(() => resolve({ kind: "exit", exitCode: value.exitCode })),
(error) => finish(() => reject(error)),
);
});
}
/**
* Stops the child and releases the driver for a terminal state. The runner calls
* this on every exit path: a normal exit, a non-zero exit, a timeout, a
@@ -394,7 +355,7 @@ export async function runSetupTokenLogin(
}
const start = driver.start(command, onData);
const raced = await raceStart(start, timeoutMs, controller.signal);
const raced = await raceLoginRunnerExit(start, timeoutMs, controller.signal);
// Release a pending code-input routine, then let it settle. The routine is
// self-guarding, so this await never rejects.
controller.abort();
+2 -1
View File
@@ -52,8 +52,9 @@
"probe:quota": "pnpm exec tsx src/cli/quota-probe.ts --json"
},
"dependencies": {
"@agentclientprotocol/codex-acp": "^1.1.4",
"@agentclientprotocol/codex-acp": "^1.2.0",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/shared": "workspace:*",
"picocolors": "^1.1.1"
},
"devDependencies": {
@@ -1,10 +1,7 @@
/**
* The canonical check code both Codex Test engines emit when a sandbox target
* has no ready authentication. The user interface reads this stable code to
* decide login eligibility. The user interface does not read the message text
* or the top-level status.
*
* The name is neutral. It carries no vendor name and no login-flow word, because
* a Test result can sync to public packages.
* The canonical check code the Codex Test engine emits when a sandbox target has
* no ready authentication. This module re-exports the one shared constant, so
* the adapters and the user interface gate login from the same code. See the
* source in the shared package for the full contract.
*/
export const ADAPTER_AUTH_MISSING_CHECK_CODE = "adapter_auth_missing";
export { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
@@ -1,3 +1,10 @@
import {
raceLoginRunnerExit,
type LoginRunnerDisposable,
type LoginRunnerLifecycleOptions,
type LoginRunnerOutcome,
type LoginRunnerResult,
} from "@paperclipai/adapter-utils";
import { parseDeviceLoginPrompt, type DeviceLoginPrompt } from "./device-login-parse.js";
// The device-login runner. It runs the Codex device-login command through an
@@ -35,7 +42,7 @@ const MAX_PARSE_BUFFER_CHARS = 64 * 1024;
* three methods to a non-persisting Daytona exec path, a file read, and a
* sandbox delete.
*/
export interface SandboxLoginDriver {
export interface SandboxLoginDriver extends LoginRunnerDisposable {
/**
* Runs `command` in the sandbox and streams standard output to `onStdout` in
* memory. Resolves with the command exit code when the command ends. A driver
@@ -44,23 +51,18 @@ export interface SandboxLoginDriver {
execStreaming(command: string, onStdout: (chunk: string) => void): Promise<{ exitCode: number | null }>;
/** Reads the bytes of one file from the sandbox. */
readFile(path: string): Promise<Buffer>;
/** Deletes the sandbox and releases its resources. */
dispose(): Promise<void>;
}
/** Receives the parsed prompt one time in memory. The caller displays it. */
export type DeviceLoginPromptSink = (prompt: DeviceLoginPrompt) => void;
export type DeviceLoginOutcome = "success" | "failure" | "timeout" | "cancelled";
/** The device-login runner outcome. It is a fixed, non-secret status. */
export type DeviceLoginOutcome = LoginRunnerOutcome;
/** The runner result. It never carries a URL, a code, or a token byte. */
export interface DeviceLoginResult {
outcome: DeviceLoginOutcome;
exitCode: number | null;
promptSurfaced: boolean;
}
export type DeviceLoginResult = LoginRunnerResult;
export interface RunDeviceLoginOptions {
export interface RunDeviceLoginOptions extends LoginRunnerLifecycleOptions {
/** The login command. Defaults to {@link CODEX_DEVICE_LOGIN_COMMAND}. */
command?: string;
/** Receives the parsed prompt one time in memory. The caller displays it. */
@@ -73,51 +75,6 @@ export interface RunDeviceLoginOptions {
onCredential?: (authBytes: Buffer) => void | Promise<void>;
/** The sandbox path of the credential file to read on success. */
authPath?: string;
/** The host-side timeout in milliseconds. */
timeoutMs: number;
/** An optional cancellation signal. */
signal?: AbortSignal;
/** A non-leaking progress sink. It receives only fixed status lines. */
log?: (line: string) => void;
}
type RaceResult =
| { kind: "exit"; exitCode: number | null }
| { kind: "timeout" }
| { kind: "cancelled" };
/**
* Races the streaming exec against the timeout and the cancellation signal. The
* exec result resolves the race; the timeout and the signal resolve the race
* with a terminal status. A driver error rejects the race, so the caller can
* convert it to a fixed, non-secret error. A late exec rejection after the race
* already settled is consumed here, so it never becomes an unhandled rejection.
*/
function raceExec(
exec: Promise<{ exitCode: number | null }>,
timeoutMs: number,
signal: AbortSignal | undefined,
): Promise<RaceResult> {
return new Promise<RaceResult>((resolve, reject) => {
let settled = false;
const cleanup = () => {
clearTimeout(timer);
if (signal) signal.removeEventListener("abort", onAbort);
};
const finish = (run: () => void) => {
if (settled) return;
settled = true;
cleanup();
run();
};
const timer = setTimeout(() => finish(() => resolve({ kind: "timeout" })), timeoutMs);
const onAbort = () => finish(() => resolve({ kind: "cancelled" }));
if (signal) signal.addEventListener("abort", onAbort, { once: true });
exec.then(
(value) => finish(() => resolve({ kind: "exit", exitCode: value.exitCode })),
(error) => finish(() => reject(error)),
);
});
}
/**
@@ -167,7 +124,7 @@ export async function runDeviceLogin(
}
const exec = driver.execStreaming(command, onStdout);
const raced = await raceExec(exec, timeoutMs, signal);
const raced = await raceLoginRunnerExit(exec, timeoutMs, signal);
if (raced.kind === "timeout") {
log("[paperclip] Device login timed out; disposing the sandbox.");
@@ -32,7 +32,7 @@ export {
type DeviceLoginResult,
type RunDeviceLoginOptions,
} from "./device-login-runner.js";
export { DEVICE_LOGIN_URL, type DeviceLoginPrompt } from "./device-login-parse.js";
export { DEVICE_LOGIN_URL, parseDeviceLoginPrompt, type DeviceLoginPrompt } from "./device-login-parse.js";
export {
promoteDeviceLoginCredential,
checkStagedCredentialReadiness,
+1 -1
View File
@@ -50,7 +50,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@cursor/sdk": "^1.0.24",
"@cursor/sdk": "^1.0.28",
"@paperclipai/adapter-utils": "workspace:*",
"picocolors": "^1.1.1"
},
+27 -1
View File
@@ -23,6 +23,31 @@ function asString(value) {
return typeof value === "string" ? value : "";
}
// Pull the reasoning text out of a `reasoning.available` event payload.
// The exact field name is defined by the external Hermes gateway, so this
// checks the plausible field names (mirroring the fallback chain used by
// extractOutput() in gateway/server/execute.ts) and recurses one level into
// nested `data` / `payload` records.
function extractDirectReasoningText(data) {
return (
asString(data.reasoning).trim() ||
asString(data.reasoning_text).trim() ||
asString(data.thinking).trim() ||
asString(data.text).trim() ||
asString(data.summary).trim() ||
asString(data.content).trim()
);
}
function extractReasoningText(data) {
if (!data) return "";
const direct = extractDirectReasoningText(data);
if (direct) return stripAnsi(direct);
const nested = asRecord(data.data) || asRecord(data.payload);
const nestedDirect = nested ? extractDirectReasoningText(nested) : "";
return nestedDirect ? stripAnsi(nestedDirect) : "";
}
function parseStdoutLine(line, ts) {
const cleaned = stripAnsi(line);
const trimmed = cleaned.trim();
@@ -41,7 +66,8 @@ function parseStdoutLine(line, ts) {
return [{ kind: "stderr", ts, text: message }];
}
if (eventName === "reasoning.available") {
return [{ kind: "thinking", ts, text: "Hermes reasoning available" }];
const reasoning = extractReasoningText(data);
return [{ kind: "thinking", ts, text: reasoning || "Hermes reasoning available" }];
}
return [{ kind: "system", ts, text: `Hermes event: ${eventName}` }];
}
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import { parseHermesGatewayStdoutLine } from "./parse-stdout.js";
const TS = "2026-07-08T12:00:00.000Z";
function eventLine(eventName: string, data: unknown): string {
return `[hermes-gateway:event] run=run-123 event=${eventName} data=${JSON.stringify(data)}`;
}
describe("parseHermesGatewayStdoutLine — reasoning.available payload extraction", () => {
// This is the assertion that FAILS on the old hardcoded-placeholder code
// and PASSES once the real reasoning text is extracted from `data`.
it("uses the real reasoning text from data.text instead of the hardcoded placeholder", () => {
const result = parseHermesGatewayStdoutLine(
eventLine("reasoning.available", { text: "Considering three approaches to the cache invalidation bug." }),
TS,
);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
kind: "thinking",
ts: TS,
text: "Considering three approaches to the cache invalidation bug.",
});
expect(result[0]).not.toHaveProperty("text", "Hermes reasoning available");
});
it("uses the real reasoning text from data.summary", () => {
const result = parseHermesGatewayStdoutLine(
eventLine("reasoning.available", { summary: "Weighing tradeoffs between two refactor strategies." }),
TS,
);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
kind: "thinking",
ts: TS,
text: "Weighing tradeoffs between two refactor strategies.",
});
});
it("recurses one level into a nested data.data record to find the reasoning text", () => {
const result = parseHermesGatewayStdoutLine(
eventLine("reasoning.available", { data: { text: "Nested reasoning payload from gateway wrapper." } }),
TS,
);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
kind: "thinking",
ts: TS,
text: "Nested reasoning payload from gateway wrapper.",
});
});
it("recurses one level into a nested data.payload record to find the reasoning text", () => {
const result = parseHermesGatewayStdoutLine(
eventLine("reasoning.available", { payload: { reasoning: "Nested via payload wrapper instead of data." } }),
TS,
);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
kind: "thinking",
ts: TS,
text: "Nested via payload wrapper instead of data.",
});
});
it("falls back to the placeholder when data has no recognizable text field (preserves bare-signal behavior)", () => {
const result = parseHermesGatewayStdoutLine(
eventLine("reasoning.available", { unrelatedField: 42 }),
TS,
);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ kind: "thinking", ts: TS, text: "Hermes reasoning available" });
});
it("falls back to the placeholder when data is entirely absent/unparseable", () => {
const result = parseHermesGatewayStdoutLine(
"[hermes-gateway:event] run=run-123 event=reasoning.available data=not-json",
TS,
);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ kind: "thinking", ts: TS, text: "Hermes reasoning available" });
});
});
describe("parseHermesGatewayStdoutLine — regression guards for unrelated handlers", () => {
it("still yields an assistant delta part for message.delta", () => {
const result = parseHermesGatewayStdoutLine(
eventLine("message.delta", { delta: "Hello there" }),
TS,
);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ kind: "assistant", ts: TS, text: "Hello there", delta: true });
});
it("still yields a stdout part for a plain non-event line", () => {
const result = parseHermesGatewayStdoutLine("just a plain line of output", TS);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ kind: "stdout", ts: TS, text: "just a plain line of output" });
});
});
@@ -27,6 +27,33 @@ function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
/**
* Pull the reasoning text out of a `reasoning.available` event payload.
* The exact field name is defined by the external Hermes gateway, so this
* checks the plausible field names (mirroring the fallback chain used by
* extractOutput() in gateway/server/execute.ts) and recurses one level into
* nested `data` / `payload` records.
*/
function extractDirectReasoningText(data: Record<string, unknown>): string {
return (
asString(data.reasoning).trim() ||
asString(data.reasoning_text).trim() ||
asString(data.thinking).trim() ||
asString(data.text).trim() ||
asString(data.summary).trim() ||
asString(data.content).trim()
);
}
function extractReasoningText(data: Record<string, unknown> | null): string {
if (!data) return "";
const direct = extractDirectReasoningText(data);
if (direct) return stripAnsi(direct);
const nested = asRecord(data.data) ?? asRecord(data.payload);
const nestedDirect = nested ? extractDirectReasoningText(nested) : "";
return nestedDirect ? stripAnsi(nestedDirect) : "";
}
export function parseHermesGatewayStdoutLine(line: string, ts: string): TranscriptEntry[] {
const cleaned = stripAnsi(line);
const trimmed = cleaned.trim();
@@ -45,7 +72,8 @@ export function parseHermesGatewayStdoutLine(line: string, ts: string): Transcri
return [{ kind: "stderr", ts, text: message }];
}
if (eventName === "reasoning.available") {
return [{ kind: "thinking", ts, text: "Hermes reasoning available" }];
const reasoning = extractReasoningText(data);
return [{ kind: "thinking", ts, text: reasoning || "Hermes reasoning available" }];
}
return [{ kind: "system", ts, text: `Hermes event: ${eventName}` }];
}
@@ -52,7 +52,7 @@
"dependencies": {
"@paperclipai/adapter-utils": "workspace:*",
"picocolors": "^1.1.1",
"ws": "^8.21.1"
"ws": "^8.21.3"
},
"devDependencies": {
"@types/node": "^22.20.1",
+1 -1
View File
@@ -55,7 +55,7 @@
"devDependencies": {
"@types/node": "^22.20.1",
"drizzle-kit": "^0.31.10",
"tsx": "^4.23.1",
"tsx": "^4.23.12",
"typescript": "^5.7.3",
"vitest": "^4.1.10"
}
@@ -1,10 +1,15 @@
import fs from "node:fs";
import { getTableConfig } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import { adapterAuthSessions } from "./schema/adapter_auth_sessions.js";
import {
ADAPTER_AUTH_SESSION_ACTIVE_STATES,
adapterAuthSessions,
} from "./schema/adapter_auth_sessions.js";
type PgTable = Parameters<typeof getTableConfig>[0];
const ACTIVE_INDEX = "adapter_auth_sessions_company_owner_adapter_active_uq";
function findIndex(table: PgTable, indexName: string) {
return getTableConfig(table).indexes.find((candidate) => candidate.config.name === indexName);
}
@@ -15,53 +20,114 @@ function indexColumns(table: PgTable, indexName: string): string[] {
return index.config.columns.map((column) => (column as { name: string }).name);
}
function columnIsNotNull(table: PgTable, columnName: string): boolean {
const column = getTableConfig(table).columns.find((candidate) => candidate.name === columnName);
return column?.notNull ?? false;
function column(table: PgTable, columnName: string) {
return getTableConfig(table).columns.find((candidate) => candidate.name === columnName);
}
// The migration that creates the table. The test locates it by content, so a
// later re-generation with a different name does not break the test.
function columnIsNotNull(table: PgTable, columnName: string): boolean {
return column(table, columnName)?.notNull ?? false;
}
// The migration that re-scopes the active index. The test locates it by content,
// so a later re-generation with a different name does not break the test.
function activeIndexMigrationSql(): string {
const migrationsDir = new URL("./migrations/", import.meta.url);
const files = fs.readdirSync(migrationsDir).filter((name) => name.endsWith(".sql"));
for (const name of files) {
const content = fs.readFileSync(new URL(name, migrationsDir), "utf8");
if (content.includes("adapter_auth_sessions_company_adapter_active_uq")) {
if (content.includes(ACTIVE_INDEX)) {
return content;
}
}
throw new Error("no migration creates adapter_auth_sessions_company_adapter_active_uq");
throw new Error(`no migration creates ${ACTIVE_INDEX}`);
}
describe("adapter auth sessions schema", () => {
it("serializes on the company credential slot over the active statuses", () => {
const active = findIndex(adapterAuthSessions, "adapter_auth_sessions_company_adapter_active_uq");
it("serializes the active slot on company, owner, and adapter", () => {
const active = findIndex(adapterAuthSessions, ACTIVE_INDEX);
expect(active).toBeDefined();
expect(active?.config.unique).toBe(true);
expect(indexColumns(adapterAuthSessions, "adapter_auth_sessions_company_adapter_active_uq")).toEqual([
expect(indexColumns(adapterAuthSessions, ACTIVE_INDEX)).toEqual([
"company_id",
"started_by_user_id",
"adapter_type",
]);
// The active index is partial; the where clause is present.
expect(active?.config.where).toBeDefined();
});
it("keeps the owner principal non-null and out of the uniqueness key", () => {
it("keeps the owner principal non-null and drops the environment from the key", () => {
expect(columnIsNotNull(adapterAuthSessions, "started_by_user_id")).toBe(true);
const activeColumns = indexColumns(
adapterAuthSessions,
"adapter_auth_sessions_company_adapter_active_uq",
);
expect(activeColumns).not.toContain("started_by_user_id");
const activeColumns = indexColumns(adapterAuthSessions, ACTIVE_INDEX);
expect(activeColumns).toContain("started_by_user_id");
expect(activeColumns).not.toContain("environment_id");
});
it("generates a partial unique index over the three active statuses", () => {
it("adds the public session id, non-null and unique", () => {
expect(column(adapterAuthSessions, "public_session_id")).toBeDefined();
expect(columnIsNotNull(adapterAuthSessions, "public_session_id")).toBe(true);
const uniqueOnPublicSessionId = findIndex(
adapterAuthSessions,
"adapter_auth_sessions_public_session_id_uq",
);
expect(uniqueOnPublicSessionId).toBeDefined();
expect(uniqueOnPublicSessionId?.config.unique).toBe(true);
expect(indexColumns(adapterAuthSessions, "adapter_auth_sessions_public_session_id_uq")).toEqual([
"public_session_id",
]);
});
it("adds the claim marker, nullable", () => {
// A row starts with no claim consumption. The service fills `bound_at` only
// when the create path consumes the stored claim.
expect(column(adapterAuthSessions, "bound_at")).toBeDefined();
expect(columnIsNotNull(adapterAuthSessions, "bound_at")).toBe(false);
});
it("merges the two state unions and drops the persisting state", () => {
expect([...ADAPTER_AUTH_SESSION_ACTIVE_STATES]).toEqual([
"starting",
"waiting_for_user",
"promoting",
"awaiting_code",
"submitting",
]);
// The removed state stays out of the active set.
expect([...ADAPTER_AUTH_SESSION_ACTIVE_STATES]).not.toContain("persisting");
// The one-time claim state and the terminal states do not hold the slot.
expect([...ADAPTER_AUTH_SESSION_ACTIVE_STATES]).not.toContain("stored");
expect([...ADAPTER_AUTH_SESSION_ACTIVE_STATES]).not.toContain("completed");
});
it("holds no secret-bearing column", () => {
// The durable store keeps only ids, the state, the lease reference, and the
// deadlines. It never holds the prompt, the login URL, the browser code, the
// token, or a process chunk.
const names = getTableConfig(adapterAuthSessions).columns.map((c) => c.name);
const forbidden = ["token", "code", "url", "secret", "credential", "prompt", "output"];
for (const name of names) {
for (const needle of forbidden) {
expect(name.includes(needle)).toBe(false);
}
}
});
it("generates the partial unique index over the merged active states", () => {
const sql = activeIndexMigrationSql();
expect(sql).toContain('CREATE UNIQUE INDEX "adapter_auth_sessions_company_adapter_active_uq"');
expect(sql).toContain('("company_id","adapter_type")');
expect(sql).toContain("'starting', 'waiting_for_user', 'promoting'");
expect(sql).toContain('"started_by_user_id" text NOT NULL');
expect(sql).toContain(`CREATE UNIQUE INDEX "${ACTIVE_INDEX}"`);
expect(sql).toContain('("company_id","started_by_user_id","adapter_type")');
expect(sql).toContain(
"'starting', 'waiting_for_user', 'promoting', 'awaiting_code', 'submitting'",
);
// The new non-null column and the unique index are in the migration.
expect(sql).toContain('"public_session_id" varchar(128) NOT NULL');
expect(sql).toContain(`CREATE UNIQUE INDEX "adapter_auth_sessions_public_session_id_uq"`);
// The removed state and the non-active states stay out of the predicate.
const predicate = sql.slice(sql.indexOf(ACTIVE_INDEX));
const whereClause = predicate.slice(predicate.indexOf("WHERE"), predicate.indexOf(";"));
expect(whereClause).not.toContain("'persisting'");
expect(whereClause).not.toContain("'stored'");
expect(whereClause).not.toContain("'completed'");
expect(whereClause).not.toContain("'authenticated'");
});
});
@@ -1,116 +0,0 @@
import fs from "node:fs";
import { getTableConfig } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import { claudeSetupTokenSessions } from "./schema/claude_setup_token_sessions.js";
type PgTable = Parameters<typeof getTableConfig>[0];
function findIndex(table: PgTable, indexName: string) {
return getTableConfig(table).indexes.find((candidate) => candidate.config.name === indexName);
}
function indexColumns(table: PgTable, indexName: string): string[] {
const index = findIndex(table, indexName);
if (!index) return [];
return index.config.columns.map((column) => (column as { name: string }).name);
}
function column(table: PgTable, columnName: string) {
return getTableConfig(table).columns.find((candidate) => candidate.name === columnName);
}
function columnIsNotNull(table: PgTable, columnName: string): boolean {
return column(table, columnName)?.notNull ?? false;
}
const ACTIVE_INDEX = "claude_setup_token_sessions_active_uq";
// The migration that creates the table. The test locates it by content, so a
// later re-generation with a different name does not break the test.
function activeIndexMigrationSql(): string {
const migrationsDir = new URL("./migrations/", import.meta.url);
const files = fs.readdirSync(migrationsDir).filter((name) => name.endsWith(".sql"));
for (const name of files) {
const content = fs.readFileSync(new URL(name, migrationsDir), "utf8");
if (content.includes(ACTIVE_INDEX)) {
return content;
}
}
throw new Error(`no migration creates ${ACTIVE_INDEX}`);
}
describe("claude setup token sessions schema", () => {
it("keeps the scope columns and the deadline non-null", () => {
expect(columnIsNotNull(claudeSetupTokenSessions, "session_id")).toBe(true);
expect(columnIsNotNull(claudeSetupTokenSessions, "company_id")).toBe(true);
expect(columnIsNotNull(claudeSetupTokenSessions, "owner_user_id")).toBe(true);
expect(columnIsNotNull(claudeSetupTokenSessions, "adapter_type")).toBe(true);
expect(columnIsNotNull(claudeSetupTokenSessions, "environment_id")).toBe(true);
expect(columnIsNotNull(claudeSetupTokenSessions, "state")).toBe(true);
expect(columnIsNotNull(claudeSetupTokenSessions, "deadline_at")).toBe(true);
});
it("keeps the claim marker and the lease reference nullable", () => {
// A row starts with no claim consumption and no lease. The service fills
// `bound_at` only when the create path consumes the stored claim.
expect(column(claudeSetupTokenSessions, "bound_at")).toBeDefined();
expect(columnIsNotNull(claudeSetupTokenSessions, "bound_at")).toBe(false);
expect(column(claudeSetupTokenSessions, "lease_id")).toBeDefined();
expect(columnIsNotNull(claudeSetupTokenSessions, "lease_id")).toBe(false);
});
it("references the company and the environment", () => {
const foreignKeys = getTableConfig(claudeSetupTokenSessions).foreignKeys;
const referencedTables = foreignKeys.map((fk) => fk.reference().foreignTable);
const referencedNames = referencedTables.map(
(table) => getTableConfig(table as PgTable).name,
);
expect(referencedNames).toContain("companies");
expect(referencedNames).toContain("environments");
});
it("holds no secret-bearing column", () => {
// The durable store keeps only ids, the state, and the deadlines. It never
// holds the login URL, the browser code, the token, or a process chunk.
const names = getTableConfig(claudeSetupTokenSessions).columns.map((c) => c.name);
const forbidden = ["token", "code", "url", "secret", "credential", "prompt", "output"];
for (const name of names) {
for (const needle of forbidden) {
expect(name.includes(needle)).toBe(false);
}
}
});
it("serializes the active session over the full owner scope", () => {
const active = findIndex(claudeSetupTokenSessions, ACTIVE_INDEX);
expect(active).toBeDefined();
expect(active?.config.unique).toBe(true);
expect(indexColumns(claudeSetupTokenSessions, ACTIVE_INDEX)).toEqual([
"company_id",
"owner_user_id",
"adapter_type",
"environment_id",
]);
// The active index is partial; the where clause is present.
expect(active?.config.where).toBeDefined();
});
it("scopes the active partial unique index to the active states only", () => {
const sql = activeIndexMigrationSql();
expect(sql).toContain(`CREATE UNIQUE INDEX "${ACTIVE_INDEX}"`);
expect(sql).toContain('("company_id","owner_user_id","adapter_type","environment_id")');
// The active states are in the predicate.
expect(sql).toContain("'starting'");
expect(sql).toContain("'awaiting_code'");
expect(sql).toContain("'submitting'");
expect(sql).toContain("'persisting'");
// The stored state and the terminal states stay out of the predicate.
const predicate = sql.slice(sql.indexOf(ACTIVE_INDEX));
const whereClause = predicate.slice(predicate.indexOf("WHERE"), predicate.indexOf(";"));
expect(whereClause).not.toContain("'stored'");
expect(whereClause).not.toContain("'completed'");
expect(whereClause).not.toContain("'failed'");
expect(whereClause).not.toContain("'timed_out'");
expect(whereClause).not.toContain("'cancelled'");
});
});
+17 -1
View File
@@ -35,13 +35,20 @@ function splitMigrationStatements(content: string): string[] {
}
export type MigrationState =
| { status: "upToDate"; tableCount: number; availableMigrations: string[]; appliedMigrations: string[] }
| {
status: "upToDate";
tableCount: number;
availableMigrations: string[];
appliedMigrations: string[];
journalEntryCount: number;
}
| {
status: "needsMigrations";
tableCount: number;
availableMigrations: string[];
appliedMigrations: string[];
pendingMigrations: string[];
journalEntryCount: number;
reason: "no-migration-journal-empty-db" | "no-migration-journal-non-empty-db" | "pending-migrations";
};
@@ -683,6 +690,7 @@ export async function inspectMigrations(url: string): Promise<MigrationState> {
availableMigrations,
appliedMigrations: [],
pendingMigrations: availableMigrations,
journalEntryCount: 0,
reason: "no-migration-journal-non-empty-db",
};
}
@@ -693,10 +701,16 @@ export async function inspectMigrations(url: string): Promise<MigrationState> {
availableMigrations,
appliedMigrations: [],
pendingMigrations: availableMigrations,
journalEntryCount: 0,
reason: "no-migration-journal-empty-db",
};
}
const qualifiedMigrationTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`;
const journalCountRows = await sql.unsafe<{ count: number }[]>(
`SELECT count(*)::int AS count FROM ${qualifiedMigrationTable}`,
);
const journalEntryCount = journalCountRows[0]?.count ?? 0;
const appliedMigrations = await loadAppliedMigrations(sql, migrationTableSchema, availableMigrations);
const pendingMigrations = availableMigrations.filter((name) => !appliedMigrations.includes(name));
if (pendingMigrations.length === 0) {
@@ -705,6 +719,7 @@ export async function inspectMigrations(url: string): Promise<MigrationState> {
tableCount,
availableMigrations,
appliedMigrations,
journalEntryCount,
};
}
@@ -714,6 +729,7 @@ export async function inspectMigrations(url: string): Promise<MigrationState> {
availableMigrations,
appliedMigrations,
pendingMigrations,
journalEntryCount,
reason: "pending-migrations",
};
} finally {
@@ -0,0 +1,77 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import { loadWithoutEmbeddedPostgresExitHooks } from "./embedded-postgres-lifecycle.js";
describe("loadWithoutEmbeddedPostgresExitHooks", () => {
it("removes every eager exit hook from the real embedded-postgres import", async () => {
const eventNames = [
"exit",
"beforeExit",
"SIGHUP",
"SIGINT",
"SIGTERM",
"SIGBREAK",
"message",
];
const before = new Map(eventNames.map((eventName) => [
eventName,
process.rawListeners(eventName),
]));
const moduleName = "embedded-postgres";
await loadWithoutEmbeddedPostgresExitHooks(() => import(moduleName));
for (const eventName of eventNames) {
expect(process.rawListeners(eventName)).toEqual(before.get(eventName));
}
});
it("removes dependency exit hooks while preserving existing listeners", async () => {
const target = new EventEmitter();
const existingSignalListener = vi.fn();
const existingExitListener = vi.fn();
target.on("SIGTERM", existingSignalListener);
target.on("exit", existingExitListener);
const dependencyListener = vi.fn();
const loaded = await loadWithoutEmbeddedPostgresExitHooks(
async () => {
for (const eventName of [
"exit",
"beforeExit",
"SIGHUP",
"SIGINT",
"SIGTERM",
"SIGBREAK",
"message",
]) {
target.on(eventName, dependencyListener);
}
return { default: class EmbeddedPostgres {} };
},
target,
);
expect(loaded.default.name).toBe("EmbeddedPostgres");
expect(target.rawListeners("SIGTERM")).toEqual([existingSignalListener]);
expect(target.rawListeners("exit")).toEqual([existingExitListener]);
for (const eventName of ["beforeExit", "SIGHUP", "SIGINT", "SIGBREAK", "message"]) {
expect(target.rawListeners(eventName)).toEqual([]);
}
});
it("cleans up listeners even when the import fails", async () => {
const target = new EventEmitter();
const dependencyListener = vi.fn();
await expect(loadWithoutEmbeddedPostgresExitHooks(
async () => {
target.on("SIGTERM", dependencyListener);
throw new Error("import failed");
},
target,
)).rejects.toThrow("import failed");
expect(target.rawListeners("SIGTERM")).toEqual([]);
});
});
@@ -0,0 +1,54 @@
const EMBEDDED_POSTGRES_EXIT_EVENTS = [
"exit",
"beforeExit",
"SIGHUP",
"SIGINT",
"SIGTERM",
"SIGBREAK",
"message",
] as const;
type EmbeddedPostgresExitTarget = {
rawListeners(eventName: string): Function[];
removeListener(eventName: string, listener: (...args: any[]) => void): unknown;
};
/**
* embedded-postgres installs async-exit-hook listeners as an import side effect.
* Paperclip-managed clusters have an explicit owner and shutdown path, so those
* global listeners must not be allowed to stop a cluster independently of that
* owner (for example while a worktree seed restore is still streaming).
*
* Remove only listeners added by the supplied import and preserve every listener
* that was already registered by Paperclip or its host process.
*/
export async function loadWithoutEmbeddedPostgresExitHooks<T>(
load: () => Promise<T>,
target: EmbeddedPostgresExitTarget = process,
): Promise<T> {
const listenersBeforeLoad = new Map(
EMBEDDED_POSTGRES_EXIT_EVENTS.map((eventName) => [
eventName,
target.rawListeners(eventName),
]),
);
let loaded: T;
try {
loaded = await load();
} finally {
for (const eventName of EMBEDDED_POSTGRES_EXIT_EVENTS) {
const remainingBeforeLoad = [...(listenersBeforeLoad.get(eventName) ?? [])];
for (const listener of target.rawListeners(eventName)) {
const existingIndex = remainingBeforeLoad.indexOf(listener);
if (existingIndex >= 0) {
remainingBeforeLoad.splice(existingIndex, 1);
continue;
}
target.removeListener(eventName, listener as (...args: any[]) => void);
}
}
}
return loaded;
}
+1
View File
@@ -35,6 +35,7 @@ export {
ensureLinuxSharedLibraryAliases,
prepareEmbeddedPostgresNativeRuntime,
} from "./embedded-postgres-native.js";
export { loadWithoutEmbeddedPostgresExitHooks } from "./embedded-postgres-lifecycle.js";
export { issueRelations } from "./schema/issue_relations.js";
export { issueReferenceMentions } from "./schema/issue_reference_mentions.js";
export * from "./schema/index.js";
@@ -0,0 +1,18 @@
-- Extend "adapter_auth_sessions" to hold both login flows. The migration adds
-- the public session id and the claim marker, and it re-scopes the active
-- credential slot to company + owner + adapter. The migration copies no rows
-- from "claude_setup_token_sessions"; that table stays in place for a later drop.
--
-- NOT NULL on a live table: "public_session_id" is a new non-null column with no
-- default, and "adapter_auth_sessions" can hold rows when the migration runs. The
-- rows are short-lived login sessions, and the board excluded deploy-window
-- support, so the migration deletes the existing rows instead of a backfill of a
-- generated value. A dropped login session re-starts on the next request. The
-- table stays small because the reaper removes finished rows, so the one-time
-- full delete is safe.
DELETE FROM "adapter_auth_sessions";--> statement-breakpoint
ALTER TABLE "adapter_auth_sessions" ADD COLUMN "public_session_id" varchar(128) NOT NULL;--> statement-breakpoint
ALTER TABLE "adapter_auth_sessions" ADD COLUMN "bound_at" timestamp with time zone;--> statement-breakpoint
DROP INDEX "adapter_auth_sessions_company_adapter_active_uq";--> statement-breakpoint
CREATE UNIQUE INDEX "adapter_auth_sessions_company_owner_adapter_active_uq" ON "adapter_auth_sessions" USING btree ("company_id","started_by_user_id","adapter_type") WHERE "adapter_auth_sessions"."status" IN ('starting', 'waiting_for_user', 'promoting', 'awaiting_code', 'submitting');--> statement-breakpoint
CREATE UNIQUE INDEX "adapter_auth_sessions_public_session_id_uq" ON "adapter_auth_sessions" USING btree ("public_session_id");
@@ -0,0 +1,5 @@
-- Drop the old Claude setup-token table. The unified "adapter_auth_sessions"
-- table now holds the Claude setup-token store for both login flows, so the
-- old table has no reader. Migration 0224 moved the columns and the active-slot
-- index onto "adapter_auth_sessions"; this migration removes the dead table.
DROP TABLE IF EXISTS "claude_setup_token_sessions";
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,6 @@
{
"id": "8553adfc-b438-4f01-b88f-b1100a9077d8",
"prevId": "d925c84b-41b6-42d1-a0f9-4ee090ef3cd1",
"id": "f5748a8a-c6ea-4d9c-b023-1cd12d812bc5",
"prevId": "5f26d1e4-4e78-4976-8a1f-1bd860931b8d",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -279,6 +279,12 @@
"primaryKey": false,
"notNull": true
},
"public_session_id": {
"name": "public_session_id",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"provider_lease_id": {
"name": "provider_lease_id",
"type": "text",
@@ -304,6 +310,12 @@
"primaryKey": false,
"notNull": false
},
"bound_at": {
"name": "bound_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"finished_at": {
"name": "finished_at",
"type": "timestamp with time zone",
@@ -353,8 +365,8 @@
"method": "btree",
"with": {}
},
"adapter_auth_sessions_company_adapter_active_uq": {
"name": "adapter_auth_sessions_company_adapter_active_uq",
"adapter_auth_sessions_company_owner_adapter_active_uq": {
"name": "adapter_auth_sessions_company_owner_adapter_active_uq",
"columns": [
{
"expression": "company_id",
@@ -362,6 +374,12 @@
"asc": true,
"nulls": "last"
},
{
"expression": "started_by_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "adapter_type",
"isExpression": false,
@@ -370,7 +388,22 @@
}
],
"isUnique": true,
"where": "\"adapter_auth_sessions\".\"status\" IN ('starting', 'waiting_for_user', 'promoting')",
"where": "\"adapter_auth_sessions\".\"status\" IN ('starting', 'waiting_for_user', 'promoting', 'awaiting_code', 'submitting')",
"concurrently": false,
"method": "btree",
"with": {}
},
"adapter_auth_sessions_public_session_id_uq": {
"name": "adapter_auth_sessions_public_session_id_uq",
"columns": [
{
"expression": "public_session_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
@@ -4536,187 +4569,6 @@
},
"isRLSEnabled": false
},
"public.claude_setup_token_sessions": {
"name": "claude_setup_token_sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"owner_user_id": {
"name": "owner_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"adapter_type": {
"name": "adapter_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"environment_id": {
"name": "environment_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"lease_id": {
"name": "lease_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'starting'"
},
"deadline_at": {
"name": "deadline_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"bound_at": {
"name": "bound_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"claude_setup_token_sessions_active_uq": {
"name": "claude_setup_token_sessions_active_uq",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "owner_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "adapter_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "environment_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"claude_setup_token_sessions\".\"state\" IN ('starting', 'awaiting_code', 'submitting', 'persisting')",
"concurrently": false,
"method": "btree",
"with": {}
},
"claude_setup_token_sessions_session_id_uq": {
"name": "claude_setup_token_sessions_session_id_uq",
"columns": [
{
"expression": "session_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"claude_setup_token_sessions_deadline_idx": {
"name": "claude_setup_token_sessions_deadline_idx",
"columns": [
{
"expression": "deadline_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"claude_setup_token_sessions_company_id_companies_id_fk": {
"name": "claude_setup_token_sessions_company_id_companies_id_fk",
"tableFrom": "claude_setup_token_sessions",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"claude_setup_token_sessions_environment_id_environments_id_fk": {
"name": "claude_setup_token_sessions_environment_id_environments_id_fk",
"tableFrom": "claude_setup_token_sessions",
"tableTo": "environments",
"columnsFrom": [
"environment_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.cli_auth_challenges": {
"name": "cli_auth_challenges",
"schema": "",
+16 -2
View File
@@ -1559,8 +1559,22 @@
{
"idx": 224,
"version": "7",
"when": 1787070369923,
"tag": "0224_flippant_goliath",
"when": 1787000754489,
"tag": "0224_unified_adapter_auth_sessions",
"breakpoints": true
},
{
"idx": 225,
"version": "7",
"when": 1787101144413,
"tag": "0225_drop_claude_setup_token_sessions",
"breakpoints": true
},
{
"idx": 226,
"version": "7",
"when": 1787173793193,
"tag": "0226_sharp_lilandra",
"breakpoints": true
}
]
+65 -10
View File
@@ -1,13 +1,47 @@
import { sql } from "drizzle-orm";
import { index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { index, pgTable, text, timestamp, uniqueIndex, uuid, varchar } from "drizzle-orm/pg-core";
import type { AdapterAuthSessionInternalStatus, AgentAdapterType } from "@paperclipai/shared";
import { companies } from "./companies.js";
import { environments } from "./environments.js";
// The merged state set for the unified login table. One column holds the state
// of both login flows: the device-login flow and the setup-token flow. The set
// joins the two prior state unions into one union. The set drops the dead
// `persisting` state.
//
// The active states hold the company credential slot for one company, owner, and
// adapter. The partial unique index applies to the active states only.
// - `starting`, `waiting_for_user`, `promoting`: the device-login active states.
// - `awaiting_code`, `submitting`: the setup-token active states.
// The non-active states do not hold the slot:
// - `stored`: the one-time setup-token claim. The create path consumes it and
// writes `bound_at`.
// - `authenticated`, `completed`: the terminal success states.
// - `cleanup_pending`: the terminal state whose sandbox delete failed.
// - `failed`, `timed_out`, `cancelled`: the shared terminal failure states.
export type AdapterAuthSessionState =
| AdapterAuthSessionInternalStatus
| "awaiting_code"
| "submitting"
| "stored"
| "completed";
// The active states of the merged set. The partial unique index and the store
// use this list. The list excludes `stored`, every terminal state, and the
// removed `persisting` state.
export const ADAPTER_AUTH_SESSION_ACTIVE_STATES = [
"starting",
"waiting_for_user",
"promoting",
"awaiting_code",
"submitting",
] as const satisfies readonly AdapterAuthSessionState[];
// The durable store for an adapter login session. One row tracks one login
// attempt for one adapter in one environment. The row keeps the owner principal,
// the provider lease reference, the status, and the finish times. The row never
// stores the prompt, a credential byte, or the raw provider secret.
// the public session id, the provider lease reference, the status, and the
// finish times. The row never stores the prompt, a credential byte, or the raw
// provider secret.
export const adapterAuthSessions = pgTable(
"adapter_auth_sessions",
{
@@ -17,12 +51,20 @@ export const adapterAuthSessions = pgTable(
adapterType: text("adapter_type").$type<AgentAdapterType>().notNull(),
// The immutable owner principal. The service sets this column one time at
// create and never updates it. The service returns the prompt only to this
// owner.
// owner. The active-slot index includes this column, so the column stays
// non-null; a null owner escapes a partial unique index.
startedByUserId: text("started_by_user_id").notNull(),
// The opaque public session id. The public API returns this id, and the
// store keys its lookups by it. The column is non-null, length-bounded, and
// unique. The store fills it at create with a CSPRNG value.
publicSessionId: varchar("public_session_id", { length: 128 }).notNull(),
// The provider lease reference for the sandbox. The reaper reads it to retry
// a failed sandbox delete. It is not a public field.
providerLeaseId: text("provider_lease_id"),
status: text("status").$type<AdapterAuthSessionInternalStatus>().notNull().default("starting"),
// The merged login state. The column is `text`, so it stores every value of
// the merged `AdapterAuthSessionState` set. The compile-time `$type` is the
// merged union, so both login stores share one status type.
status: text("status").$type<AdapterAuthSessionState>().notNull().default("starting"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
// The promotion claim deadline. The service sets this column when it moves the
// row to `promoting`. While the deadline is in the future, the claim is live,
@@ -31,6 +73,11 @@ export const adapterAuthSessions = pgTable(
// stalled `promoting` row. The service clears the column on every terminal
// transition.
promotionExpiresAt: timestamp("promotion_expires_at", { withTimezone: true }),
// The claim-consumption marker for a `stored` row. The column is null while
// the stored claim is live. The create path sets it one time when it consumes
// the claim. A non-null value marks a consumed claim, so the reaper removes
// the row. A device-login row never sets this column.
boundAt: timestamp("bound_at", { withTimezone: true }),
finishedAt: timestamp("finished_at", { withTimezone: true }),
// The fixed, non-secret failure code. The public response reads it.
failureReason: text("failure_reason"),
@@ -43,11 +90,19 @@ export const adapterAuthSessions = pgTable(
table.status,
),
// Serialize on the company credential slot. Only one active session can hold
// the slot per adapter. The index applies to the three active statuses. It
// does not include the owner or the environment; those stay columns only.
companyAdapterActiveUq: uniqueIndex("adapter_auth_sessions_company_adapter_active_uq")
.on(table.companyId, table.adapterType)
.where(sql`${table.status} IN ('starting', 'waiting_for_user', 'promoting')`),
// the slot per company, owner, and adapter. The index applies to the active
// states of the merged set. It does not include the environment; the board
// rule scopes the slot to the owner, not the environment.
companyOwnerAdapterActiveUq: uniqueIndex("adapter_auth_sessions_company_owner_adapter_active_uq")
.on(table.companyId, table.startedByUserId, table.adapterType)
.where(
sql`${table.status} IN ('starting', 'waiting_for_user', 'promoting', 'awaiting_code', 'submitting')`,
),
// The public session id is unique across the table. The store keys its
// lookups by this id.
publicSessionIdUq: uniqueIndex("adapter_auth_sessions_public_session_id_uq").on(
table.publicSessionId,
),
environmentIdx: index("adapter_auth_sessions_environment_idx").on(table.environmentId),
expiresIdx: index("adapter_auth_sessions_expires_idx").on(table.expiresAt),
providerLeaseIdx: index("adapter_auth_sessions_provider_lease_idx").on(table.providerLeaseId),
@@ -1,83 +0,0 @@
import { sql } from "drizzle-orm";
import { index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import type { AgentAdapterType } from "@paperclipai/shared";
import { companies } from "./companies.js";
import { environments } from "./environments.js";
/**
* The durable state of one Claude setup-token cleanup record. The store never
* reuses `adapter_auth_sessions`; this table is a dedicated, non-secret cleanup
* store. The active states hold a live login. The `stored` state marks a session
* that wrote the owner-bound secret and now persists as a one-time claim. The
* create path consumes the claim by setting `bound_at`. The reaper removes the
* row after `deadline_at`. The terminal states end the login.
*/
export type ClaudeSetupTokenSessionState =
| "starting"
| "awaiting_code"
| "submitting"
| "persisting"
| "stored"
| "completed"
| "failed"
| "timed_out"
| "cancelled";
// The active states hold the company credential slot. The partial unique index
// applies to these states only. It does not include `stored` or a terminal
// state, so a stored claim and a terminal record do not block a new login.
export const CLAUDE_SETUP_TOKEN_ACTIVE_STATES = [
"starting",
"awaiting_code",
"submitting",
"persisting",
] as const satisfies readonly ClaudeSetupTokenSessionState[];
// The durable store for one Claude setup-token cleanup record. One row tracks
// one login for one owner, one adapter, and one environment. The row keeps only
// ids, the state, the sandbox lease reference, and the deadlines. The row never
// holds the login URL, the browser code, the token, or a process chunk.
export const claudeSetupTokenSessions = pgTable(
"claude_setup_token_sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
// The opaque service session id. The store keys every update by this id.
sessionId: text("session_id").notNull(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
// The immutable owner principal. The service sets this column one time at
// create and never updates it.
ownerUserId: text("owner_user_id").notNull(),
adapterType: text("adapter_type").$type<AgentAdapterType>().notNull(),
environmentId: uuid("environment_id")
.notNull()
.references(() => environments.id, { onDelete: "cascade" }),
// The provider lease reference for the sandbox. The reaper reads it to
// release a lease that a crash or a failure left behind. It is not a secret.
leaseId: text("lease_id"),
state: text("state").$type<ClaudeSetupTokenSessionState>().notNull().default("starting"),
// The login deadline. The reaper removes the row after this time. The
// claim-consumption write also checks this column with `clock_timestamp()`.
deadlineAt: timestamp("deadline_at", { withTimezone: true }).notNull(),
// The claim-consumption marker. It is null while the stored claim is live.
// The create path sets it one time when it consumes the claim. A non-null
// value marks a consumed claim, so the reaper removes the row.
boundAt: timestamp("bound_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
// Serialize the active login on the full owner scope. Only one active
// session can run per company, owner, adapter, and environment. The index
// applies to the active states only; it excludes `stored` and every terminal
// state, so a stored claim and a terminal record never block a new login.
activeUq: uniqueIndex("claude_setup_token_sessions_active_uq")
.on(table.companyId, table.ownerUserId, table.adapterType, table.environmentId)
.where(sql`${table.state} IN ('starting', 'awaiting_code', 'submitting', 'persisting')`),
// The store keys every update by the opaque session id, so it stays unique.
sessionIdUq: uniqueIndex("claude_setup_token_sessions_session_id_uq").on(table.sessionId),
// The reaper scans by the deadline to remove an expired record.
deadlineIdx: index("claude_setup_token_sessions_deadline_idx").on(table.deadlineAt),
}),
);
-5
View File
@@ -35,11 +35,6 @@ export { environmentLeases } from "./environment_leases.js";
export { environmentCustomImageTemplates } from "./environment_custom_image_templates.js";
export { environmentCustomImageSetupSessions } from "./environment_custom_image_setup_sessions.js";
export { adapterAuthSessions } from "./adapter_auth_sessions.js";
export {
claudeSetupTokenSessions,
CLAUDE_SETUP_TOKEN_ACTIVE_STATES,
type ClaudeSetupTokenSessionState,
} from "./claude_setup_token_sessions.js";
export { workspaceOperations } from "./workspace_operations.js";
export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
export { projectGoals } from "./project_goals.js";
+1
View File
@@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});
@@ -0,0 +1,155 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
// The plugin module imports `@daytonaio/sdk` as a value, but the sync tests never
// touch a real Daytona client — every sandbox call goes through a local mock. Stub
// the SDK so the import resolves without the excluded provider package.
import { vi } from "vitest";
vi.mock("@daytonaio/sdk", () => ({
Daytona: class MockDaytona {},
DaytonaNotFoundError: class MockDaytonaNotFoundError extends Error {},
DaytonaTimeoutError: class MockDaytonaTimeoutError extends Error {},
}));
import { performSyncIn } from "./file-sync.js";
import type { PluginSyncOperation } from "@paperclipai/plugin-sdk";
// One recorded in-sandbox command, so a test can assert the exact cleanup command.
interface RecordedCommand {
command: string;
}
// Build a mock Daytona sandbox for the inbound directory path. `executeCommand`
// records every command and returns exit 0, except that any command whose text
// matches `failCommandMatch` returns exit 1 (to simulate an extract failure).
// `uploadFiles` records each upload destination so a test can read the reserved
// scratch tar path the runtime chose.
function createMockSandbox(input: {
failCommandMatch?: RegExp;
uploadedDestinations: string[];
commands: RecordedCommand[];
}) {
return {
process: {
executeCommand: async (command: string) => {
input.commands.push({ command });
if (input.failCommandMatch && input.failCommandMatch.test(command)) {
return { exitCode: 1, result: "simulated extract failure" };
}
return { exitCode: 0, result: "" };
},
},
fs: {
uploadFiles: async (uploads: Array<{ source: string; destination: string }>) => {
for (const upload of uploads) input.uploadedDestinations.push(upload.destination);
},
},
};
}
describe("daytona file-sync inbound scratch cleanup", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
it("removes the reserved scratch tar when a directory extraction fails", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-daytona-scratch-"));
cleanupDirs.push(rootDir);
const sourceDir = path.join(rootDir, "referenced-project");
await fs.mkdir(sourceDir, { recursive: true });
await fs.writeFile(path.join(sourceDir, "README.md"), "referenced project\n", "utf8");
const remoteDir = "/workspace";
const targetPath = "/workspace/.paperclip-runtime/test-adapter/project-abc";
const uploadedDestinations: string[] = [];
const commands: RecordedCommand[] = [];
// Fail the extract round trip (the only command that runs `tar -xf`).
const sandbox = createMockSandbox({
failCommandMatch: /tar -xf/,
uploadedDestinations,
commands,
});
const operations: PluginSyncOperation[] = [{
operationId: "sync-op-1",
files: [{ sourcePath: sourceDir, targetPath, kind: "directory" }],
}];
await expect(
performSyncIn({
// The mock stands in for the Daytona SDK Sandbox; only the two methods the
// inbound directory path calls are needed.
sandbox: sandbox as never,
operations,
remoteDir,
timeoutSeconds: 30,
}),
).rejects.toThrow(/syncIn extract/);
// The runtime uploaded exactly one reserved scratch tar under the workspace
// root. Its name carries the reserved `.paperclip-upload-` prefix.
expect(uploadedDestinations).toHaveLength(1);
const scratchTar = uploadedDestinations[0];
expect(scratchTar).toContain(".paperclip-upload-");
expect(scratchTar.startsWith(`${remoteDir}/`)).toBe(true);
// The failure path swept the scratch tar: a standalone `rm -f` of the exact
// scratch path ran after the failed extract. The extract command itself also
// contains an `rm -f`, so the cleanup is the `rm -f` command that does NOT run
// `tar -xf`.
const cleanupCommands = commands.filter(
(entry) =>
entry.command.includes(scratchTar) &&
entry.command.includes("rm -f") &&
!entry.command.includes("tar -xf"),
);
expect(cleanupCommands.length).toBeGreaterThan(0);
});
it("does not sweep scratch on the happy path (extract removes it)", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-daytona-scratch-"));
cleanupDirs.push(rootDir);
const sourceDir = path.join(rootDir, "referenced-project");
await fs.mkdir(sourceDir, { recursive: true });
await fs.writeFile(path.join(sourceDir, "README.md"), "referenced project\n", "utf8");
const remoteDir = "/workspace";
const targetPath = "/workspace/.paperclip-runtime/test-adapter/project-abc";
const uploadedDestinations: string[] = [];
const commands: RecordedCommand[] = [];
// No failure: every command succeeds, so the extract's own `rm -f` clears the
// scratch and no extra cleanup round trip runs.
const sandbox = createMockSandbox({ uploadedDestinations, commands });
const operations: PluginSyncOperation[] = [{
operationId: "sync-op-1",
files: [{ sourcePath: sourceDir, targetPath, kind: "directory" }],
}];
await performSyncIn({
sandbox: sandbox as never,
operations,
remoteDir,
timeoutSeconds: 30,
});
const scratchTar = uploadedDestinations[0];
// The standalone cleanup command (a `rm -f` without `tar -xf`) never runs on the
// happy path — only the extract command, which ends with its own `rm -f`.
const standaloneRemoves = commands.filter(
(entry) =>
entry.command.includes(scratchTar) &&
entry.command.includes("rm -f") &&
!entry.command.includes("tar -xf"),
);
expect(standaloneRemoves).toHaveLength(0);
});
});
@@ -24,6 +24,10 @@ const SPAN_ATTR = {
packWallMs: `${SPAN_ATTR_PREFIX}pack.wall_ms`,
transferWallMs: `${SPAN_ATTR_PREFIX}transfer.wall_ms`,
transferGuardCount: `${SPAN_ATTR_PREFIX}transfer.guard.count`,
// The transfer direction: `inbound` for an upload to the sandbox, `outbound`
// for a download from the sandbox. Operation identity comes from the parent
// span, so the transfer span never carries an operation label.
transferDirection: `${SPAN_ATTR_PREFIX}transfer.direction`,
} as const;
/** The value of `SpanStatusCode.ERROR` in `@opentelemetry/api`. The plugin stays
@@ -532,7 +536,10 @@ async function syncInFileMappings(input: {
await withProviderSpan({
name: "transfer",
wallMsAttr: SPAN_ATTR.transferWallMs,
attributes: { [SPAN_ATTR.transferGuardCount]: guardRoundTrips },
attributes: {
[SPAN_ATTR.transferGuardCount]: guardRoundTrips,
[SPAN_ATTR.transferDirection]: "inbound",
},
run: () => sandbox.fs.uploadFiles(uploads, timeoutSeconds),
});
@@ -655,52 +662,67 @@ async function syncInDirectoryMapping(input: {
}),
});
guardRoundTrips += 1;
// `transfer` span: the real byte upload — `sandbox.fs.uploadFiles`.
await withProviderSpan({
name: "transfer",
wallMsAttr: SPAN_ATTR.transferWallMs,
attributes: { [SPAN_ATTR.transferGuardCount]: guardRoundTrips },
run: () =>
sandbox.fs.uploadFiles([{ source: archivePath, destination: remoteTar }], timeoutSeconds),
});
// Bind validation and extraction into ONE sandbox invocation, then extract into
// an OPEN directory inode rather than a path string. `exec 9<"$_pc_real"` itself
// walks every ancestor of `$_pc_real` during the `open()` syscall, so a sandbox
// process that swaps an ancestor component for a symlink AFTER `_pc_resolve`
// returns but BEFORE the `open()` resolves would leave fd 9 pointing at a
// directory outside the workspace — the earlier `case` check on the resolved
// string cannot see that. Close the gap with open-then-verify: open fd 9 (which
// PINS whatever inode `open()` landed on), then re-canonicalize `/proc/self/fd/9`
// — the pinned inode's own path — and confirm it is still inside `$_pc_root`
// before extracting. If an ancestor swap redirected the open, the pinned inode
// resolves outside the root and the verify fails closed (exit 42); once the
// verify passes, the inode is fixed and `tar -C /proc/self/fd/9` chdir's through
// the magic symlink to that exact inode, so a post-open ancestor swap cannot
// redirect the write. (The initial `case` on `$_pc_real` still fails fast on a
// pre-open escape; the fd re-verify is what makes the guarantee race-free.)
const extractScript = [
...canonicalizerPreamble(shellQuote(remoteDir)),
`_pc_real=$(_pc_resolve ${shellQuote(mapping.targetPath)}) || { echo "ESCAPE"; exit 42; };`,
`case "$_pc_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE"; exit 42 ;; esac;`,
`exec 9<"$_pc_real" || { echo "open failed"; exit 46; };`,
`_pc_fd_real=$(_pc_resolve /proc/self/fd/9) || { echo "ESCAPE"; exit 42; };`,
`case "$_pc_fd_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE"; exit 42 ;; esac;`,
`tar -xf ${shellQuote(remoteTar)} -C /proc/self/fd/9 || { echo "extract failed"; exit 43; };`,
`exec 9>&-;`,
`rm -f ${shellQuote(remoteTar)};`,
].join("\n");
// `extractTarball` span: one round trip — re-check the path, `tar -xf`, and
// remove the scratch tarball.
await withProviderSpan({
name: "extractTarball",
run: () =>
assertSandboxCommandOk(
sandbox,
`sh -c ${shellQuote(extractScript)}`,
timeoutSeconds,
"syncIn extract",
),
});
// The uploaded scratch tar lands at the workspace root as a reserved
// `.paperclip-upload-*` entry. The extract script below removes it only on
// success. On an upload or extract failure the scratch tar can remain, and the
// runtime workspace wipe preserves every `.paperclip-upload-*` entry, so a
// stale tar would surface in the agent workspace. Sweep the scratch on any
// failure — symmetric with the file-mapping path — so a failed sync (for
// example a referenced-project extraction) leaves no residue.
try {
// `transfer` span: the real byte upload — `sandbox.fs.uploadFiles`.
await withProviderSpan({
name: "transfer",
wallMsAttr: SPAN_ATTR.transferWallMs,
attributes: {
[SPAN_ATTR.transferGuardCount]: guardRoundTrips,
[SPAN_ATTR.transferDirection]: "inbound",
},
run: () =>
sandbox.fs.uploadFiles([{ source: archivePath, destination: remoteTar }], timeoutSeconds),
});
// Bind validation and extraction into ONE sandbox invocation, then extract into
// an OPEN directory inode rather than a path string. `exec 9<"$_pc_real"` itself
// walks every ancestor of `$_pc_real` during the `open()` syscall, so a sandbox
// process that swaps an ancestor component for a symlink AFTER `_pc_resolve`
// returns but BEFORE the `open()` resolves would leave fd 9 pointing at a
// directory outside the workspace — the earlier `case` check on the resolved
// string cannot see that. Close the gap with open-then-verify: open fd 9 (which
// PINS whatever inode `open()` landed on), then re-canonicalize `/proc/self/fd/9`
// — the pinned inode's own path — and confirm it is still inside `$_pc_root`
// before extracting. If an ancestor swap redirected the open, the pinned inode
// resolves outside the root and the verify fails closed (exit 42); once the
// verify passes, the inode is fixed and `tar -C /proc/self/fd/9` chdir's through
// the magic symlink to that exact inode, so a post-open ancestor swap cannot
// redirect the write. (The initial `case` on `$_pc_real` still fails fast on a
// pre-open escape; the fd re-verify is what makes the guarantee race-free.)
const extractScript = [
...canonicalizerPreamble(shellQuote(remoteDir)),
`_pc_real=$(_pc_resolve ${shellQuote(mapping.targetPath)}) || { echo "ESCAPE"; exit 42; };`,
`case "$_pc_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE"; exit 42 ;; esac;`,
`exec 9<"$_pc_real" || { echo "open failed"; exit 46; };`,
`_pc_fd_real=$(_pc_resolve /proc/self/fd/9) || { echo "ESCAPE"; exit 42; };`,
`case "$_pc_fd_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE"; exit 42 ;; esac;`,
`tar -xf ${shellQuote(remoteTar)} -C /proc/self/fd/9 || { echo "extract failed"; exit 43; };`,
`exec 9>&-;`,
`rm -f ${shellQuote(remoteTar)};`,
].join("\n");
// `extractTarball` span: one round trip — re-check the path, `tar -xf`, and
// remove the scratch tarball.
await withProviderSpan({
name: "extractTarball",
run: () =>
assertSandboxCommandOk(
sandbox,
`sh -c ${shellQuote(extractScript)}`,
timeoutSeconds,
"syncIn extract",
),
});
} catch (error) {
await removeSandboxScratch(sandbox, [remoteTar], timeoutSeconds);
throw error;
}
const filesTransferred = await countHostFiles(mapping.sourcePath, mapping.exclude);
return { filesTransferred, bytesTransferred };
});
@@ -873,10 +895,25 @@ async function syncOutFileMappings(input: {
throw error;
}
// Count the serial sandbox round trips before the transfer, so the transfer
// span records how much of the wall time is guard cost. The validate-and-
// snapshot step is one sandbox round trip. This is symmetric with the inbound
// transfer span.
const guardRoundTrips = 1;
let responses: FileDownloadResponse[];
try {
// One batched bulk download for all file mappings, reading the snapshots.
responses = await sandbox.fs.downloadFiles(requests, timeoutSeconds);
// `transfer` span: the real byte download — `sandbox.fs.downloadFiles`.
responses = await withProviderSpan({
name: "transfer",
wallMsAttr: SPAN_ATTR.transferWallMs,
attributes: {
[SPAN_ATTR.transferGuardCount]: guardRoundTrips,
[SPAN_ATTR.transferDirection]: "outbound",
},
run: () => sandbox.fs.downloadFiles(requests, timeoutSeconds),
});
} catch (error) {
await cleanup();
throw error;
@@ -926,6 +963,9 @@ async function syncOutDirectoryMapping(input: {
}): Promise<{ filesTransferred: number; bytesTransferred: number }> {
const { sandbox, mapping, remoteDir, timeoutSeconds } = input;
assertConfinedSandboxPath(remoteDir, mapping.sourcePath, "source");
// Count the serial sandbox round trips before the transfer, so the transfer
// span records how much of the wall time is guard cost.
let guardRoundTrips = 0;
await assertSandboxPathsConfined({
sandbox,
remoteDir,
@@ -933,6 +973,7 @@ async function syncOutDirectoryMapping(input: {
timeoutSeconds,
label: "outbound symlink-escape guard",
});
guardRoundTrips += 1;
return withHostTempDir(async (tmp) => {
const remoteTar = path.posix.join(remoteDir, scratchName(".tar"));
@@ -951,14 +992,22 @@ async function syncOutDirectoryMapping(input: {
`else tar -c --no-xattrs ${mapping.followSymlinks ? "-h " : ""}${excludeFlags} -f ${shellQuote(remoteTar)} -- "$@"; fi`,
].join(" && ");
await assertSandboxCommandOk(sandbox, `sh -c ${shellQuote(tarScript)}`, timeoutSeconds, "syncOut tar");
guardRoundTrips += 1;
const localTar = path.join(tmp, "sync-out.tar");
let bytesTransferred = 0;
try {
const responses = await sandbox.fs.downloadFiles(
[{ source: remoteTar, destination: localTar }],
timeoutSeconds,
);
// `transfer` span: the real byte download — `sandbox.fs.downloadFiles`.
const responses = await withProviderSpan({
name: "transfer",
wallMsAttr: SPAN_ATTR.transferWallMs,
attributes: {
[SPAN_ATTR.transferGuardCount]: guardRoundTrips,
[SPAN_ATTR.transferDirection]: "outbound",
},
run: () =>
sandbox.fs.downloadFiles([{ source: remoteTar, destination: localTar }], timeoutSeconds),
});
const response = responses.find((entry) => entry.source === remoteTar) ?? responses[0];
if (!response || response.error) {
throw new Error(
@@ -1,10 +1,16 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const PLUGIN_ID = "paperclip.daytona-sandbox-provider";
// 0.1.2 adds `supportsSetupTokenLogin` to the driver. The version bump makes
// the bundled-plugin boot reconcile refresh the persisted manifest for an
// existing install, so the Claude setup-token login capability propagates.
const PLUGIN_VERSION = "0.1.2";
// The bundled-plugin boot reconcile refreshes the persisted manifest for an
// existing install only when PLUGIN_VERSION changes. A manifest change without a
// version bump never reaches an existing install. The reconcile also reads the
// persisted manifest raw and does not re-run the validator, so it never
// canonicalizes a renamed capability.
//
// 0.1.3 renamed the login transport flag from `supportsSetupTokenLogin` to the
// neutral `supportsLoginPty`.
// 0.1.4 adds the `concurrentSyncOperations` sandbox capability to the driver.
const PLUGIN_VERSION = "0.1.4";
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
@@ -31,8 +37,16 @@ const manifest: PaperclipPluginManifestV1 = {
// emits incremental session output while the command runs. Declare the
// opt-in capability so the host selects the session-output streaming path.
// A generic one-shot provider that omits this key keeps the poll path.
//
// Daytona also runs file transfers into and out of the sandbox in parallel.
// Each concurrent sync hook call uses separate temporary state (random
// scratch names and per-mapping host temporary directories), and teardown
// waits for all active calls. Declare the opt-in capability so the host may
// schedule sync operations concurrently. The host resolves it `true` only
// when the worker also verifies both sync verbs.
sandboxCapabilities: {
incrementalSessionOutput: true,
concurrentSyncOperations: true,
},
supportsInteractiveSetup: true,
interactiveSetupConnectionTypes: ["ssh"],
@@ -44,10 +58,10 @@ const manifest: PaperclipPluginManifestV1 = {
},
templateIdentityPaths: ["apiUrl"],
supportsTemplateDelete: true,
// Daytona hosts the Claude setup-token login on a real pseudo-terminal.
// It is the only bundled provider that implements the setup-token
// pseudo-terminal methods, so it advertises the capability.
supportsSetupTokenLogin: true,
// Daytona hosts an interactive login on a real pseudo-terminal. It is the
// only bundled provider that implements the login pseudo-terminal methods,
// so it advertises the capability.
supportsLoginPty: true,
configSchema: {
type: "object",
properties: {
@@ -139,6 +139,15 @@ describe("Daytona sandbox provider plugin", () => {
});
});
it("declares the concurrent-sync-operations capability so the host may parallelize sync operations", () => {
// Daytona runs file transfers into and out of the sandbox in parallel, so it
// declares the opt-in capability. The host resolves it `true` only when the
// worker also verifies both sync verbs, which the sync hooks provide.
expect(manifest.environmentDrivers?.[0]?.sandboxCapabilities).toMatchObject({
concurrentSyncOperations: true,
});
});
it("normalizes config and validates the API key fallback", async () => {
process.env.DAYTONA_API_KEY = "host-key";
@@ -3014,6 +3023,95 @@ describe("daytona native file-sync hooks", () => {
};
}
// A concurrency gate for a fake transfer call (uploadFiles / downloadFiles).
// The gate lets two concurrent hook calls both enter the fake, then holds them
// there until the test releases them. It records the peak number of calls that
// are in the fake at the same time, so a test proves the two calls overlap.
//
// `expected` is how many calls the test starts. `bothArrived` resolves once
// that many calls sit inside the fake at the same moment. `release()` frees
// them. `body` is the fake implementation: it marks arrival, waits for the
// release, and then runs `onRelease` to produce the fake result.
function createTransferGate<T>(expected: number, onRelease: (args: unknown[]) => Promise<T>) {
let inFlight = 0;
let peakInFlight = 0;
let signalArrived!: () => void;
const bothArrived = new Promise<void>((resolve) => {
signalArrived = resolve;
});
let signalReleased!: () => void;
const released = new Promise<void>((resolve) => {
signalReleased = resolve;
});
const body = async (...args: unknown[]): Promise<T> => {
inFlight += 1;
peakInFlight = Math.max(peakInFlight, inFlight);
if (inFlight === expected) signalArrived();
await released;
inFlight -= 1;
return onRelease(args);
};
return {
body,
bothArrived,
release: () => signalReleased(),
peak: () => peakInFlight,
};
}
// Write each download request's snapshot bytes to its host destination and
// report success, matching the real batch-download contract the outbound sync
// path expects.
async function fulfilDownload(args: unknown[]): Promise<Array<{ source: string; result: string }>> {
const requests = args[0] as Array<{ source: string; destination: string }>;
return Promise.all(
requests.map(async (request) => {
await fs.writeFile(request.destination, "bytes");
return { source: request.source, result: request.destination };
}),
);
}
function syncInParams(overrides: {
operationId: string;
sourcePath: string;
targetPath: string;
}) {
return {
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: overrides.operationId,
files: [{ sourcePath: overrides.sourcePath, targetPath: overrides.targetPath, kind: "file" as const }],
},
],
};
}
function syncOutParams(overrides: {
operationId: string;
sourcePath: string;
targetPath: string;
}) {
return {
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: overrides.operationId,
files: [{ sourcePath: overrides.sourcePath, targetPath: overrides.targetPath, kind: "file" as const }],
},
],
};
}
beforeEach(() => {
mockGet.mockReset();
process.env.DAYTONA_API_KEY = "host-key";
@@ -3295,6 +3393,66 @@ describe("daytona native file-sync hooks", () => {
expect(spans.find((span) => span.name === "pack")).toBeUndefined();
});
it("marks the inbound transfer span with the inbound direction attribute", async () => {
const hostDir = await makeHostDir();
const source = path.join(hostDir, "config.txt");
await fs.writeFile(source, "plain");
const sandbox = createMockSandbox();
mockGet.mockResolvedValue(sandbox);
const { tracer, spans } = createRecordingPluginTracer();
const restore = __setDaytonaPluginContextForTest({ tracer } as unknown as PluginContext);
try {
await plugin.definition.onEnvironmentSyncIn?.(
syncInParams({
operationId: "sync-op-in-dir",
sourcePath: source,
targetPath: `${REMOTE_DIR}/config.txt`,
}),
);
} finally {
restore();
}
// An upload to the sandbox is an inbound transfer.
const transfer = spans.find((span) => span.name === "transfer");
expect(transfer).toBeDefined();
expect(transfer!.attributes["paperclip.sandbox.startup.transfer.direction"]).toBe("inbound");
});
it("marks the outbound transfer span with the outbound direction attribute", async () => {
const hostDir = await makeHostDir();
const sandbox = createMockSandbox();
sandbox.fs.downloadFiles.mockImplementation(async (requests: Array<{ source: string; destination?: string }>) => {
return Promise.all(
requests.map(async (req) => {
await fs.writeFile(req.destination!, "bytes");
return { source: req.source, result: req.destination };
}),
);
});
mockGet.mockResolvedValue(sandbox);
const { tracer, spans } = createRecordingPluginTracer();
const restore = __setDaytonaPluginContextForTest({ tracer } as unknown as PluginContext);
try {
await plugin.definition.onEnvironmentSyncOut?.(
syncOutParams({
operationId: "sync-op-out-dir",
sourcePath: `${REMOTE_DIR}/out/result.txt`,
targetPath: path.join(hostDir, "result.txt"),
}),
);
} finally {
restore();
}
// A download from the sandbox is an outbound transfer.
const transfer = spans.find((span) => span.name === "transfer");
expect(transfer).toBeDefined();
expect(transfer!.attributes["paperclip.sandbox.startup.transfer.direction"]).toBe("outbound");
});
it("opens a pack span and a transfer span around a directory mapping sync", async () => {
const hostDir = await makeHostDir();
const sourceDir = path.join(hostDir, "assets");
@@ -4530,6 +4688,239 @@ describe("daytona native file-sync hooks", () => {
});
expect(withEmpty.process.executeCommand.mock.calls.length).toBe(baselineExecCount);
});
it("runs two concurrent inbound syncIn calls with separate reserved scratch names", async () => {
const hostDir = await makeHostDir();
const sourceA = path.join(hostDir, "a.txt");
const sourceB = path.join(hostDir, "b.txt");
await fs.writeFile(sourceA, "alpha");
await fs.writeFile(sourceB, "beta");
const sandbox = createMockSandbox();
// Gate the upload so both concurrent calls sit inside uploadFiles together.
const gate = createTransferGate(2, async () => undefined);
sandbox.fs.uploadFiles.mockImplementation(gate.body);
mockGet.mockResolvedValue(sandbox);
const callA = plugin.definition.onEnvironmentSyncIn?.(
syncInParams({ operationId: "in-a", sourcePath: sourceA, targetPath: `${REMOTE_DIR}/a.txt` }),
);
const callB = plugin.definition.onEnvironmentSyncIn?.(
syncInParams({ operationId: "in-b", sourcePath: sourceB, targetPath: `${REMOTE_DIR}/b.txt` }),
);
// Both calls reached the upload before either finished, so they overlap.
await gate.bothArrived;
expect(gate.peak()).toBe(2);
expect(sandbox.fs.uploadFiles).toHaveBeenCalledTimes(2);
gate.release();
await Promise.all([callA, callB]);
// Each concurrent call staged its upload under its own reserved scratch name;
// the two calls never share a temporary destination.
const destinations = sandbox.fs.uploadFiles.mock.calls.flatMap(
([uploads]) => (uploads as Array<{ destination: string }>).map((upload) => upload.destination),
);
expect(destinations).toHaveLength(2);
for (const destination of destinations) {
expect(path.posix.basename(destination)).toMatch(/^\.paperclip-upload-/);
expect(path.posix.dirname(destination)).toBe(REMOTE_DIR);
}
expect(new Set(destinations).size).toBe(destinations.length);
});
it("runs two concurrent outbound syncOut calls that both reach downloadFiles before either opens", async () => {
const hostDir = await makeHostDir();
const targetA = path.join(hostDir, "a.txt");
const targetB = path.join(hostDir, "b.txt");
const sandbox = createMockSandbox();
// Gate the download so both concurrent calls sit inside downloadFiles
// together before either resolves.
const gate = createTransferGate(2, fulfilDownload);
sandbox.fs.downloadFiles.mockImplementation(gate.body);
mockGet.mockResolvedValue(sandbox);
const callA = plugin.definition.onEnvironmentSyncOut?.(
syncOutParams({ operationId: "out-a", sourcePath: `${REMOTE_DIR}/a.txt`, targetPath: targetA }),
);
const callB = plugin.definition.onEnvironmentSyncOut?.(
syncOutParams({ operationId: "out-b", sourcePath: `${REMOTE_DIR}/b.txt`, targetPath: targetB }),
);
// Both calls reached the download before either gate opened, so they overlap.
await gate.bothArrived;
expect(gate.peak()).toBe(2);
expect(sandbox.fs.downloadFiles).toHaveBeenCalledTimes(2);
gate.release();
await Promise.all([callA, callB]);
// Each concurrent call read its own reserved snapshot; the two calls never
// share a download source.
const sources = sandbox.fs.downloadFiles.mock.calls.flatMap(
([requests]) => (requests as Array<{ source: string }>).map((request) => request.source),
);
expect(sources).toHaveLength(2);
for (const source of sources) {
expect(source.startsWith(`${REMOTE_DIR}/`)).toBe(true);
expect(path.posix.basename(source)).toMatch(/^\.paperclip-upload-/);
}
expect(new Set(sources).size).toBe(sources.length);
expect(await fs.readFile(targetA, "utf8")).toBe("bytes");
expect(await fs.readFile(targetB, "utf8")).toBe("bytes");
});
it("waits for one active inbound and one active outbound call before teardown releases the sandbox", async () => {
const hostDir = await makeHostDir();
const inboundSource = path.join(hostDir, "in.txt");
const outboundTarget = path.join(hostDir, "out.txt");
await fs.writeFile(inboundSource, "inbound");
const sandbox = createMockSandbox({ id: "sandbox-123" });
// Hold the inbound upload and the outbound download open at the same time, so
// the shared lease has two active sync calls when teardown starts.
let releaseUpload!: () => void;
sandbox.fs.uploadFiles.mockImplementation(async () => {
await new Promise<void>((resolve) => {
releaseUpload = resolve;
});
});
let releaseDownload!: () => void;
sandbox.fs.downloadFiles.mockImplementation(async (requests: Array<{ source: string; destination: string }>) => {
await new Promise<void>((resolve) => {
releaseDownload = resolve;
});
return Promise.all(
requests.map(async (request) => {
await fs.writeFile(request.destination, "bytes");
return { source: request.source, result: request.destination };
}),
);
});
mockGet.mockResolvedValue(sandbox);
const inboundCall = plugin.definition.onEnvironmentSyncIn?.(
syncInParams({ operationId: "in-active", sourcePath: inboundSource, targetPath: `${REMOTE_DIR}/in.txt` }),
);
const outboundCall = plugin.definition.onEnvironmentSyncOut?.(
syncOutParams({ operationId: "out-active", sourcePath: `${REMOTE_DIR}/out.txt`, targetPath: outboundTarget }),
);
// Let both sync calls register on the activity gate and reach their hung
// transfer, so teardown sees a refCount of two.
await new Promise((resolve) => setTimeout(resolve, 0));
const destroyCall = plugin.definition.onEnvironmentDestroyLease?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "sandbox-123",
config: { timeoutMs: 300000, reuseLease: false },
});
await new Promise((resolve) => setTimeout(resolve, 0));
// Two active sync calls block teardown, so it must not delete the sandbox yet.
expect(sandbox.delete).not.toHaveBeenCalled();
// Release only the inbound call. One outbound call is still active, so
// teardown must keep waiting.
releaseUpload();
await inboundCall;
await new Promise((resolve) => setTimeout(resolve, 0));
expect(sandbox.delete).not.toHaveBeenCalled();
// Release the outbound call. No sync call is active now, so teardown deletes.
releaseDownload();
await Promise.all([outboundCall, destroyCall]);
expect(sandbox.fs.uploadFiles).toHaveBeenCalledTimes(1);
expect(sandbox.fs.downloadFiles).toHaveBeenCalledTimes(1);
expect(sandbox.delete).toHaveBeenCalledTimes(1);
});
it("opens a transfer span with the guard round-trip count around the bulk file download", async () => {
const hostDir = await makeHostDir();
const target = path.join(hostDir, "result.txt");
const sandbox = createMockSandbox();
sandbox.fs.downloadFiles.mockImplementation(async (requests: Array<{ source: string; destination: string }>) => {
return Promise.all(
requests.map(async (request) => {
await fs.writeFile(request.destination, "bytes");
return { source: request.source, result: request.destination };
}),
);
});
mockGet.mockResolvedValue(sandbox);
const { tracer, spans } = createRecordingPluginTracer();
const restore = __setDaytonaPluginContextForTest({ tracer } as unknown as PluginContext);
try {
await plugin.definition.onEnvironmentSyncOut?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-out",
files: [{ sourcePath: `${REMOTE_DIR}/out/result.txt`, targetPath: target, kind: "file" }],
},
],
});
} finally {
restore();
}
const transfer = spans.find((span) => span.name === "transfer");
expect(transfer).toBeDefined();
expect(transfer!.ended).toBe(true);
// One serial guard round trip before the transfer: the validate-and-snapshot.
expect(transfer!.attributes["paperclip.sandbox.startup.transfer.guard.count"]).toBe(1);
expect(transfer!.attributes["paperclip.sandbox.startup.provider"]).toBe("daytona");
expect(typeof transfer!.attributes["paperclip.sandbox.startup.transfer.wall_ms"]).toBe("number");
});
it("opens a transfer span around a directory-mapping download with the guard round-trip count", async () => {
const hostDir = await makeHostDir();
const targetDir = path.join(hostDir, "assets");
const sandbox = createMockSandbox();
sandbox.fs.downloadFiles.mockImplementation(async (requests: Array<{ source: string; destination: string }>) => {
// Write a valid empty tar (1024-byte zero EOF marker) so host-side extract
// is a clean no-op.
await Promise.all(requests.map((request) => fs.writeFile(request.destination, Buffer.alloc(1024))));
return requests.map((request) => ({ source: request.source, result: request.destination }));
});
mockGet.mockResolvedValue(sandbox);
const { tracer, spans } = createRecordingPluginTracer();
const restore = __setDaytonaPluginContextForTest({ tracer } as unknown as PluginContext);
try {
await plugin.definition.onEnvironmentSyncOut?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-out-dir",
files: [{ sourcePath: `${REMOTE_DIR}/out/assets`, targetPath: targetDir, kind: "directory" }],
},
],
});
} finally {
restore();
}
const transfer = spans.find((span) => span.name === "transfer");
expect(transfer).toBeDefined();
expect(transfer!.ended).toBe(true);
// Two serial guard round trips before the transfer: confinement + in-sandbox
// tar.
expect(transfer!.attributes["paperclip.sandbox.startup.transfer.guard.count"]).toBe(2);
expect(typeof transfer!.attributes["paperclip.sandbox.startup.transfer.wall_ms"]).toBe("number");
});
});
describe("daytona manifest memory config", () => {
+33
View File
@@ -88,6 +88,12 @@ import type {
PluginSetupTokenPtyStopParams,
PluginSetupTokenPtyCloseParams,
PluginSetupTokenPtyCloseResult,
PluginDuplexChannelOpenParams,
PluginDuplexChannelOpenResult,
PluginDuplexChannelWriteParams,
PluginDuplexChannelStopParams,
PluginDuplexChannelCloseParams,
PluginDuplexChannelCloseResult,
} from "./protocol.js";
// ---------------------------------------------------------------------------
@@ -464,6 +470,33 @@ export interface PluginDefinition {
onSetupTokenPtyClose?(
params: PluginSetupTokenPtyCloseParams,
): Promise<PluginSetupTokenPtyCloseResult>;
/**
* Called to open one persistent duplex channel. The worker registers the
* channel under the host route identifier and returns a worker session
* identifier for the data notification binding only. The worker streams data
* and the exit through worker→host notifications, never as a reply. Defining
* the four `onDuplexChannel*` hooks advertises the four methods. The host reads
* the open verb to gate the `duplexCommandStream` capability.
*/
onDuplexChannelOpen?(
params: PluginDuplexChannelOpenParams,
): Promise<PluginDuplexChannelOpenResult>;
/** Called to write raw input to an open duplex channel, keyed by the worker session identifier. */
onDuplexChannelWrite?(params: PluginDuplexChannelWriteParams): Promise<void>;
/** Called to stop an open duplex channel child, keyed by the worker session identifier. */
onDuplexChannelStop?(params: PluginDuplexChannelStopParams): Promise<void>;
/**
* Called to close an open duplex channel by the host route identifier. The
* worker closes the exact channel registered under that identifier and returns
* a close acknowledgement that carries the same identifier.
*/
onDuplexChannelClose?(
params: PluginDuplexChannelCloseParams,
): Promise<PluginDuplexChannelCloseResult>;
}
// ---------------------------------------------------------------------------
+2
View File
@@ -84,6 +84,8 @@ export {
JsonRpcCallError,
SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION,
SETUP_TOKEN_PTY_EXIT_NOTIFICATION,
DUPLEX_CHANNEL_DATA_NOTIFICATION,
DUPLEX_CHANNEL_EXIT_NOTIFICATION,
_resetIdCounter,
} from "./protocol.js";
@@ -0,0 +1,156 @@
import { describe, expect, it } from "vitest";
import {
DUPLEX_CHANNEL_DATA_NOTIFICATION,
DUPLEX_CHANNEL_EXIT_NOTIFICATION,
type PluginDuplexChannelCloseParams,
type PluginDuplexChannelCloseResult,
type PluginDuplexChannelDataParams,
type PluginDuplexChannelExitParams,
type PluginDuplexChannelOpenParams,
type PluginDuplexChannelOpenResult,
type PluginDuplexChannelStopParams,
type PluginDuplexChannelWriteParams,
} from "./protocol.js";
// The generic duplex channel messages model the setup-token pseudo-terminal
// contract: open, write, stop, and close requests, plus data and exit
// notifications. The host owns the route identifier. The worker returns a worker
// session identifier that binds the data and the exit notification only. A close
// keys on the host route identifier, so a lost open reply still permits a
// host-keyed close.
//
// The messages are static types, so a valid payload assigns to its type and an
// invalid payload fails the type check. The `@ts-expect-error` directives make
// the compiler reject each invalid payload. The `tsc --noEmit` check validates
// the directives, so an invalid payload that the type accepts fails the build.
describe("duplex channel request schemas", () => {
it("accepts a valid open request and its reply", () => {
const open: PluginDuplexChannelOpenParams = {
hostRouteId: "route-1",
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "lease-1",
command: "paperclip-bridge",
};
const reply: PluginDuplexChannelOpenResult = { workerSessionId: "ws-1" };
expect(open.hostRouteId).toBe("route-1");
expect(reply.workerSessionId).toBe("ws-1");
});
it("rejects an open request that omits the host route identifier", () => {
// @ts-expect-error — hostRouteId is required.
const open: PluginDuplexChannelOpenParams = {
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "lease-1",
command: "paperclip-bridge",
};
expect(open).toBeDefined();
});
it("rejects an open request with a non-string command", () => {
const open: PluginDuplexChannelOpenParams = {
hostRouteId: "route-1",
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "lease-1",
// @ts-expect-error — command must be a string.
command: 42,
};
expect(open).toBeDefined();
});
it("accepts a valid write request", () => {
const write: PluginDuplexChannelWriteParams = {
workerSessionId: "ws-1",
data: "payload",
};
expect(write.data).toBe("payload");
});
it("rejects a write request that omits the data", () => {
// @ts-expect-error — data is required.
const write: PluginDuplexChannelWriteParams = { workerSessionId: "ws-1" };
expect(write).toBeDefined();
});
it("accepts a valid stop request", () => {
const stop: PluginDuplexChannelStopParams = { workerSessionId: "ws-1" };
expect(stop.workerSessionId).toBe("ws-1");
});
it("rejects a stop request that omits the worker session identifier", () => {
// @ts-expect-error — workerSessionId is required.
const stop: PluginDuplexChannelStopParams = {};
expect(stop).toBeDefined();
});
it("accepts a close request keyed only by the host route identifier", () => {
const close: PluginDuplexChannelCloseParams = { hostRouteId: "route-1" };
const reply: PluginDuplexChannelCloseResult = { hostRouteId: "route-1" };
expect(close.hostRouteId).toBe("route-1");
expect(reply.hostRouteId).toBe("route-1");
});
it("accepts a close request that also carries the worker session identifier", () => {
const close: PluginDuplexChannelCloseParams = {
hostRouteId: "route-1",
workerSessionId: "ws-1",
};
expect(close.workerSessionId).toBe("ws-1");
});
it("rejects a close request that omits the host route identifier", () => {
// @ts-expect-error — hostRouteId is the authoritative close key and is required.
const close: PluginDuplexChannelCloseParams = { workerSessionId: "ws-1" };
expect(close).toBeDefined();
});
});
describe("duplex channel notification schemas", () => {
it("uses the generic notification method names", () => {
expect(DUPLEX_CHANNEL_DATA_NOTIFICATION).toBe("duplexChannel.data");
expect(DUPLEX_CHANNEL_EXIT_NOTIFICATION).toBe("duplexChannel.exit");
});
it("accepts a valid data notification", () => {
const data: PluginDuplexChannelDataParams = {
workerSessionId: "ws-1",
chunk: "output bytes",
};
expect(data.chunk).toBe("output bytes");
});
it("rejects a data notification that omits the chunk", () => {
// @ts-expect-error — chunk is required.
const data: PluginDuplexChannelDataParams = { workerSessionId: "ws-1" };
expect(data).toBeDefined();
});
it("accepts an exit notification with a numeric code and with null", () => {
const exit: PluginDuplexChannelExitParams = {
workerSessionId: "ws-1",
exitCode: 0,
};
const exitNull: PluginDuplexChannelExitParams = {
workerSessionId: "ws-1",
exitCode: null,
};
expect(exit.exitCode).toBe(0);
expect(exitNull.exitCode).toBeNull();
});
it("rejects an exit notification with a non-numeric, non-null exit code", () => {
const exit: PluginDuplexChannelExitParams = {
workerSessionId: "ws-1",
// @ts-expect-error — exitCode must be a number or null.
exitCode: "0",
};
expect(exit).toBeDefined();
});
});
+114
View File
@@ -1082,6 +1082,102 @@ export const SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION = "setupTokenPty.output";
/** The worker→host notification method for one pseudo-terminal exit. */
export const SETUP_TOKEN_PTY_EXIT_NOTIFICATION = "setupTokenPty.exit";
// ---------------------------------------------------------------------------
// Generic duplex channel worker methods.
// ---------------------------------------------------------------------------
// The host drives one persistent duplex channel inside a sandbox provider
// worker. The channel replaces the file transport of the sandbox callback bridge
// with one live bidirectional stream. These messages are generic. They model the
// setup-token pseudo-terminal contract above, but they carry no login command
// allowlist. The host owns the route. It mints an opaque host route identifier,
// carries that identifier in the open request, and keys the close on that
// identifier. The worker registers the channel under the host route identifier
// and returns a worker session identifier for the data and the exit notification
// binding only. The worker never keys a close on the worker session identifier,
// so the host closes a worker-created channel even when the open reply was lost
// and no worker session identifier arrived. The worker sends data and exit as
// notifications, never as a reply, so the host binds them by the worker session
// identifier while the route is open.
/** The open request for one persistent duplex channel. The worker registers the channel by `hostRouteId`. */
export interface PluginDuplexChannelOpenParams {
/** The host-owned opaque route identifier. The worker registers the channel by it. */
hostRouteId: string;
/** The environment driver key, for the worker sandbox scope. */
driverKey: string;
/** The company that owns the channel. */
companyId: string;
/** The environment the channel runs in. */
environmentId: string;
/** The provider lease the sandbox is cached under. The worker resolves the sandbox by it. */
providerLeaseId: string;
/** The command the worker runs on the channel. */
command: string;
}
/** The open reply. It returns the worker session identifier for data binding only. */
export interface PluginDuplexChannelOpenResult {
/** The worker session identifier. It binds the data and the exit notification only. */
workerSessionId: string;
}
/** The write request. It carries the worker session identifier and the raw input bytes. */
export interface PluginDuplexChannelWriteParams {
/** The worker session identifier that the open reply returned. */
workerSessionId: string;
/** The raw input bytes to write to the channel. */
data: string;
}
/** The stop request. It carries the worker session identifier. */
export interface PluginDuplexChannelStopParams {
/** The worker session identifier that the open reply returned. */
workerSessionId: string;
}
/** The close request. The host route identifier is the authoritative key. */
export interface PluginDuplexChannelCloseParams {
/**
* The host-owned opaque route identifier. This is the authoritative close key,
* so the host closes the channel even when no worker session identifier arrived
* after a lost open reply.
*/
hostRouteId: string;
/**
* A non-authoritative worker session identifier. The worker never keys the
* close on it. The field is optional, so a close with only the host route
* identifier is a valid request for this lifecycle.
*/
workerSessionId?: string;
}
/** The close reply. It acknowledges the close and carries the same host route identifier. */
export interface PluginDuplexChannelCloseResult {
/** The close acknowledgement. It carries the same host route identifier the close sent. */
hostRouteId: string;
}
/** The worker→host duplex channel data notification parameters. */
export interface PluginDuplexChannelDataParams {
/** The worker session identifier that the open reply returned. */
workerSessionId: string;
/** The raw channel output bytes. */
chunk: string;
}
/** The worker→host duplex channel exit notification parameters. */
export interface PluginDuplexChannelExitParams {
/** The worker session identifier that the open reply returned. */
workerSessionId: string;
/** The child exit code, or null when the child ended with no code. */
exitCode: number | null;
}
/** The worker→host notification method for one duplex channel data chunk. */
export const DUPLEX_CHANNEL_DATA_NOTIFICATION = "duplexChannel.data";
/** The worker→host notification method for one duplex channel exit. */
export const DUPLEX_CHANNEL_EXIT_NOTIFICATION = "duplexChannel.exit";
/**
* Map of host→worker RPC method names to their `[params, result]` types.
*
@@ -1200,6 +1296,20 @@ export interface HostToWorkerMethods {
params: PluginSetupTokenPtyCloseParams,
result: PluginSetupTokenPtyCloseResult,
];
/** Open one persistent duplex channel keyed by a host-owned route identifier. */
duplexChannelOpen: [
params: PluginDuplexChannelOpenParams,
result: PluginDuplexChannelOpenResult,
];
/** Write raw input to a persistent duplex channel, keyed by the worker session identifier. */
duplexChannelWrite: [params: PluginDuplexChannelWriteParams, result: void];
/** Stop a persistent duplex channel child, keyed by the worker session identifier. */
duplexChannelStop: [params: PluginDuplexChannelStopParams, result: void];
/** Close a persistent duplex channel by the host route identifier and return a bound acknowledgement. */
duplexChannelClose: [
params: PluginDuplexChannelCloseParams,
result: PluginDuplexChannelCloseResult,
];
}
/** Union of all host→worker method names. */
@@ -1245,6 +1355,10 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[]
"setupTokenPtyInput",
"setupTokenPtyStop",
"setupTokenPtyClose",
"duplexChannelOpen",
"duplexChannelWrite",
"duplexChannelStop",
"duplexChannelClose",
] as const;
// ---------------------------------------------------------------------------
@@ -104,6 +104,10 @@ import type {
PluginSetupTokenPtyInputParams,
PluginSetupTokenPtyStopParams,
PluginSetupTokenPtyCloseParams,
PluginDuplexChannelOpenParams,
PluginDuplexChannelWriteParams,
PluginDuplexChannelStopParams,
PluginDuplexChannelCloseParams,
PluginInvocationContext,
WorkerToHostMethodName,
WorkerToHostMethods,
@@ -1624,6 +1628,18 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
case "setupTokenPtyClose":
return handleSetupTokenPtyClose(params as PluginSetupTokenPtyCloseParams);
case "duplexChannelOpen":
return handleDuplexChannelOpen(params as PluginDuplexChannelOpenParams);
case "duplexChannelWrite":
return handleDuplexChannelWrite(params as PluginDuplexChannelWriteParams);
case "duplexChannelStop":
return handleDuplexChannelStop(params as PluginDuplexChannelStopParams);
case "duplexChannelClose":
return handleDuplexChannelClose(params as PluginDuplexChannelCloseParams);
default:
throw Object.assign(
new Error(`Unknown method: ${method}`),
@@ -1679,6 +1695,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (plugin.definition.onSetupTokenPtyInput) supportedMethods.push("setupTokenPtyInput");
if (plugin.definition.onSetupTokenPtyStop) supportedMethods.push("setupTokenPtyStop");
if (plugin.definition.onSetupTokenPtyClose) supportedMethods.push("setupTokenPtyClose");
if (plugin.definition.onDuplexChannelOpen) supportedMethods.push("duplexChannelOpen");
if (plugin.definition.onDuplexChannelWrite) supportedMethods.push("duplexChannelWrite");
if (plugin.definition.onDuplexChannelStop) supportedMethods.push("duplexChannelStop");
if (plugin.definition.onDuplexChannelClose) supportedMethods.push("duplexChannelClose");
return { ok: true, supportedMethods };
}
@@ -2055,6 +2075,34 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
return plugin.definition.onSetupTokenPtyClose(params);
}
async function handleDuplexChannelOpen(params: PluginDuplexChannelOpenParams) {
if (!plugin.definition.onDuplexChannelOpen) {
throw methodNotImplemented("duplexChannelOpen");
}
return plugin.definition.onDuplexChannelOpen(params);
}
async function handleDuplexChannelWrite(params: PluginDuplexChannelWriteParams) {
if (!plugin.definition.onDuplexChannelWrite) {
throw methodNotImplemented("duplexChannelWrite");
}
return plugin.definition.onDuplexChannelWrite(params);
}
async function handleDuplexChannelStop(params: PluginDuplexChannelStopParams) {
if (!plugin.definition.onDuplexChannelStop) {
throw methodNotImplemented("duplexChannelStop");
}
return plugin.definition.onDuplexChannelStop(params);
}
async function handleDuplexChannelClose(params: PluginDuplexChannelCloseParams) {
if (!plugin.definition.onDuplexChannelClose) {
throw methodNotImplemented("duplexChannelClose");
}
return plugin.definition.onDuplexChannelClose(params);
}
// -----------------------------------------------------------------------
// Event filter helper
// -----------------------------------------------------------------------
@@ -905,3 +905,191 @@ describe("worker setup-token pseudo-terminal dispatch", () => {
}
});
});
describe("worker duplex channel dispatch", () => {
it("dispatches open, write, stop, and close, and reports the duplex methods", async () => {
const hostToWorker = new PassThrough();
const workerToHost = new PassThrough();
const hostReadline = createInterface({ input: workerToHost });
const pending = new Map<string, (response: JsonRpcResponse) => void>();
let nextRequestId = 1;
const writes: string[] = [];
let stopped = 0;
let closed = 0;
// The plugin declares the four duplex channel handlers. The open returns a
// worker session id. The close keys on the host route id and returns a bound
// acknowledgement.
const controllablePlugin = definePlugin({
async setup() {},
async onDuplexChannelOpen(params) {
expect(params.hostRouteId).toBe("route-1");
expect(params.command).toBe("paperclip-bridge");
expect(params.providerLeaseId).toBe("lease-1");
return { workerSessionId: "ws-1" };
},
async onDuplexChannelWrite(params) {
writes.push(params.data);
},
async onDuplexChannelStop() {
stopped += 1;
},
async onDuplexChannelClose(params) {
closed += 1;
return { hostRouteId: params.hostRouteId };
},
});
const worker = startWorkerRpcHost({
plugin: controllablePlugin,
stdin: hostToWorker,
stdout: workerToHost,
});
function callWorker(method: string, params: unknown) {
const id = `host-${nextRequestId++}`;
const result = new Promise<unknown>((resolve, reject) => {
pending.set(id, (response) => {
if ("error" in response && response.error) {
reject(new Error(response.error.message));
return;
}
resolve((response as { result?: unknown }).result);
});
});
hostToWorker.write(serializeMessage(createRequest(method, params, id)));
return result;
}
hostReadline.on("line", (line) => {
const message = parseMessage(line);
if (isJsonRpcResponse(message)) {
pending.get(String(message.id))?.(message);
pending.delete(String(message.id));
}
});
try {
await expect(
callWorker("initialize", {
manifest: {
id: "paperclip.duplex-channel",
apiVersion: 1,
version: "1.0.0",
displayName: "Duplex Channel Test",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: {},
},
config: {},
databaseNamespace: null,
}),
).resolves.toMatchObject({
ok: true,
supportedMethods: expect.arrayContaining([
"duplexChannelOpen",
"duplexChannelWrite",
"duplexChannelStop",
"duplexChannelClose",
]),
});
await expect(
callWorker("duplexChannelOpen", {
hostRouteId: "route-1",
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "lease-1",
command: "paperclip-bridge",
}),
).resolves.toEqual({ workerSessionId: "ws-1" });
await callWorker("duplexChannelWrite", { workerSessionId: "ws-1", data: "payload" });
await callWorker("duplexChannelStop", { workerSessionId: "ws-1" });
await expect(
callWorker("duplexChannelClose", { hostRouteId: "route-1" }),
).resolves.toEqual({ hostRouteId: "route-1" });
expect(writes).toEqual(["payload"]);
expect(stopped).toBe(1);
expect(closed).toBe(1);
} finally {
worker.stop();
hostReadline.close();
}
});
it("reports no duplex methods when the plugin declares no duplex handlers", async () => {
const hostToWorker = new PassThrough();
const workerToHost = new PassThrough();
const hostReadline = createInterface({ input: workerToHost });
const pending = new Map<string, (response: JsonRpcResponse) => void>();
let nextRequestId = 1;
// A plugin with no duplex handlers advertises no duplex method. The Phase 1
// capability `duplexCommandStream` still resolves false for this provider,
// because the prerequisite verb `duplexChannelOpen` is absent.
const barePlugin = definePlugin({
async setup() {},
});
const worker = startWorkerRpcHost({
plugin: barePlugin,
stdin: hostToWorker,
stdout: workerToHost,
});
function callWorker(method: string, params: unknown) {
const id = `host-${nextRequestId++}`;
const result = new Promise<unknown>((resolve, reject) => {
pending.set(id, (response) => {
if ("error" in response && response.error) {
reject(new Error(response.error.message));
return;
}
resolve((response as { result?: unknown }).result);
});
});
hostToWorker.write(serializeMessage(createRequest(method, params, id)));
return result;
}
hostReadline.on("line", (line) => {
const message = parseMessage(line);
if (isJsonRpcResponse(message)) {
pending.get(String(message.id))?.(message);
pending.delete(String(message.id));
}
});
try {
const result = (await callWorker("initialize", {
manifest: {
id: "paperclip.bare",
apiVersion: 1,
version: "1.0.0",
displayName: "Bare Test",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: {},
},
config: {},
databaseNamespace: null,
})) as { supportedMethods: string[] };
expect(result.supportedMethods).not.toContain("duplexChannelOpen");
expect(result.supportedMethods).not.toContain("duplexChannelWrite");
expect(result.supportedMethods).not.toContain("duplexChannelStop");
expect(result.supportedMethods).not.toContain("duplexChannelClose");
} finally {
worker.stop();
hostReadline.close();
}
});
});
+1 -1
View File
@@ -3,6 +3,6 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
include: ["tests/**/*.test.ts", "src/**/*.test.ts"],
},
});
@@ -0,0 +1,9 @@
import { describe, it, expect } from "vitest";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./adapter-auth-check-code.js";
describe("ADAPTER_AUTH_MISSING_CHECK_CODE", () => {
it("keeps the canonical missing-auth check code stable", () => {
expect(ADAPTER_AUTH_MISSING_CHECK_CODE).toBe("adapter_auth_missing");
});
});
@@ -0,0 +1,12 @@
/**
* The canonical check code an adapter Test engine emits when a sandbox target
* has no ready authentication. The user interface reads this stable code to
* decide login eligibility. The user interface does not read the message text
* or the top-level status.
*
* The name is neutral. It carries no vendor name and no login-flow word, because
* a Test result can sync to public packages. The adapters and the user interface
* import this one constant, so the user interface can gate login from one code
* across adapters.
*/
export const ADAPTER_AUTH_MISSING_CHECK_CODE = "adapter_auth_missing";
+3 -3
View File
@@ -49,7 +49,7 @@ export interface EnvironmentProviderCapability {
templateRefKind?: string;
templateConfigBinding?: PluginEnvironmentTemplateConfigBinding;
supportsTemplateDelete: boolean;
supportsSetupTokenLogin: boolean;
supportsLoginPty: boolean;
displayName?: string;
description?: string;
source?: "builtin" | "plugin";
@@ -157,7 +157,7 @@ export function getEnvironmentCapabilities(
interactiveSetupConnectionTypes: [],
supportsTemplateCapture: false,
supportsTemplateDelete: false,
supportsSetupTokenLogin: false,
supportsLoginPty: false,
displayName: "Fake",
source: "builtin",
},
@@ -177,7 +177,7 @@ export function getEnvironmentCapabilities(
templateRefKind: capability.templateRefKind,
templateConfigBinding: capability.templateConfigBinding,
supportsTemplateDelete: capability.supportsTemplateDelete ?? false,
supportsSetupTokenLogin: capability.supportsSetupTokenLogin ?? false,
supportsLoginPty: capability.supportsLoginPty ?? false,
displayName: capability.displayName,
description: capability.description,
source: capability.source ?? "plugin",
+9
View File
@@ -1,4 +1,5 @@
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
export { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./adapter-auth-check-code.js";
export {
nativeFinalizationResultSchema,
nativeFinalizationResultV1Schema,
@@ -890,6 +891,11 @@ export type {
WorkspaceOperation,
WorkspaceOperationPhase,
WorkspaceOperationStatus,
WorkspaceLoginHandoffTicketResponse,
WorkspaceReadiness,
WorkspaceReadinessProbeResult,
WorkspaceReadinessState,
WorkspaceSeedReadinessState,
NormalizedWorkspaceFileAvailabilityQuery,
WorkspaceFileAvailabilityQuery,
WorkspaceFileAvailabilityRequest,
@@ -1450,6 +1456,7 @@ export type {
QuotaWindow,
ProviderQuotaResult,
} from "./types/index.js";
export { WORKSPACE_READINESS_STATES } from "./types/index.js";
export {
COMPANY_SEARCH_EXTRACT_KINDS,
COMPANY_SEARCH_EXTRACT_SCOPES,
@@ -2451,6 +2458,7 @@ export {
startEnvironmentCustomImageSetupSessionSchema,
finishEnvironmentCustomImageSetupSessionSchema,
cancelEnvironmentCustomImageSetupSessionSchema,
relinkEnvironmentCustomImageTemplateSchema,
createEnvironmentCustomImageTerminalSessionTokenSchema,
environmentCustomImageTerminalSessionTokenSchema,
type EnvironmentCustomImageSetupConnectionSummary,
@@ -2459,6 +2467,7 @@ export {
type StartEnvironmentCustomImageSetupSession,
type FinishEnvironmentCustomImageSetupSession,
type CancelEnvironmentCustomImageSetupSession,
type RelinkEnvironmentCustomImageTemplate,
type CreateEnvironmentCustomImageTerminalSessionToken,
type EnvironmentCustomImageTerminalSessionToken,
} from "./validators/environment-custom-images.js";
@@ -13,16 +13,16 @@ describe("forceLoopbackBindInCommand", () => {
// PAP-17256 reproduction: this is verbatim what `workspace_runtime_services`
// recorded for every lane the broker denied.
expect(forceLoopbackBindInCommand("pnpm dev --bind lan")).toBe(
"pnpm dev --bind custom --bind-host 127.0.0.1",
"pnpm dev --bind loopback",
);
});
it("replaces an existing loopback-adjacent bind rather than duplicating it", () => {
expect(forceLoopbackBindInCommand("pnpm dev --bind tailnet")).toBe(
"pnpm dev --bind custom --bind-host 127.0.0.1",
"pnpm dev --bind loopback",
);
expect(forceLoopbackBindInCommand("pnpm dev --bind custom --bind-host 10.0.0.4")).toBe(
"pnpm dev --bind custom --bind-host 127.0.0.1",
"pnpm dev --bind loopback",
);
});
@@ -33,7 +33,7 @@ describe("forceLoopbackBindInCommand", () => {
it("handles the =-separated spelling", () => {
expect(forceLoopbackBindInCommand("pnpm dev --bind=lan --bind-host=0.0.0.0")).toBe(
"pnpm dev --bind custom --bind-host 127.0.0.1",
"pnpm dev --bind loopback",
);
});
@@ -41,7 +41,7 @@ describe("forceLoopbackBindInCommand", () => {
// A bare `pnpm dev` lets an old guest runner infer its bind from HOST, so the
// flags must be added rather than assumed.
expect(forceLoopbackBindInCommand("pnpm dev")).toBe(
"pnpm dev --bind custom --bind-host 127.0.0.1",
"pnpm dev --bind loopback",
);
});
@@ -49,19 +49,19 @@ describe("forceLoopbackBindInCommand", () => {
// `isPaperclipDevRuntimeService` matches `--tailscale-auth` as a substring;
// an explicit `--bind` already beats the alias in every dev-runner version.
expect(forceLoopbackBindInCommand("pnpm dev:once --tailscale-auth")).toBe(
"pnpm dev:once --tailscale-auth --bind custom --bind-host 127.0.0.1",
"pnpm dev:once --tailscale-auth --bind loopback",
);
});
it("never strips a value that is actually the next flag", () => {
expect(forceLoopbackBindInCommand("pnpm dev --bind --verbose")).toBe(
"pnpm dev --bind --verbose --bind custom --bind-host 127.0.0.1",
"pnpm dev --bind --verbose --bind loopback",
);
});
it("does not touch a --bind occurrence that is not a flag boundary", () => {
expect(forceLoopbackBindInCommand("pnpm dev --no--bind lan")).toBe(
"pnpm dev --no--bind lan --bind custom --bind-host 127.0.0.1",
"pnpm dev --no--bind lan --bind loopback",
);
});
@@ -74,8 +74,9 @@ describe("forceLoopbackBindInCommand", () => {
expect(forceLoopbackBindInCommand(fixture)).toBe(fixture);
});
it("uses the loopback host constant", () => {
expect(forceLoopbackBindInCommand("pnpm dev")).toContain(RUNTIME_EXPOSURE_BIND_HOST);
it("uses the loopback preset without turning it into a custom bind", () => {
expect(forceLoopbackBindInCommand("pnpm dev")).toBe("pnpm dev --bind loopback");
expect(RUNTIME_EXPOSURE_BIND_HOST).toBe("127.0.0.1");
});
});
@@ -18,8 +18,8 @@
* Pure string functions only — no I/O, no process state.
*/
/** The only address an exposed managed runtime may bind. */
export const RUNTIME_EXPOSURE_BIND_MODE = "custom";
/** The only bind preset an exposed managed runtime may use. */
export const RUNTIME_EXPOSURE_BIND_MODE = "loopback";
export const RUNTIME_EXPOSURE_BIND_HOST = "127.0.0.1";
/**
@@ -72,7 +72,7 @@ export function forceLoopbackBindInCommand(command: string): string {
if (!isPaperclipDevRunnerCommand(command)) return command;
const stripped = command.replace(BIND_SELECTING_ARG, "").trim();
if (stripped.length === 0) return command;
return `${stripped} --bind ${RUNTIME_EXPOSURE_BIND_MODE} --bind-host ${RUNTIME_EXPOSURE_BIND_HOST}`;
return `${stripped} --bind ${RUNTIME_EXPOSURE_BIND_MODE}`;
}
/**
+32 -9
View File
@@ -111,10 +111,16 @@ absent, never a misleading `0`.
| `stage.sync` | Workspace stage-sync step. | `sandbox.startup` |
| `snapshot.git` | Host-side git workspace enumeration inside `stage.sync` (`git status --ignored`, the HEAD diffs, `ls-files`). | `stage.sync` |
| `snapshot.baseline` | Host-side baseline workspace content-hash walk inside `stage.sync`, kept for restore. | `stage.sync` |
| `pack` | Host-side workspace tarball build inside `stage.sync`. | `stage.sync` |
| `stage.workspace` | One inbound workspace stage task inside `stage.sync`. It packs and uploads the workspace. | `stage.sync` |
| `stage.asset.<key>` | One inbound asset stage task inside `stage.sync`. It packs and uploads one managed-home asset. The `<key>` segment is the asset key. | `stage.sync` |
| `stage.project.<id>` | One inbound referenced-project stage task inside `stage.sync`. It uploads one referenced project. The `<id>` segment is the project id. | `stage.sync` |
| `pack` | Host-side workspace tarball build inside the `stage.workspace` task. | `stage.workspace` |
| `bridge.paperclip` | Paperclip bridge start step. | `sandbox.startup` |
| `bridge.process-session` | Process-session bridge start step. | `sandbox.startup` |
| `acp.handshake` | ACP session handshake step. | `sandbox.startup` |
| `sandbox.syncBack` | The settlement sync-back that restores the managed home at teardown. | the active run span |
| `restore.workspace` | One outbound workspace restore task at teardown. It reads the sandbox workspace back and merges it into the host workspace. | `sandbox.syncBack` |
| `restore.asset.<key>` | One outbound asset restore task at teardown. It reads one asset back to its host store. The `<key>` segment is the asset key. | `sandbox.syncBack` |
| `sandbox.agentSession.sendInput` | One outbound ACP message to the agent — the socket handler's one `writeTextFile` exec. | the active run span |
| `sandbox.agentSession.pollOutput` | One 100 ms poll tick — `list`, then `read`+`remove` per file found (`1 + 2n` execs). | the active run span |
| `sandbox.callbackBridge.relayRequest` | One Paperclip-API callback request — read the request, write the response, remove it. | the active run span |
@@ -123,9 +129,19 @@ absent, never a misleading `0`.
A step span name is the step name. The `sandbox.exec` span parents to the step
span that runs the execution, so each execution nests under its step. Within
`stage.sync`, the host-side sub-steps `snapshot.git`, `snapshot.baseline`, and
`pack` open as child spans of the step, so the host work at the head of the step
is attributed rather than showing as a gap. A run-time
`stage.sync`, the host-side sub-steps `snapshot.git` and `snapshot.baseline` open
as child spans of the step, so the host work at the head of the step is
attributed rather than showing as a gap. Each inbound sync operation also opens
its own task span under `stage.sync`: `stage.workspace`, one `stage.asset.<key>`
per asset, and one `stage.project.<id>` per referenced project. The `pack` span
nests under `stage.workspace`, because the host builds the tarball inside that
task. Two concurrent tasks produce overlapping spans.
The settlement `sandbox.syncBack` span runs at teardown and parents to the run
span. It wraps the managed-home restore. Each outbound restore operation opens
its own task span under `sandbox.syncBack`: `restore.workspace` and one
`restore.asset.<key>` per asset. Two concurrent restore tasks produce overlapping
spans. A run-time
`sandbox.exec` span parents instead to the run-time wrapper span that runs it
(`sandbox.agentSession.sendInput`, `sandbox.agentSession.pollOutput`,
`sandbox.callbackBridge.relayRequest`, or `sandbox.agentProcess`). Each run-time
@@ -228,7 +244,7 @@ before it records the span.
| Span | Scope | Parent |
| --- | --- | --- |
| `sandbox.daytona.pack` | The host-local pack step that builds the upload tarball. It makes no sandbox round trip. | the active startup step span |
| `sandbox.daytona.transfer` | The transfer step that uploads the files to the sandbox. | the active startup step span |
| `sandbox.daytona.transfer` | The transfer step: an upload to the sandbox (inbound) or a download from the sandbox (outbound). The `paperclip.sandbox.startup.transfer.direction` attribute records the direction. | the active sync task span (`stage.*` inbound, `restore.*` under `sandbox.syncBack` outbound) |
| `sandbox.daytona.ensureDirectory` | The `mkdir -p` step that ensures a directory exists before a write. | the active startup step span |
| `sandbox.daytona.checkSymlinkEscape` | The re-check step that a path resolves inside the workspace root before use. | the active startup step span |
| `sandbox.daytona.promote` | The atomic move of a staged temp onto its target via a pinned dir handle. | the active startup step span |
@@ -258,10 +274,12 @@ the attributes that the producer sends for one span.
| `paperclip.sandbox.startup.pack.wall_ms` | number | yes | The host-local wall time of the pack step. It rides the `sandbox.daytona.pack` span. |
| `paperclip.sandbox.startup.transfer.wall_ms` | number | yes | The wall time of the transfer step. It rides the `sandbox.daytona.transfer` span. |
| `paperclip.sandbox.startup.transfer.guard.count` | number | yes | The number of serial guard round trips before one transfer. It rides the `sandbox.daytona.transfer` span. |
| `paperclip.sandbox.startup.transfer.direction` | string | yes | The transfer direction (`inbound` or `outbound`). It rides the `sandbox.daytona.transfer` span. |
The `span.record` host handler enforces the allowlist. It re-maps `provider`
through the provider-family normalizer. It keeps `outcome` only when the value
is `ok`, `skipped`, or `failed`. It keeps a numeric attribute only when the
is `ok`, `skipped`, or `failed`. It keeps `transfer.direction` only when the
value is `inbound` or `outbound`. It keeps a numeric attribute only when the
value is a finite number. It drops a status message and keeps only the numeric
status code. The handler never throws, because observability must not change the
sync control flow.
@@ -271,9 +289,14 @@ capability. So only a plugin that registers an environment driver may emit a
provider span. The capability gate rejects a provider span from any other
plugin.
The host parents each provider span to the active startup step span. The host
mints a W3C `traceparent` from the active step and passes it to the plugin
worker on the per-call invocation channel. The worker tags its span with the
The host parents each provider span to the active sync task span. An inbound
transfer runs inside a `stage.*` task span, so its provider spans parent there.
An outbound transfer runs inside a `restore.*` task span under `sandbox.syncBack`
at teardown, so its provider spans parent there. The host mints a W3C
`traceparent` from the active task span and passes it to the plugin worker on the
per-call invocation channel. The teardown restore runs inside the run-parented
`sandbox.syncBack` span, so the host mints a `traceparent` for an outbound
provider span the same way it does for an inbound one. The worker tags its span with the
`traceparent` and treats the value as opaque. The worker never derives the
parent from it. The host recovers the `traceparent` from its own invocation
record, so a worker can never forge a parent. The host validates the
+8
View File
@@ -413,6 +413,14 @@ export type {
WorkspaceOperationPhase,
WorkspaceOperationStatus,
} from "./workspace-operation.js";
export { WORKSPACE_READINESS_STATES } from "./workspace-readiness.js";
export type {
WorkspaceLoginHandoffTicketResponse,
WorkspaceReadiness,
WorkspaceReadinessProbeResult,
WorkspaceReadinessState,
WorkspaceSeedReadinessState,
} from "./workspace-readiness.js";
export type {
NormalizedWorkspaceFileAvailabilityQuery,
WorkspaceFileAvailabilityQuery,
+34 -4
View File
@@ -163,6 +163,27 @@ export interface SandboxProviderCapabilities {
* the output-file poll path.
*/
incrementalSessionOutput?: boolean;
/**
* Provider can run file transfers into and out of the sandbox in parallel, in
* both directions. This is an opt-in behavioral guarantee. An omitted key
* denies the capability, so the host keeps the serial transfer path. The host
* resolves the capability `true` only when the provider declares this key
* `true` and the live worker verifies both sync verbs (`environmentSyncIn` and
* `environmentSyncOut`). A provider that verifies only one verb resolves
* `false`.
*/
concurrentSyncOperations?: boolean;
/**
* Provider opens one persistent, bidirectional duplex channel that carries the
* command stream, in place of the file transport of the callback bridge. This
* is an opt-in behavioral guarantee, not a worker-method property: a provider
* that keeps persistent sessions and runs independent control commands still
* does not carry a framed duplex stream unless it declares this key. An omitted
* key denies the capability, so the provider keeps the file bridge. Only a
* provider that declares this key `true` and whose worker verifies the duplex
* open method selects the duplex channel path.
*/
duplexCommandStream?: boolean;
}
export interface PluginEnvironmentDriverDeclaration {
@@ -216,10 +237,19 @@ export interface PluginEnvironmentDriverDeclaration {
/** Provider supports best-effort deletion/cleanup of captured templates. */
supportsTemplateDelete?: boolean;
/**
* Provider can host the Claude setup-token login on a real pseudo-terminal.
* Only a provider with this flag exposes the setup-token pseudo-terminal
* methods. The setup-token login server and the login UI both gate on this
* flag, so a provider without it never starts a login.
* Provider can host an interactive login on a real pseudo-terminal. Only a
* provider with this flag exposes the login pseudo-terminal methods. The login
* server and the login UI both gate on this flag, so a provider without it
* never starts a login.
*/
supportsLoginPty?: boolean;
/**
* Deprecated alias for `supportsLoginPty`. It exists only so an external
* plugin manifest that declares the old name still loads. The manifest
* validator canonicalizes it onto `supportsLoginPty` and drops it. Do not read
* this field; read `supportsLoginPty`.
*
* @deprecated Use `supportsLoginPty`.
*/
supportsSetupTokenLogin?: boolean;
/** JSON Schema describing the driver's provider-specific configuration. */
@@ -2,7 +2,9 @@ export type WorkspaceOperationPhase =
| "worktree_prepare"
| "workspace_config_freshness"
| "workspace_provision"
| "workspace_seed"
| "workspace_runtime_provision"
| "workspace_repair"
| "workspace_teardown"
| "worktree_cleanup"
| "workspace_finalize";
@@ -0,0 +1,91 @@
/**
* Workspace readiness contract (PAP-17572).
*
* Transport health ("the port answered") is not user readiness. A cloned
* workspace is only usable when its own database is reachable, the seed
* manifest reached a verified terminal state, representative cloned rows are
* readable, the password-independent login handoff is configured, and the
* instance/workspace/company identity matches the one the control plane expects.
*
* These fields ride on the *protected* health response only. Public health
* stays redacted: an anonymous caller learns liveness, never which instance or
* execution workspace answered.
*/
export const WORKSPACE_READINESS_STATES = [
/** The isolated database is still being restored. */
"provisioning",
/** Restore finished; the clone is being validated. */
"validating",
/** Every readiness signal agrees; the workspace can be opened. */
"ready",
/** Serving, but at least one readiness signal regressed after being ready. */
"degraded",
/** A managed repair is running right now. */
"repairing",
/** A terminal provisioning or repair failure needs an operator action. */
"failed",
] as const;
export type WorkspaceReadinessState = (typeof WORKSPACE_READINESS_STATES)[number];
/** Terminal state of the versioned seed manifest, or `unknown` for a legacy marker. */
export type WorkspaceSeedReadinessState =
| "pending"
| "running"
| "verified"
| "failed"
| "unknown"
| "absent";
export interface WorkspaceReadiness {
state: WorkspaceReadinessState;
/** `SELECT 1` plus a readable migration journal on this instance's own database. */
databaseReady: boolean;
/** A representative cloned company/issue pair is readable. */
cloneDataReady: boolean;
/** A signing key and a resolvable cloned admin identity are both present. */
authHandoffReady: boolean;
/** Exact cloned user checked for a caller-scoped handoff probe, or null. */
authHandoffUserId: string | null;
seedState: WorkspaceSeedReadinessState;
/** Last recorded seed phase, e.g. `restore` or `post_restore_validation`. */
seedPhase: string | null;
seedMode: "minimal" | "full" | null;
/** Resolved instance identity of the process that answered, never a branch heuristic. */
instanceId: string | null;
/** Execution workspace this instance was provisioned for, when known. */
executionWorkspaceId: string | null;
/** Company whose cloned board this workspace serves, when known. */
companyId: string | null;
/** Phase that failed, so operators do not need host-shell archaeology. */
failurePhase: string | null;
}
/**
* Result of the control plane's protected readiness probe against a managed
* workspace runtime. `reason` is a stable machine code so the runtime gate and
* the UI can branch without parsing prose.
*/
export type WorkspaceReadinessProbeResult =
| { ok: true; readiness: WorkspaceReadiness }
| {
ok: false;
reason:
| "unreachable"
| "http_error"
| "unhealthy_payload"
| "readiness_missing"
| "not_ready"
| "identity_mismatch";
readiness: WorkspaceReadiness | null;
detail: string | null;
};
export interface WorkspaceLoginHandoffTicketResponse {
/** Absolute URL that exchanges the ticket and redirects to the cloned board. */
url: string;
expiresAt: string;
/** `handoff` for the signed path, `credentials` when only the fallback is available. */
mode: "handoff" | "credentials";
}
@@ -94,6 +94,12 @@ export const cancelEnvironmentCustomImageSetupSessionSchema = z.object({
export type CancelEnvironmentCustomImageSetupSession =
z.infer<typeof cancelEnvironmentCustomImageSetupSessionSchema>;
export const relinkEnvironmentCustomImageTemplateSchema = z.object({
confirmBootSourceDrift: z.boolean().optional().default(false),
}).strict();
export type RelinkEnvironmentCustomImageTemplate =
z.infer<typeof relinkEnvironmentCustomImageTemplateSchema>;
export const createEnvironmentCustomImageTerminalSessionTokenSchema = z.object({}).strict().default({});
export type CreateEnvironmentCustomImageTerminalSessionToken =
z.infer<typeof createEnvironmentCustomImageTerminalSessionTokenSchema>;
+2
View File
@@ -95,6 +95,7 @@ export {
startEnvironmentCustomImageSetupSessionSchema,
finishEnvironmentCustomImageSetupSessionSchema,
cancelEnvironmentCustomImageSetupSessionSchema,
relinkEnvironmentCustomImageTemplateSchema,
createEnvironmentCustomImageTerminalSessionTokenSchema,
environmentCustomImageTerminalSessionTokenSchema,
type EnvironmentCustomImageSetupConnectionSummary,
@@ -103,6 +104,7 @@ export {
type StartEnvironmentCustomImageSetupSession,
type FinishEnvironmentCustomImageSetupSession,
type CancelEnvironmentCustomImageSetupSession,
type RelinkEnvironmentCustomImageTemplate,
type CreateEnvironmentCustomImageTerminalSessionToken,
type EnvironmentCustomImageTerminalSessionToken,
} from "./environment-custom-images.js";
+84 -12
View File
@@ -313,18 +313,29 @@ describe("sandbox provider capability declaration validators", () => {
expect(rejected.success).toBe(false);
});
it("test_removed_concurrency_capabilities_are_rejected_as_unknown_keys", () => {
// The concurrency flags left the public contract because no runtime path
// enforced them. The strict schema now rejects them, so a manifest cannot
// declare a capability the host does not honor.
for (const key of ["concurrentSyncAndExec", "concurrentSyncOperations"]) {
const rejected = pluginManifestV1Schema.safeParse(
buildSandboxProviderManifest({
sandboxCapabilities: { [key]: true },
}),
);
expect(rejected.success).toBe(false);
}
it("test_manifest_accepts_concurrent_sync_operations_capability", () => {
// A provider opts in to parallel bidirectional file sync with this key. The
// strict schema accepts it and keeps the declared value.
const parsed = pluginManifestV1Schema.parse(
buildSandboxProviderManifest({
sandboxCapabilities: { concurrentSyncOperations: true },
}),
);
expect(parsed.environmentDrivers?.[0]?.sandboxCapabilities).toEqual({
concurrentSyncOperations: true,
});
});
it("test_manifest_rejects_unknown_sync_concurrency_capability_key", () => {
// A neighboring but unknown concurrency key must fail validation, not drop
// silently. The strict schema rejects a capability the host does not honor.
const rejected = pluginManifestV1Schema.safeParse(
buildSandboxProviderManifest({
sandboxCapabilities: { concurrentSyncAndExec: true },
}),
);
expect(rejected.success).toBe(false);
});
it("test_supports_reusable_leases_compat_maps_to_reusable_leases", () => {
@@ -350,3 +361,64 @@ describe("sandbox provider capability declaration validators", () => {
expect(resolveDeclaredSandboxCapabilities(driver!).reusableLeases).toBe(false);
});
});
describe("login pty transport capability and legacy alias", () => {
it("test_login_pty_new_field_parses_and_carries_the_flag", () => {
const parsed = pluginManifestV1Schema.parse(
buildSandboxProviderManifest({ supportsLoginPty: true }),
);
expect(parsed.environmentDrivers?.[0]?.supportsLoginPty).toBe(true);
});
it("test_legacy_alias_only_canonicalizes_onto_login_pty", () => {
const parsed = pluginManifestV1Schema.parse(
buildSandboxProviderManifest({ supportsSetupTokenLogin: true }),
);
const driver = parsed.environmentDrivers?.[0];
// The validator maps the deprecated alias onto the canonical field at parse
// time, and it drops the alias so a downstream reader cannot read the old
// name.
expect(driver?.supportsLoginPty).toBe(true);
expect(driver).not.toHaveProperty("supportsSetupTokenLogin");
});
it("test_conflicting_alias_and_new_field_reject", () => {
const rejected = pluginManifestV1Schema.safeParse(
buildSandboxProviderManifest({
supportsLoginPty: true,
supportsSetupTokenLogin: false,
}),
);
expect(rejected.success).toBe(false);
});
it("test_matching_alias_and_new_field_pass", () => {
const parsed = pluginManifestV1Schema.parse(
buildSandboxProviderManifest({
supportsLoginPty: true,
supportsSetupTokenLogin: true,
}),
);
expect(parsed.environmentDrivers?.[0]?.supportsLoginPty).toBe(true);
});
it("test_unknown_login_field_misspelling_rejects", () => {
const rejected = pluginManifestV1Schema.safeParse(
buildSandboxProviderManifest({ supportsLoginPTY: true }),
);
expect(rejected.success).toBe(false);
});
it("test_login_pty_accepts_literal_booleans_only", () => {
const rejected = pluginManifestV1Schema.safeParse(
buildSandboxProviderManifest({ supportsLoginPty: "true" }),
);
expect(rejected.success).toBe(false);
});
});
+37 -2
View File
@@ -157,8 +157,8 @@ export const pluginEnvironmentTemplateConfigBindingSchema = z.object({
// The nested sandbox capability declaration is `.strict()` so an unknown
// capability key is a validation error, not a silently dropped field. The outer
// driver schema below is non-strict and drops unknown top-level keys, so the
// declaration itself is the gate that a typo in a capability name cannot pass.
// driver schema below is also `.strict()`, so a typo in a top-level capability
// name cannot pass either.
export const sandboxProviderCapabilitiesSchema = z.object({
reusableLeases: z.boolean().optional(),
nativeSyncIn: z.boolean().optional(),
@@ -166,10 +166,15 @@ export const sandboxProviderCapabilitiesSchema = z.object({
persistentProcessSessions: z.boolean().optional(),
independentControlCommands: z.boolean().optional(),
incrementalSessionOutput: z.boolean().optional(),
concurrentSyncOperations: z.boolean().optional(),
duplexCommandStream: z.boolean().optional(),
}).strict();
export type SandboxProviderCapabilitiesInput = z.infer<typeof sandboxProviderCapabilitiesSchema>;
// The driver declaration schema is `.strict()` so an unknown top-level key is a
// validation error, not a silently dropped field. A misspelled capability name
// (for example `supportsLoginPTY`) fails validation instead of dropping.
export const pluginEnvironmentDriverDeclarationSchema = z.object({
driverKey: z.string().min(1).regex(
/^[a-z0-9][a-z0-9._-]*$/,
@@ -187,8 +192,38 @@ export const pluginEnvironmentDriverDeclarationSchema = z.object({
templateConfigBinding: pluginEnvironmentTemplateConfigBindingSchema.optional(),
templateIdentityPaths: z.array(z.string().min(1).max(200)).max(20).optional(),
supportsTemplateDelete: z.boolean().optional(),
// The neutral transport capability: does the provider host an interactive
// login on a real pseudo-terminal? Validate a literal boolean only.
supportsLoginPty: z.boolean().optional(),
// Deprecated alias for `supportsLoginPty`. It exists only so an external
// plugin manifest that declares the old name still loads. Validate a literal
// boolean only. The transform below canonicalizes it onto `supportsLoginPty`
// and drops it, so every downstream reader reads only the canonical field.
supportsSetupTokenLogin: z.boolean().optional(),
configSchema: jsonSchemaSchema,
}).strict().superRefine((driver, ctx) => {
// Reject a manifest that carries both the alias and the canonical field with
// different values. Matching values pass. A single field passes.
if (
driver.supportsLoginPty !== undefined
&& driver.supportsSetupTokenLogin !== undefined
&& driver.supportsLoginPty !== driver.supportsSetupTokenLogin
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"supportsSetupTokenLogin is a deprecated alias for supportsLoginPty; the two flags must not carry different values",
path: ["supportsLoginPty"],
});
}
}).transform((driver) => {
// Canonicalize before any authorization or projection reads the value. The
// canonical field wins; the alias fills it in when only the alias is present.
// Drop the alias so every downstream reader reads only `supportsLoginPty`. An
// undefined value is omitted on JSON serialization, so a driver that declares
// neither field carries no login flag.
const { supportsSetupTokenLogin, ...rest } = driver;
return { ...rest, supportsLoginPty: rest.supportsLoginPty ?? supportsSetupTokenLogin };
});
export type PluginEnvironmentDriverDeclarationInput = z.infer<
@@ -0,0 +1,147 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { afterEach, describe, expect, it } from "vitest";
import {
withWorktreePortRegistryLock,
withWorktreePortRegistryLockSync,
} from "./worktree-port-registry.js";
const temporaryRoots: string[] = [];
function makeTemporaryRoot(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-port-registry-lock-"));
temporaryRoots.push(root);
return root;
}
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
afterEach(() => {
for (const root of temporaryRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
describe("worktree port registry lock", () => {
it("does not reclaim a stale lock while its fallback ownership probe responds", async () => {
const homeDir = makeTemporaryRoot();
const lockPath = path.join(homeDir, ".worktree-port-reservations.lock");
const firstEntered = deferred();
const releaseFirst = deferred();
let secondEntered = false;
const first = withWorktreePortRegistryLock(homeDir, async () => {
fs.renameSync(path.join(lockPath, "owner.json"), path.join(lockPath, "owner.unavailable.json"));
const backupOwnerPath = path.join(lockPath, "owner.backup.json");
const owner = JSON.parse(fs.readFileSync(backupOwnerPath, "utf8"));
fs.writeFileSync(backupOwnerPath, `${JSON.stringify({
...owner,
processIdentity: "unavailable-process-identity",
})}\n`);
const oldTimestamp = new Date(Date.now() - 10_000);
fs.utimesSync(lockPath, oldTimestamp, oldTimestamp);
firstEntered.resolve();
await releaseFirst.promise;
});
await firstEntered.promise;
expect(Date.now() - fs.statSync(lockPath).mtimeMs).toBeGreaterThan(5_000);
const second = withWorktreePortRegistryLock(homeDir, async () => {
secondEntered = true;
});
await delay(100);
expect(secondEntered).toBe(false);
releaseFirst.resolve();
await Promise.all([first, second]);
expect(secondEntered).toBe(true);
}, 10_000);
it("refreshes the lease throughout an async critical section", async () => {
const homeDir = makeTemporaryRoot();
const lockPath = path.join(homeDir, ".worktree-port-reservations.lock");
await withWorktreePortRegistryLock(homeDir, async () => {
await delay(5_250);
expect(Date.now() - fs.statSync(lockPath).mtimeMs).toBeLessThan(2_000);
});
expect(fs.existsSync(lockPath)).toBe(false);
}, 10_000);
it("reclaims an old lock after its owner process exits", async () => {
const homeDir = makeTemporaryRoot();
const lockPath = path.join(homeDir, ".worktree-port-reservations.lock");
fs.mkdirSync(lockPath);
fs.writeFileSync(
path.join(lockPath, "owner.json"),
`${JSON.stringify({
version: 1,
pid: 2_147_483_647,
processIdentity: "dead-process",
probePort: 1,
token: "dead-owner",
})}\n`,
);
const oldTimestamp = new Date(Date.now() - 10_000);
fs.utimesSync(lockPath, oldTimestamp, oldTimestamp);
let entered = false;
await withWorktreePortRegistryLock(homeDir, async () => {
entered = true;
});
expect(entered).toBe(true);
expect(fs.existsSync(lockPath)).toBe(false);
});
it("reclaims an old lock when its pid belongs to a different process", async () => {
const homeDir = makeTemporaryRoot();
const lockPath = path.join(homeDir, ".worktree-port-reservations.lock");
fs.mkdirSync(lockPath);
fs.writeFileSync(
path.join(lockPath, "owner.json"),
`${JSON.stringify({
version: 1,
pid: process.pid,
processIdentity: "reused-pid-owner",
probePort: 1,
token: "abandoned-owner",
})}\n`,
);
const oldTimestamp = new Date(Date.now() - 10_000);
fs.utimesSync(lockPath, oldTimestamp, oldTimestamp);
let entered = false;
await withWorktreePortRegistryLock(homeDir, async () => {
entered = true;
});
expect(entered).toBe(true);
expect(fs.existsSync(lockPath)).toBe(false);
});
it("refreshes the lease while a synchronous critical section blocks the main thread", () => {
const homeDir = makeTemporaryRoot();
const lockPath = path.join(homeDir, ".worktree-port-reservations.lock");
const blocker = new Int32Array(new SharedArrayBuffer(4));
withWorktreePortRegistryLockSync(homeDir, () => {
const oldTimestamp = new Date(Date.now() - 10_000);
fs.utimesSync(lockPath, oldTimestamp, oldTimestamp);
Atomics.wait(blocker, 0, 0, 1_500);
expect(Date.now() - fs.statSync(lockPath).mtimeMs).toBeLessThan(1_250);
});
expect(fs.existsSync(lockPath)).toBe(false);
});
});
@@ -0,0 +1,411 @@
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { setTimeout as delay } from "node:timers/promises";
import { Worker } from "node:worker_threads";
type WorktreePortRegistry = {
version: 1;
configPaths: string[];
};
const WORKTREE_PORT_REGISTRY_FILE = "worktree-port-reservations.json";
const WORKTREE_PORT_REGISTRY_LOCK_DIR = ".worktree-port-reservations.lock";
const WORKTREE_PORT_REGISTRY_LOCK_OWNER_FILE = "owner.json";
const WORKTREE_PORT_REGISTRY_LOCK_OWNER_BACKUP_FILE = "owner.backup.json";
const WORKTREE_PORT_REGISTRY_LOCK_STALE_MS = 5_000;
const WORKTREE_PORT_REGISTRY_LOCK_TIMEOUT_MS = 10_000;
const WORKTREE_PORT_REGISTRY_LOCK_HEARTBEAT_MS = 1_000;
const WORKTREE_PORT_REGISTRY_LOCK_PROBE_TIMEOUT_MS = 500;
const sleepSyncBuffer = new Int32Array(new SharedArrayBuffer(4));
type RegistryLockOwner = {
version: 1;
pid: number;
processIdentity: string;
probePort: number;
token: string;
};
type RegistryLockLease = {
token: string;
worker: Worker;
control: Int32Array;
probePort: number;
};
const REGISTRY_LOCK_HEARTBEAT_SOURCE = `
const fs = require("node:fs");
const net = require("node:net");
const path = require("node:path");
const { parentPort, workerData } = require("node:worker_threads");
const control = new Int32Array(workerData.control);
function touchOwnedLock() {
let sawDifferentOwner = false;
for (const ownerFile of workerData.ownerFiles) {
try {
const owner = JSON.parse(fs.readFileSync(path.join(workerData.lockPath, ownerFile), "utf8"));
if (owner.token === workerData.token) {
try {
const now = new Date();
fs.utimesSync(workerData.lockPath, now, now);
return "refreshed";
} catch {
return "retry";
}
}
sawDifferentOwner = true;
} catch {
// The redundant owner file may still be readable.
}
}
return sawDifferentOwner ? "lost" : "retry";
}
let finished = false;
let timer;
function complete() {
Atomics.store(control, 0, 2);
Atomics.notify(control, 0);
}
function finish() {
if (finished) return;
finished = true;
if (timer) clearInterval(timer);
server.close(complete);
setTimeout(complete, 250).unref();
}
const server = net.createServer((socket) => {
socket.setEncoding("utf8");
socket.setTimeout(workerData.probeTimeoutMs, () => socket.destroy());
socket.once("data", (candidate) => {
socket.end(candidate === workerData.token ? "owned" : "denied");
});
});
parentPort.once("message", finish);
server.once("error", () => {
Atomics.store(control, 0, -1);
Atomics.notify(control, 0);
});
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
Atomics.store(control, 0, -1);
Atomics.notify(control, 0);
return;
}
Atomics.store(control, 2, address.port);
Atomics.store(control, 0, 1);
Atomics.notify(control, 0);
touchOwnedLock();
timer = setInterval(() => {
if (Atomics.load(control, 1) !== 0 || touchOwnedLock() === "lost") finish();
}, workerData.heartbeatMs);
});
`;
const REGISTRY_LOCK_PROBE_SOURCE = `
const net = require("node:net");
const { workerData } = require("node:worker_threads");
const control = new Int32Array(workerData.control);
let finished = false;
function finish(result) {
if (finished) return;
finished = true;
Atomics.store(control, 0, result);
Atomics.notify(control, 0);
process.exit(0);
}
const socket = net.createConnection({ host: "127.0.0.1", port: workerData.port });
socket.setEncoding("utf8");
socket.setTimeout(workerData.timeoutMs, () => finish(2));
socket.once("connect", () => socket.write(workerData.token));
socket.once("data", (response) => finish(response === "owned" ? 1 : 2));
socket.once("error", () => finish(2));
socket.once("close", () => finish(2));
`;
function resolveRegistryLockPath(homeDir: string): string {
fs.mkdirSync(homeDir, { recursive: true });
return path.resolve(homeDir, WORKTREE_PORT_REGISTRY_LOCK_DIR);
}
function readRegistryLockOwner(lockPath: string): RegistryLockOwner | null {
for (const ownerFile of [
WORKTREE_PORT_REGISTRY_LOCK_OWNER_FILE,
WORKTREE_PORT_REGISTRY_LOCK_OWNER_BACKUP_FILE,
]) {
try {
const parsed = JSON.parse(
fs.readFileSync(path.join(lockPath, ownerFile), "utf8"),
) as Partial<RegistryLockOwner>;
if (
parsed.version === 1
&& Number.isInteger(parsed.pid)
&& (parsed.pid ?? 0) > 0
&& typeof parsed.processIdentity === "string"
&& parsed.processIdentity.length > 0
&& Number.isInteger(parsed.probePort)
&& (parsed.probePort ?? 0) > 0
&& (parsed.probePort ?? 0) <= 65_535
&& typeof parsed.token === "string"
&& parsed.token.length > 0
) {
return parsed as RegistryLockOwner;
}
} catch {
// Try the redundant owner record.
}
}
return null;
}
function writeRegistryLockOwner(lockPath: string, owner: RegistryLockOwner): void {
const contents = `${JSON.stringify(owner)}\n`;
for (const ownerFile of [
WORKTREE_PORT_REGISTRY_LOCK_OWNER_FILE,
WORKTREE_PORT_REGISTRY_LOCK_OWNER_BACKUP_FILE,
]) {
const ownerPath = path.join(lockPath, ownerFile);
const temporaryPath = `${ownerPath}.${owner.token}.tmp`;
fs.writeFileSync(temporaryPath, contents, { mode: 0o600 });
fs.renameSync(temporaryPath, ownerPath);
}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return !(error instanceof Error && "code" in error && error.code === "ESRCH");
}
}
function readProcessIdentity(pid: number): string | null {
if (!Number.isInteger(pid) || pid <= 0) return null;
if (process.platform === "linux") {
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
const commandEnd = stat.lastIndexOf(")");
if (commandEnd < 0) return null;
const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
const startTicks = fields[19];
if (!startTicks) return null;
const bootId = fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
return bootId ? `linux:${bootId}:${startTicks}` : null;
} catch {
return null;
}
}
try {
if (process.platform === "win32") {
const ticks = execFileSync("powershell.exe", [
"-NoProfile",
"-NonInteractive",
"-Command",
`(Get-Process -Id ${pid}).StartTime.ToUniversalTime().Ticks`,
], { encoding: "utf8", windowsHide: true }).trim();
return ticks ? `win32:${ticks}` : null;
}
const startedAt = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
encoding: "utf8",
}).trim();
return startedAt ? `${process.platform}:${startedAt}` : null;
} catch {
return null;
}
}
function probeRegistryLockOwner(owner: RegistryLockOwner): boolean {
const control = new Int32Array(new SharedArrayBuffer(4));
const worker = new Worker(REGISTRY_LOCK_PROBE_SOURCE, {
eval: true,
execArgv: [],
workerData: {
control: control.buffer,
port: owner.probePort,
timeoutMs: WORKTREE_PORT_REGISTRY_LOCK_PROBE_TIMEOUT_MS,
token: owner.token,
},
});
Atomics.wait(control, 0, 0, WORKTREE_PORT_REGISTRY_LOCK_PROBE_TIMEOUT_MS + 250);
const isOwner = Atomics.load(control, 0) === 1;
void worker.terminate();
return isOwner;
}
function removeStaleRegistryLock(lockPath: string): boolean {
try {
const ageMs = Date.now() - fs.statSync(lockPath).mtimeMs;
if (ageMs <= WORKTREE_PORT_REGISTRY_LOCK_STALE_MS) return false;
const owner = readRegistryLockOwner(lockPath);
if (owner && isProcessAlive(owner.pid)) {
if (probeRegistryLockOwner(owner)) return false;
const currentIdentity = readProcessIdentity(owner.pid);
if (owner.processIdentity === currentIdentity) return false;
}
const currentOwner = readRegistryLockOwner(lockPath);
if (owner ? currentOwner?.token !== owner.token : currentOwner !== null) return false;
const currentAgeMs = Date.now() - fs.statSync(lockPath).mtimeMs;
if (currentAgeMs <= WORKTREE_PORT_REGISTRY_LOCK_STALE_MS) return false;
fs.rmSync(lockPath, { recursive: true, force: true });
return true;
} catch {
return false;
}
}
function startRegistryLockHeartbeat(lockPath: string, token: string): RegistryLockLease {
const control = new Int32Array(new SharedArrayBuffer(12));
const worker = new Worker(REGISTRY_LOCK_HEARTBEAT_SOURCE, {
eval: true,
execArgv: [],
workerData: {
control: control.buffer,
heartbeatMs: WORKTREE_PORT_REGISTRY_LOCK_HEARTBEAT_MS,
lockPath,
ownerFiles: [
WORKTREE_PORT_REGISTRY_LOCK_OWNER_FILE,
WORKTREE_PORT_REGISTRY_LOCK_OWNER_BACKUP_FILE,
],
probeTimeoutMs: WORKTREE_PORT_REGISTRY_LOCK_PROBE_TIMEOUT_MS,
token,
},
});
Atomics.wait(control, 0, 0, 2_000);
if (Atomics.load(control, 0) !== 1) {
void worker.terminate();
throw new Error(`Failed to start worktree port reservation lock heartbeat at ${lockPath}`);
}
const probePort = Atomics.load(control, 2);
if (probePort <= 0) {
void worker.terminate();
throw new Error(`Failed to start worktree port reservation lock probe at ${lockPath}`);
}
return { token, worker, control, probePort };
}
function stopRegistryLockHeartbeat(lease: RegistryLockLease): void {
Atomics.store(lease.control, 1, 1);
Atomics.notify(lease.control, 1);
lease.worker.postMessage("stop");
if (Atomics.load(lease.control, 0) === 1) {
Atomics.wait(lease.control, 0, 1, 2_000);
}
void lease.worker.terminate();
}
function acquireRegistryLock(lockPath: string, deadline: number): RegistryLockLease | null {
try {
fs.mkdirSync(lockPath);
const token = `${process.pid}-${randomUUID()}`;
let lease: RegistryLockLease | null = null;
try {
const processIdentity = readProcessIdentity(process.pid);
if (!processIdentity) {
throw new Error("Cannot determine worktree port reservation lock owner identity");
}
const owner: RegistryLockOwner = {
version: 1,
pid: process.pid,
processIdentity,
probePort: 1,
token,
};
writeRegistryLockOwner(lockPath, owner);
lease = startRegistryLockHeartbeat(lockPath, token);
writeRegistryLockOwner(lockPath, { ...owner, probePort: lease.probePort });
return lease;
} catch (error) {
if (lease) stopRegistryLockHeartbeat(lease);
fs.rmSync(lockPath, { recursive: true, force: true });
throw error;
}
} catch (error) {
const code = error instanceof Error && "code" in error ? error.code : null;
if (code !== "EEXIST") throw error;
if (removeStaleRegistryLock(lockPath)) return null;
if (Date.now() >= deadline) {
throw new Error(`Timed out waiting for worktree port reservation lock at ${lockPath}`);
}
return null;
}
}
function releaseRegistryLock(lockPath: string, lease: RegistryLockLease): void {
stopRegistryLockHeartbeat(lease);
const owner = readRegistryLockOwner(lockPath);
if (owner?.token !== lease.token) {
return;
}
fs.rmSync(lockPath, { recursive: true, force: true });
}
export function withWorktreePortRegistryLockSync<T>(homeDir: string, run: () => T): T {
const lockPath = resolveRegistryLockPath(homeDir);
const deadline = Date.now() + WORKTREE_PORT_REGISTRY_LOCK_TIMEOUT_MS;
let lease: RegistryLockLease | null = null;
while (!(lease = acquireRegistryLock(lockPath, deadline))) {
Atomics.wait(sleepSyncBuffer, 0, 0, 25);
}
try {
return run();
} finally {
releaseRegistryLock(lockPath, lease);
}
}
export async function withWorktreePortRegistryLock<T>(
homeDir: string,
run: () => Promise<T>,
): Promise<T> {
const lockPath = resolveRegistryLockPath(homeDir);
const deadline = Date.now() + WORKTREE_PORT_REGISTRY_LOCK_TIMEOUT_MS;
let lease: RegistryLockLease | null = null;
while (!(lease = acquireRegistryLock(lockPath, deadline))) {
await delay(25);
}
try {
return await run();
} finally {
releaseRegistryLock(lockPath, lease);
}
}
export function readWorktreePortRegistry(homeDir: string): Set<string> {
const registryPath = path.resolve(homeDir, WORKTREE_PORT_REGISTRY_FILE);
if (!fs.existsSync(registryPath)) return new Set();
try {
const parsed = JSON.parse(fs.readFileSync(registryPath, "utf8")) as Partial<WorktreePortRegistry>;
if (parsed.version !== 1 || !Array.isArray(parsed.configPaths)) return new Set();
return new Set(
parsed.configPaths
.filter((configPath): configPath is string => typeof configPath === "string" && configPath.length > 0)
.map((configPath) => path.resolve(configPath)),
);
} catch {
return new Set();
}
}
export function writeWorktreePortRegistry(homeDir: string, configPaths: Iterable<string>): void {
fs.mkdirSync(homeDir, { recursive: true });
const registryPath = path.resolve(homeDir, WORKTREE_PORT_REGISTRY_FILE);
const persistedPaths = Array.from(new Set(Array.from(configPaths, (configPath) => path.resolve(configPath))))
.filter((configPath) => fs.existsSync(configPath))
.sort();
const registry: WorktreePortRegistry = {
version: 1,
configPaths: persistedPaths,
};
const temporaryPath = `${registryPath}.${process.pid}.tmp`;
fs.writeFileSync(temporaryPath, `${JSON.stringify(registry, null, 2)}\n`, { mode: 0o600 });
fs.renameSync(temporaryPath, registryPath);
}
@@ -0,0 +1,61 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveCanonicalWorktreeSeedSource } from "./worktree-seed-source.js";
const cleanup: string[] = [];
function makeInstance(prefix: string, instanceId: string) {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
cleanup.push(cwd);
const configDir = path.join(cwd, ".paperclip");
const configPath = path.join(configDir, "config.json");
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, "{}\n");
fs.writeFileSync(path.join(configDir, ".env"), `PAPERCLIP_INSTANCE_ID=${instanceId}\n`);
return { cwd, configPath, instanceId };
}
afterEach(() => {
for (const dir of cleanup.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
describe("resolveCanonicalWorktreeSeedSource", () => {
it("returns only the registered base workspace config", () => {
const source = makeInstance("paperclip-seed-source-", "source-instance");
const target = makeInstance("paperclip-seed-target-", "target-instance");
expect(resolveCanonicalWorktreeSeedSource({
registeredBaseWorkspaceCwd: source.cwd,
targetConfigPath: target.configPath,
expectedTargetInstanceId: target.instanceId,
manifestSource: { configPath: source.configPath, instanceId: source.instanceId },
manifestTargetInstanceId: target.instanceId,
})).toMatchObject({
baseWorkspaceCwd: source.cwd,
configPath: source.configPath,
targetConfigPath: target.configPath,
});
});
it("fails closed without registration and when source equals target", () => {
const target = makeInstance("paperclip-seed-same-target-", "target-instance");
const diagnostic = { configPath: target.configPath, instanceId: target.instanceId };
expect(() => resolveCanonicalWorktreeSeedSource({
targetConfigPath: target.configPath,
expectedTargetInstanceId: target.instanceId,
manifestSource: diagnostic,
manifestTargetInstanceId: target.instanceId,
})).toThrow(/not registered/);
expect(() => resolveCanonicalWorktreeSeedSource({
registeredBaseWorkspaceCwd: target.cwd,
targetConfigPath: target.configPath,
expectedTargetInstanceId: target.instanceId,
manifestSource: diagnostic,
manifestTargetInstanceId: target.instanceId,
})).toThrow(/same canonical file/);
});
});
+160
View File
@@ -0,0 +1,160 @@
import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
import path from "node:path";
export type WorktreeSeedSourceDiagnostic = {
configPath?: unknown;
instanceId?: unknown;
};
export type CanonicalWorktreeSeedSource = {
baseWorkspaceCwd: string | null;
configPath: string;
instanceId: string;
targetConfigPath: string;
targetInstanceId: string;
};
export type RegisteredWorktreeSeedSourceInput = {
registeredBaseWorkspaceCwd?: string | null;
explicitSourceConfigPath?: string | null;
targetConfigPath: string;
expectedTargetInstanceId: string;
};
function readInstanceId(configPath: string, label: "source" | "target"): string {
const envPath = path.join(path.dirname(configPath), ".env");
if (!existsSync(envPath)) {
throw new Error(`Registered ${label} Paperclip config is missing its adjacent .env instance pointer.`);
}
const contents = readFileSync(envPath, "utf8");
for (const rawLine of contents.split(/\r?\n/)) {
const match = rawLine.match(
/^\s*(?:export\s+)?PAPERCLIP_INSTANCE_ID\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s#]+))/,
);
const value = (match?.[1] ?? match?.[2] ?? match?.[3] ?? "").trim();
if (value) return value;
}
throw new Error(`Registered ${label} Paperclip config has no PAPERCLIP_INSTANCE_ID binding.`);
}
function canonicalRegularFile(filePath: string, label: string): string {
const resolved = path.resolve(filePath);
let canonical: string;
try {
canonical = realpathSync(resolved);
} catch {
throw new Error(`${label} does not exist at ${resolved}.`);
}
if (canonical !== resolved || lstatSync(resolved).isSymbolicLink()) {
throw new Error(`${label} must be a canonical path and cannot use a symlink alias.`);
}
if (!lstatSync(canonical).isFile()) {
throw new Error(`${label} is not a regular file at ${canonical}.`);
}
return canonical;
}
/** Resolve the authoritative source and target identities without consulting diagnostics. */
export function resolveRegisteredWorktreeSeedSource(
input: RegisteredWorktreeSeedSourceInput,
): CanonicalWorktreeSeedSource {
const registeredCwd = input.registeredBaseWorkspaceCwd?.trim();
const explicitSource = input.explicitSourceConfigPath?.trim();
if (!registeredCwd && !explicitSource) {
throw new Error(
"Worktree seed source is not registered. Managed boot requires a project workspace; manual boot requires --from-config.",
);
}
let canonicalBaseCwd: string | null = null;
let registeredConfigPath: string | null = null;
if (registeredCwd) {
const resolvedRegisteredCwd = path.resolve(registeredCwd);
try {
canonicalBaseCwd = realpathSync(resolvedRegisteredCwd);
} catch {
throw new Error(`Registered base project workspace does not exist at ${resolvedRegisteredCwd}.`);
}
if (canonicalBaseCwd !== resolvedRegisteredCwd) {
throw new Error("Registered base project workspace must be canonical and cannot use a symlink alias.");
}
if (!lstatSync(canonicalBaseCwd).isDirectory()) {
throw new Error(`Registered base project workspace is not a directory at ${canonicalBaseCwd}.`);
}
registeredConfigPath = path.join(canonicalBaseCwd, ".paperclip", "config.json");
}
const selectedPath = registeredConfigPath ?? explicitSource!;
const canonicalSourceConfigPath = canonicalRegularFile(selectedPath, "Registered source Paperclip config");
if (registeredConfigPath && canonicalSourceConfigPath !== registeredConfigPath) {
throw new Error("Registered source Paperclip config escapes the base project workspace or uses a symlink alias.");
}
if (explicitSource) {
const canonicalExplicitSource = canonicalRegularFile(explicitSource, "Explicit source Paperclip config");
if (canonicalExplicitSource !== canonicalSourceConfigPath) {
throw new Error("Explicit source Paperclip config does not match the registered base project workspace.");
}
}
const canonicalTargetConfigPath = canonicalRegularFile(
input.targetConfigPath,
"Target worktree Paperclip config",
);
if (canonicalSourceConfigPath === canonicalTargetConfigPath) {
throw new Error("Source and target Paperclip configs are the same canonical file.");
}
const sourceInstanceId = readInstanceId(canonicalSourceConfigPath, "source");
const targetInstanceId = readInstanceId(canonicalTargetConfigPath, "target");
if (targetInstanceId !== input.expectedTargetInstanceId) {
throw new Error("Target Paperclip instance does not match the registered worktree instance.");
}
if (sourceInstanceId === targetInstanceId) {
throw new Error("Source and target Paperclip configs name the same instance.");
}
return {
baseWorkspaceCwd: canonicalBaseCwd,
configPath: canonicalSourceConfigPath,
instanceId: sourceInstanceId,
targetConfigPath: canonicalTargetConfigPath,
targetInstanceId,
};
}
/**
* Resolve a worktree seed source without granting authority to the seed manifest.
*
* A managed caller supplies the project-workspace cwd from its server-owned row.
* An operator may instead supply an explicit source config. When both are present,
* the explicit path must still equal the registered project-workspace config.
* Manifest source fields are diagnostic assertions only and never select the
* returned source.
*/
export function resolveCanonicalWorktreeSeedSource(input: RegisteredWorktreeSeedSourceInput & {
manifestSource: WorktreeSeedSourceDiagnostic | null | undefined;
manifestTargetInstanceId?: unknown;
}): CanonicalWorktreeSeedSource {
const registered = resolveRegisteredWorktreeSeedSource(input);
const diagnosticPath = typeof input.manifestSource?.configPath === "string"
? input.manifestSource.configPath.trim()
: "";
if (!diagnosticPath) {
throw new Error("Worktree seed manifest is missing source path diagnostics.");
}
const canonicalDiagnosticPath = canonicalRegularFile(
diagnosticPath,
"Worktree seed manifest source diagnostic",
);
if (path.resolve(diagnosticPath) !== registered.configPath || canonicalDiagnosticPath !== registered.configPath) {
throw new Error("Worktree seed manifest source path does not match the registered canonical source.");
}
if (input.manifestSource?.instanceId !== registered.instanceId) {
throw new Error("Worktree seed manifest source instance does not match the registered source instance.");
}
if (input.manifestTargetInstanceId !== registered.targetInstanceId) {
throw new Error("Worktree seed manifest target instance does not match the registered target instance.");
}
return registered;
}
+1039 -601
View File
File diff suppressed because it is too large. Load diff
+65
View File
@@ -0,0 +1,65 @@
# Paperclip v2026.821.0
> Released: 2026-08-21
Paperclip v2026.821.0 is a fast follow to 2026.817.0 — and the first stable to walk the full canary → nightly → beta → stable happy path end to end, soaking as `2026.818.0-beta.1` before promotion. It carries 172 commits: chat-style tasks graduate from experiment to the default experience, managed runtime previews become reachable over Tailscale HTTPS, sandbox execution gains a verified provider capability contract, you can sign in to Claude and Codex from inside the product, large company imports survive dropped connections, and a large batch of long-standing community fixes lands.
## Breaking Changes
None of these require action for a typical install, but read through before upgrading:
- **Chat-style tasks are the default.** The "Chat-Style Tasks" experiment toggle is gone; the task detail page is now the conversation view for everyone. A new "Classic Task Interface" preference (`enableClassicTaskInterface`, replacing `enableTaskChatRedesign` in instance settings) restores the pre-chat page — page-level header, plain comment thread, fixed Properties sidebar — but chat-only features are unavailable there. ([#11101](https://github.com/paperclipai/paperclip/pull/11101))
- **Sandbox session-output streaming is now capability-driven, and three operator flags are gone.** The server's `streamAgentSessionOutput` setting and the Daytona plugin's `useSessions` and `useLogStream` keys no longer exist; the verified provider capability snapshot is the only streaming decision, with an automatic poll fallback. Saved configurations that still carry the removed keys load fine — the keys are simply inert. ([#11463](https://github.com/paperclipai/paperclip/pull/11463), [#11557](https://github.com/paperclipai/paperclip/pull/11557))
- **Managed dev runtimes now default to Tailscale HTTPS previews.** When the host broker is available, eligible Paperclip-managed worktree runtimes are exposed as `tailscale_https` instead of loopback HTTP. Set `PAPERCLIP_MANAGED_RUNTIME_HTTPS=off` to keep the old behavior, or `force` to fail closed when the broker is missing. Managed runtime control routes also gained two new `409` responses (`workspace_runtime_control_in_progress`, `workspace_runtime_lease_conflict`) — callers that assumed a control always starts must handle a conflict. ([#11524](https://github.com/paperclipai/paperclip/pull/11524), [#11525](https://github.com/paperclipai/paperclip/pull/11525))
- **Interaction resolver policy defaults to `anyone` for new interactions.** Every interaction kind now goes through one resolver-policy evaluator with recorded provenance, instead of per-route rules that blocked valid agents. Migration `0218` deliberately does **not** widen anything already pending: existing rows keep their old restrictions under new names (`board_or_agents` → `not_creator`, `board_only` → `human_only`). ([#11376](https://github.com/paperclipai/paperclip/pull/11376))
- **The decision-training surfaces are removed from the Decisions desk** — the training library route, row actions, and drawer. The backend snapshot contract is unchanged and no data migration runs. ([#11225](https://github.com/paperclipai/paperclip/pull/11225))
## Highlights
- **Chat-style tasks, now the default experience** — Last release's experimental conversation view is now how every task works, and it grew up on the way: cleaner message spacing and agent bubbles, searchable assignment and the sub-task tree in the properties pane, document comments docked in a side gutter, and a chat sidebar that surfaces every issue document and agent artifact. Blocked tasks show their blockers as links with live, ordered blocker work right in the thread, and an issue with a live run shows a calm in-flight notice instead of a false alarm. Onboarding is chat-first too: finishing the wizard drops you straight into your first task's conversation, where the agent greets you, asks clarifying questions, and proposes a plan you approve item by item. ([#11101](https://github.com/paperclipai/paperclip/pull/11101), [#11263](https://github.com/paperclipai/paperclip/pull/11263), [#11226](https://github.com/paperclipai/paperclip/pull/11226), [#11456](https://github.com/paperclipai/paperclip/pull/11456), [#11487](https://github.com/paperclipai/paperclip/pull/11487), [#11423](https://github.com/paperclipai/paperclip/pull/11423))
- **HTTPS previews for managed runtimes, over Tailscale** — A managed runtime service used to listen on plain loopback HTTP, so an agent's branch preview was unreachable from a phone or a second computer. Paperclip can now expose it at a real HTTPS URL on your tailnet, built in three deliberate layers: a separate least-privilege host broker that authorizes every mapping (peer credentials, a fixed port range, protected-port denies, append-only audit), the exposure lifecycle with durable issue-owned runtime leases and bounded control recovery so a stranded control can never block the lane forever, and a single port-pair mediator so a start can never collide with or adopt another issue's service — every mismatch fails closed and names the conflict. ([#11524](https://github.com/paperclipai/paperclip/pull/11524), [#11525](https://github.com/paperclipai/paperclip/pull/11525), [#11526](https://github.com/paperclipai/paperclip/pull/11526))
- **Sign in to Claude and Codex from inside Paperclip** — The new-agent page now offers Claude subscription login before you create the agent, on both Test lanes, with diagnostics kept in redacted server logs and login URLs normalized to allowlisted Anthropic hosts. Under the hood, a guarded setup-token flow drives `claude setup-token` through a sandbox pseudo-terminal with owner-bound sessions and one-time token delivery, and the Codex adapter gets an equivalent sandbox device-login that promotes the credential only after readiness checks and then deletes the temporary sandbox. Deployments behind a TLS-terminating platform edge (Railway, Render, Fly, …) can declare `CLAUDE_LOGIN_EDGE_TLS_TERMINATED=true` so the transport guard stops warning about a connection that is HTTPS to the user, and a promoted device-login credential now survives managed-home re-seeding. ([#11347](https://github.com/paperclipai/paperclip/pull/11347), [#11286](https://github.com/paperclipai/paperclip/pull/11286), [#11237](https://github.com/paperclipai/paperclip/pull/11237), [#11579](https://github.com/paperclipai/paperclip/pull/11579), [#11578](https://github.com/paperclipai/paperclip/pull/11578))
- **A real capability contract for sandbox providers** — Last release introduced live output streaming and persistent sessions for Daytona; this release makes those behaviors provable instead of configured. Providers declare capabilities in their manifest, the live worker verifies them, and the server resolves the effective set fail-closed as the intersection of declared, verified, and configured — a declared capability can never grant a method the worker didn't prove. Streaming and persistent sessions now turn on wherever the verified snapshot allows, with an automatic poll fallback, and the three operator flags that used to gate them are gone (see Breaking Changes). ([#11463](https://github.com/paperclipai/paperclip/pull/11463), [#11557](https://github.com/paperclipai/paperclip/pull/11557))
- **Chunked, resumable company imports** — Last release raised the import cap to 1 GB; this release makes the transfer worthy of it. A dropped connection at 90% used to start the whole upload over — imports now move as verified ~32 MB parts spooled to disk with a durable run ledger, so an interrupted transfer resumes from the parts already uploaded (across dropped connections, page refreshes, and server restarts) and peak upload memory drops to one part. The Import page and CLI gained the resumable upload and a chunked preview, bundles now preserve task timestamps and hierarchy through export/import, imported agents keep their own adapter instead of being coerced to the destination's CEO adapter, and freshly imported tasks stay quiescent under the productivity review sweep. ([#11223](https://github.com/paperclipai/paperclip/pull/11223), [#11224](https://github.com/paperclipai/paperclip/pull/11224), [#11193](https://github.com/paperclipai/paperclip/pull/11193), [#11192](https://github.com/paperclipai/paperclip/pull/11192), [#11191](https://github.com/paperclipai/paperclip/pull/11191))
## Improvements
- **Onboarding meets you where you are** — A company with no agent is sent into onboarding automatically, opening at the right step: the mission step is skipped when the company already has one, an existing mission is carried into the wizard, and the step is decided once instead of jumping under a mid-flow customer. The wizard's agent and connect-model steps were reworked for the chat-first flow, no longer keep an invisible disabled adapter selected, and the decorative panel follows your theme. ([#11352](https://github.com/paperclipai/paperclip/pull/11352), [#11416](https://github.com/paperclipai/paperclip/pull/11416), [#11429](https://github.com/paperclipai/paperclip/pull/11429), [#11371](https://github.com/paperclipai/paperclip/pull/11371), [#11379](https://github.com/paperclipai/paperclip/pull/11379))
- **Paperclip Cloud onboarding seed** — An instance can now receive the mission, first agent, and first task collected during Paperclip Cloud signup and apply them at activation, so a seeded stack opens ready to work instead of asking again. ([#11098](https://github.com/paperclipai/paperclip/pull/11098))
- **Managed sandbox deployments** — Tenants can edit environment variables on a platform-managed sandbox environment (and only that), and a managed-sandbox-only mode hides local execution entirely with run selection failing closed. Bundled providers' documented credential env vars (e.g. `DAYTONA_API_KEY`) now reach their plugin workers, and the synthetic "Local" default option respects the mode. ([#11200](https://github.com/paperclipai/paperclip/pull/11200), [#11244](https://github.com/paperclipai/paperclip/pull/11244))
- **Archived work reopens in place** — An archived isolated execution workspace can be reopened where it was, including archived git worktrees for managed-checkout projects. Cold arrivals on an archived company's URL are bounced to a clear page with a one-click Unarchive. ([#11322](https://github.com/paperclipai/paperclip/pull/11322), [#11395](https://github.com/paperclipai/paperclip/pull/11395), [#11302](https://github.com/paperclipai/paperclip/pull/11302))
- **Review governance** — Review-policy verdicts are serialized and transactional, a verdict can no longer bypass a policy by downgrading, the requester is persisted atomically, and the UI badges the review policy when it is what constrains approval. ([#11405](https://github.com/paperclipai/paperclip/pull/11405), [#10938](https://github.com/paperclipai/paperclip/pull/10938))
- **Agents can look up secrets by name** — A read-only `/secrets/catalog` endpoint maps secret names to ids for agents wiring env bindings — no values, no provider config. Issue-document annotations are also included in agent review context, so feedback left on a document reaches the agent that acts on it. ([#9530](https://github.com/paperclipai/paperclip/pull/9530), [#11332](https://github.com/paperclipai/paperclip/pull/11332))
- **Search that scales** — The parent-issue picker searches on the server instead of the loaded page, and the issues list API gained an `updatedSince` filter. ([#11334](https://github.com/paperclipai/paperclip/pull/11334), [#9050](https://github.com/paperclipai/paperclip/pull/9050))
- **Observability** — Host-side `stage.sync` steps are instrumented and the agent process span is homed correctly, continuing last release's sandbox-startup tracing work. ([#11301](https://github.com/paperclipai/paperclip/pull/11301))
- **See your channel with `paperclipai channels`** — the new command shows every release lane (stable, beta, nightly, canary), the version each currently resolves to, and which one your install follows. ([#11210](https://github.com/paperclipai/paperclip/pull/11210))
## Fixes
- **Deployments behind an authenticating proxy work end to end** — The live-events WebSocket now authenticates cloud-proxied browsers (previously it could never connect through an auth-terminating proxy), the service worker's fetch fallback always answers with a real `Response` instead of a blank page, the web app manifest is fetched with credentials, and the UI survives first load against a cold backend. The archived-company selection ping-pong that could trap a session in a redirect loop is gone. ([#11290](https://github.com/paperclipai/paperclip/pull/11290), [#11292](https://github.com/paperclipai/paperclip/pull/11292), [#11245](https://github.com/paperclipai/paperclip/pull/11245), [#11246](https://github.com/paperclipai/paperclip/pull/11246), [#11300](https://github.com/paperclipai/paperclip/pull/11300))
- **Everything is scoped to the signed-in account** — Invite membership checks, company selection, and query caches are all keyed to the account that is actually signed in, caches clear on sign-out, and the stored company survives a failed company request instead of being dropped. Onboarding drafts verify ownership against a list fetched this session and never restore wizard state for a company the user does not own. ([#11417](https://github.com/paperclipai/paperclip/pull/11417), [#11430](https://github.com/paperclipai/paperclip/pull/11430), [#11380](https://github.com/paperclipai/paperclip/pull/11380), [#11477](https://github.com/paperclipai/paperclip/pull/11477), [#11382](https://github.com/paperclipai/paperclip/pull/11382), [#11370](https://github.com/paperclipai/paperclip/pull/11370))
- **Security hardening** — Tool-access routes no longer leak cross-tenant ID oracles, routine webhooks reject HMAC replays, routine detail responses exclude assignee configuration, page-publish uploader credentials are scoped to the publish helper, execution workspace summaries are isolated, only accessible workspace file links are highlighted, the heartbeat-fallback comment can never publish a raw transcript, and CLI guidance shown to users goes through the safe `npx` form. ([#9589](https://github.com/paperclipai/paperclip/pull/9589), [#9994](https://github.com/paperclipai/paperclip/pull/9994), [#9818](https://github.com/paperclipai/paperclip/pull/9818), [#10894](https://github.com/paperclipai/paperclip/pull/10894), [#10790](https://github.com/paperclipai/paperclip/pull/10790), [#11090](https://github.com/paperclipai/paperclip/pull/11090), [#10143](https://github.com/paperclipai/paperclip/pull/10143), [#11343](https://github.com/paperclipai/paperclip/pull/11343), [#11400](https://github.com/paperclipai/paperclip/pull/11400))
- **Runs keep their context** — Agent sessions are reused across execution handoffs and kept across issue comments, comment-wake decisions read post-insert issue state, stale closure-comment wakeups on done issues stop, successful-run handoff recovery skips recovery-action-driven and plugin-managed runs, and ACP run lifecycle corrections settle failures, sync workspaces back, and clean up leases properly. ([#9917](https://github.com/paperclipai/paperclip/pull/9917), [#10111](https://github.com/paperclipai/paperclip/pull/10111), [#10068](https://github.com/paperclipai/paperclip/pull/10068), [#8656](https://github.com/paperclipai/paperclip/pull/8656), [#9010](https://github.com/paperclipai/paperclip/pull/9010), [#9047](https://github.com/paperclipai/paperclip/pull/9047), [#11454](https://github.com/paperclipai/paperclip/pull/11454))
- **Sandbox plumbing resilience** — The sandbox stdin file race is closed with atomic writes and a fault-tolerant poller, stdin writes are ordered in the process-session bridge, the callback bridge poll loop has a per-iteration timeout and watchdog, bridge traffic forwards to the local listen origin, the agent-hire skill's routes pass the callback bridge, runs that hit a provider worker restart window retry instead of failing setup, Daytona liveness calls are bounded per call, and `syncOut` accepts bsdtar tarball listings. ([#11235](https://github.com/paperclipai/paperclip/pull/11235), [#11406](https://github.com/paperclipai/paperclip/pull/11406), [#11341](https://github.com/paperclipai/paperclip/pull/11341), [#10017](https://github.com/paperclipai/paperclip/pull/10017), [#8978](https://github.com/paperclipai/paperclip/pull/8978), [#10212](https://github.com/paperclipai/paperclip/pull/10212), [#11408](https://github.com/paperclipai/paperclip/pull/11408), [#11289](https://github.com/paperclipai/paperclip/pull/11289))
- **Adapter reliability** — Claude local pipes the print prompt via stdin and no longer fails root-only permission skips; Codex local skips benign stderr warnings when deriving a fallback error; OpenCode retries its models preflight under transient contention and treats the availability probe as non-fatal; Grok reports real token usage and cost instead of hardcoded zeros; Cursor cloud drops an unreachable API callback; hiring onto an adapter the instance cannot run is refused; and adapters load on Windows (`pathToFileURL` before dynamic import). ([#9500](https://github.com/paperclipai/paperclip/pull/9500), [#9463](https://github.com/paperclipai/paperclip/pull/9463), [#10003](https://github.com/paperclipai/paperclip/pull/10003), [#9225](https://github.com/paperclipai/paperclip/pull/9225), [#10294](https://github.com/paperclipai/paperclip/pull/10294), [#10433](https://github.com/paperclipai/paperclip/pull/10433), [#8546](https://github.com/paperclipai/paperclip/pull/8546), [#10256](https://github.com/paperclipai/paperclip/pull/10256), [#4287](https://github.com/paperclipai/paperclip/pull/4287))
- **Server correctness** — `DELETE /api/issues/:id` succeeds for issues with dependents (comments and read-state cascade; cost and finance records are kept with their issue reference cleared), non-UUID company refs return 404 instead of 500, cross-package Zod errors are recognized, wiring blockers in a PATCH no longer implicitly reopens a blocked issue, unsigned ask-first requests stay out of the review queue without being cancelled, the terminal workspace reaper stops starving on its oldest candidates, the agent run JWT default TTL is aligned to the documented 48h so suspended-laptop runs stop waking with an expired credential, an explicit `PAPERCLIP_API_URL` overrides the derived runtime URL in run env, base-URL ports are only rewritten for loopback hosts, and silent nonzero exits in the Hermes flow surface as failures. ([#11331](https://github.com/paperclipai/paperclip/pull/11331), [#9959](https://github.com/paperclipai/paperclip/pull/9959), [#10168](https://github.com/paperclipai/paperclip/pull/10168), [#10269](https://github.com/paperclipai/paperclip/pull/10269), [#11338](https://github.com/paperclipai/paperclip/pull/11338), [#11238](https://github.com/paperclipai/paperclip/pull/11238), [#10176](https://github.com/paperclipai/paperclip/pull/10176), [#10339](https://github.com/paperclipai/paperclip/pull/10339), [#10258](https://github.com/paperclipai/paperclip/pull/10258), [#10107](https://github.com/paperclipai/paperclip/pull/10107))
- **Workspace Git scans are bounded** — One bounded scheduler now coalesces and caps every expensive workspace Git scan (a request storm once produced ~270 concurrent `git status` processes and starved health checks), and the browser only starts scans when the file panel is open and visible. ([#11572](https://github.com/paperclipai/paperclip/pull/11572))
- **UI paper cuts** — The queued-message interrupt action is back, agent secret access moved to a searchable secrets tab, slash autocomplete scrolls inside dialogs, inbox archive gained an undo toast and folded subtasks are remembered, inbox routing honors the saved policy for explicit targets, board approval prose renders as markdown, create-dialog badges use the real issue prefix, issue-document deep links route correctly, truncated issue descriptions are flagged, generated invite links prefer the public base URL, selected run lifecycle and logs stay current, and the mobile task chat gained blocker actions, a composer aligned with the thread, and a new-task dialog that stays above the keyboard. ([#11374](https://github.com/paperclipai/paperclip/pull/11374), [#11283](https://github.com/paperclipai/paperclip/pull/11283), [#11222](https://github.com/paperclipai/paperclip/pull/11222), [#11220](https://github.com/paperclipai/paperclip/pull/11220), [#11069](https://github.com/paperclipai/paperclip/pull/11069), [#11221](https://github.com/paperclipai/paperclip/pull/11221), [#9817](https://github.com/paperclipai/paperclip/pull/9817), [#8550](https://github.com/paperclipai/paperclip/pull/8550), [#11551](https://github.com/paperclipai/paperclip/pull/11551), [#4771](https://github.com/paperclipai/paperclip/pull/4771), [#7619](https://github.com/paperclipai/paperclip/pull/7619), [#10169](https://github.com/paperclipai/paperclip/pull/10169), [#11282](https://github.com/paperclipai/paperclip/pull/11282), [#11296](https://github.com/paperclipai/paperclip/pull/11296), [#11281](https://github.com/paperclipai/paperclip/pull/11281))
- **Odds and ends** — Skill sync refreshes project folders in place instead of replacing them, issue writes through board keys work regardless of assignee, environment-secret bindings resolve their company context on first save, and worktree-mode dev instances warn when the embedded-Postgres data directory sits in the OS temp dir. ([#11066](https://github.com/paperclipai/paperclip/pull/11066), [#9025](https://github.com/paperclipai/paperclip/pull/9025), [#11291](https://github.com/paperclipai/paperclip/pull/11291), [#8283](https://github.com/paperclipai/paperclip/pull/8283))
## Upgrade Guide
- **Migrations**: this release adds 11 database migrations (`0212` through `0222`), covering the one-per-company onboarding first task (`0212`), resumable company transfer runs (`0213`), adapter device-login sessions (`0214`, `0215`), Cloud onboarding seeds (`0216`), issue-deletion foreign-key policies (`0217`), interaction resolver-policy defaults and provenance (`0218`), runtime exposure state (`0219`), execution-workspace runtime leases (`0220`), Claude setup-token sessions (`0221`), and environment-lease references that survive environment deletion (`0222`). Migrations run automatically on startup — no manual action required. Nothing drops a table or deletes data; note that `0218` rewrites resolver-policy values on existing pending interactions (conservatively — see Breaking Changes) and `0217`/`0222` change what happens to dependent rows on delete.
- **New optional environment variables** (all have sensible defaults; set only if you need them):
- `PAPERCLIP_MANAGED_RUNTIME_HTTPS` — `auto` (default), `off`, or `force`; controls the Tailscale HTTPS default for managed runtime previews (see Breaking Changes). `PAPERCLIP_TAILSCALE_BROKER_SOCKET` and `PAPERCLIP_TAILSCALE_DNS_NAME` override the broker socket path and the tailnet DNS name; `PAPERCLIP_VITE_HMR_PROTOCOL` (`ws`/`wss`) pins the dev-preview HMR protocol. ([#11524](https://github.com/paperclipai/paperclip/pull/11524), [#11525](https://github.com/paperclipai/paperclip/pull/11525))
- `CLAUDE_LOGIN_TRUSTED_PROXIES` — allowlisted proxy peers for the Claude login transport guard; `CLAUDE_LOGIN_EDGE_TLS_TERMINATED=true` — declare platform-edge TLS termination on managed platforms where the proxy addresses aren't knowable. ([#11286](https://github.com/paperclipai/paperclip/pull/11286), [#11579](https://github.com/paperclipai/paperclip/pull/11579))
- `PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE` — adapter type for the agent created by a Cloud onboarding seed. ([#11098](https://github.com/paperclipai/paperclip/pull/11098))
- `SANDBOX_ORPHAN_CLEANUP_SPOOL_DIR` — where durable sandbox cleanup records are spooled; `PAPERCLIP_PROCESS_SESSION_STDIN_MAX_RETRIES` — retry ceiling for the sandbox stdin poller. ([#11347](https://github.com/paperclipai/paperclip/pull/11347), [#11235](https://github.com/paperclipai/paperclip/pull/11235))
- **Removed configuration keys**: `streamAgentSessionOutput` (server) and `useSessions` / `useLogStream` (Daytona plugin) no longer do anything — remove them from saved configs at your leisure; they load but are inert. ([#11557](https://github.com/paperclipai/paperclip/pull/11557))
- **Agent run JWT TTL**: the server default now matches the documented 48h (`PAPERCLIP_AGENT_JWT_TTL_SECONDS=172800`). If you relied on the old implicit 1h expiry, set the variable explicitly. ([#10176](https://github.com/paperclipai/paperclip/pull/10176))
- **HTTPS previews**: to use them, install Tailscale on the host and run the new broker (see the `@paperclipai/tailscale-https-broker` README); to opt out, set `PAPERCLIP_MANAGED_RUNTIME_HTTPS=off`. Nothing else changes for unmanaged or custom services.
## Contributors
This release has 172 commits from 32 contributors. Thank you to everyone who contributed to this release!
@0xopaque, @adamteale, @ApolinarioRatio, @austinpilz, @christianlappin, @cucurigoo, @dmndbrp-oss, @dylanroy, @edgardfrz, @fronc, @futhgar, @panbanda, @raviy06, @samrusani, @santastabber, @sauerdaniel, @seb-veto, @Sergio-LPA, @stubbi, @ulisavo, @vmarchaud, @webdevdot
@@ -37,6 +37,9 @@ test.after(() => {
*/
function makeBaseWorkspace({ helpExit, initExit, ensureExit = 0 }) {
const baseCwd = makeTempDir("paperclip-provision-base-");
fs.mkdirSync(path.join(baseCwd, ".paperclip"), { recursive: true });
fs.writeFileSync(path.join(baseCwd, ".paperclip", "config.json"), "{}\n");
fs.writeFileSync(path.join(baseCwd, ".paperclip", ".env"), "PAPERCLIP_INSTANCE_ID=base-source\n");
const runnerPath = path.join(baseCwd, "cli", "node_modules", "tsx", "dist", "cli.mjs");
const entryPath = path.join(baseCwd, "cli", "src", "index.ts");
fs.mkdirSync(path.dirname(runnerPath), { recursive: true });
@@ -68,7 +71,21 @@ if (cliArgs[0] === "worktree" && cliArgs[1] === "ensure-seeded") {
process.exit(${ensureExit});
}
fs.rmSync(".paperclip/seed-pending", { force: true });
fs.writeFileSync(".paperclip/seed-complete", "{}\\n");
fs.rmSync(".paperclip/seed-complete", { force: true });
fs.writeFileSync(".paperclip/seed-manifest.json", JSON.stringify({
version: 2,
source: { instanceId: "base-source", configPath: ${JSON.stringify(path.join(baseCwd, ".paperclip", "config.json"))} },
snapshotAt: "2026-08-19T00:00:00.000Z",
seedMode: "minimal",
migrationRevision: "0142_test.sql",
targetInstanceId: "target-test",
phase: "complete",
state: "verified",
attemptId: "attempt-test",
startedAt: "2026-08-19T00:00:00.000Z",
finishedAt: "2026-08-19T00:01:00.000Z",
diagnostics: [{ phase: "complete", status: "succeeded", at: "2026-08-19T00:01:00.000Z" }],
}) + "\\n");
process.exit(0);
}
process.exit(0);
@@ -91,6 +108,8 @@ function runProvision(baseCwd, { pathPrefix } = {}) {
PAPERCLIP_WORKSPACE_BRANCH: "feature/provision-test",
PAPERCLIP_WORKTREES_DIR: worktreesHome,
PAPERCLIP_HOME: path.join(worktreesHome, "no-such-instance-home"),
PAPERCLIP_PROJECT_WORKSPACE_ID: "project-workspace-1",
PAPERCLIP_SEED_EXPECTED_COMPANY_ID: "company-1",
},
});
return { result, worktreeCwd, worktreesHome };
@@ -109,6 +128,8 @@ function runRuntimeProvision(baseCwd, worktreeCwd) {
PAPERCLIP_WORKSPACE_BRANCH: "feature/provision-runtime-test",
PAPERCLIP_WORKTREES_DIR: worktreesHome,
PAPERCLIP_HOME: path.join(worktreesHome, "no-such-instance-home"),
PAPERCLIP_PROJECT_WORKSPACE_ID: "project-workspace-1",
PAPERCLIP_COMPANY_ID: "company-1",
},
});
}
@@ -137,7 +158,10 @@ test("uses the base CLI when its import graph boots", () => {
assert.equal(result.status, 0, result.stderr);
const config = readWorktreeConfig(worktreeCwd);
assert.equal(config.$meta.source, "fake-cli");
assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending")));
assert.equal(
JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state,
"pending",
);
const initInvocation = readCliInvocations(baseCwd).find(
(args) => args[0] === "worktree" && args[1] === "init",
);
@@ -166,7 +190,49 @@ test("falls back to an isolated config when the base CLI cannot boot", () => {
);
const env = fs.readFileSync(path.join(worktreeCwd, ".paperclip", ".env"), "utf8");
assert.match(env, /PAPERCLIP_IN_WORKTREE=true/);
assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending")));
assert.equal(
JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state,
"pending",
);
});
test("reconciles deployment mode from the registered source when reusing a guest config", () => {
const baseCwd = makeBaseWorkspace({ helpExit: 1, initExit: 0 });
const { result: first, worktreeCwd, worktreesHome } = runProvision(baseCwd);
assert.equal(first.status, 0, first.stderr);
assert.equal(readWorktreeConfig(worktreeCwd).server.deploymentMode, "local_trusted");
fs.writeFileSync(
path.join(baseCwd, ".paperclip", "config.json"),
`${JSON.stringify({
server: {
deploymentMode: "authenticated",
exposure: "private",
},
}, null, 2)}\n`,
);
const second = spawnSync("bash", [script], {
cwd: worktreeCwd,
encoding: "utf8",
env: {
PATH: testPath,
HOME: os.homedir(),
PAPERCLIP_WORKSPACE_BASE_CWD: baseCwd,
PAPERCLIP_WORKSPACE_CWD: worktreeCwd,
PAPERCLIP_WORKSPACE_BRANCH: "feature/provision-test",
PAPERCLIP_WORKTREES_DIR: worktreesHome,
PAPERCLIP_HOME: path.join(worktreesHome, "no-such-instance-home"),
PAPERCLIP_PROJECT_WORKSPACE_ID: "project-workspace-1",
PAPERCLIP_SEED_EXPECTED_COMPANY_ID: "company-1",
},
});
assert.equal(second.status, 0, second.stderr);
assert.match(second.stderr, /Reusing existing isolated Paperclip worktree config/);
assert.match(second.stderr, /Reconciled isolated Paperclip worktree deployment mode/);
assert.equal(readWorktreeConfig(worktreeCwd).server.deploymentMode, "authenticated");
assert.equal(readWorktreeConfig(worktreeCwd).server.exposure, "private");
});
test("repairs an unhealthy base install under the lock and then uses the CLI", (t) => {
@@ -181,6 +247,9 @@ test("repairs an unhealthy base install under the lock and then uses the CLI", (
// The CLI's health is controlled by a flag file, and a fake `pnpm install`
// creates that flag — modeling a forced reinstall that relinks the store.
const baseCwd = makeTempDir("paperclip-provision-repair-base-");
fs.mkdirSync(path.join(baseCwd, ".paperclip"), { recursive: true });
fs.writeFileSync(path.join(baseCwd, ".paperclip", "config.json"), "{}\n");
fs.writeFileSync(path.join(baseCwd, ".paperclip", ".env"), "PAPERCLIP_INSTANCE_ID=base-source\n");
const healthFlag = path.join(baseCwd, "cli-healthy.flag");
const runnerPath = path.join(baseCwd, "cli", "node_modules", "tsx", "dist", "cli.mjs");
const entryPath = path.join(baseCwd, "cli", "src", "index.ts");
@@ -260,23 +329,72 @@ test("runtime provisioning invokes ensure-seeded once and fast-exits after succe
const first = runRuntimeProvision(baseCwd, worktreeCwd);
assert.equal(first.status, 0, first.stderr);
assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-complete")));
assert.equal(
JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state,
"verified",
);
assert.ok(!fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending")));
const ensureCallsAfterFirst = readCliInvocations(baseCwd)
.filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded");
assert.equal(ensureCallsAfterFirst.length, 1);
assert.ok(ensureCallsAfterFirst[0].includes("--config"));
assert.ok(ensureCallsAfterFirst[0].includes("--from-config"));
assert.ok(!ensureCallsAfterFirst[0].includes("--from-config"));
const second = runRuntimeProvision(baseCwd, worktreeCwd);
assert.equal(second.status, 0, second.stderr);
assert.match(second.stderr, /already seeded; skipping/);
assert.match(second.stderr, /verified seed manifest.*skipping/);
const ensureCallsAfterSecond = readCliInvocations(baseCwd)
.filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded");
assert.equal(ensureCallsAfterSecond.length, 1);
});
test("runtime provisioning seeds a worktree config that has no seed markers", () => {
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
const worktreeCwd = makeTempDir("paperclip-provision-runtime-unmarked-config-");
fs.mkdirSync(path.join(worktreeCwd, ".paperclip"), { recursive: true });
fs.writeFileSync(path.join(worktreeCwd, ".paperclip", "config.json"), "{}\n");
const result = runRuntimeProvision(baseCwd, worktreeCwd);
assert.equal(result.status, 0, result.stderr);
assert.equal(
readCliInvocations(baseCwd)
.filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded").length,
1,
);
assert.equal(
JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state,
"verified",
);
});
test("runtime provisioning bootstraps and seeds an empty .paperclip directory", () => {
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
fs.mkdirSync(path.join(baseCwd, "scripts"), { recursive: true });
fs.copyFileSync(script, path.join(baseCwd, "scripts", "provision-worktree.sh"));
const worktreeCwd = makeTempDir("paperclip-provision-runtime-empty-state-");
fs.mkdirSync(path.join(worktreeCwd, ".paperclip"), { recursive: true });
const result = runRuntimeProvision(baseCwd, worktreeCwd);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stderr, /config is missing; running the built-in worktree provisioner/);
const invocations = readCliInvocations(baseCwd);
assert.equal(
invocations.filter((args) => args[0] === "worktree" && args[1] === "init").length,
1,
);
assert.equal(
invocations.filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded").length,
1,
);
assert.equal(
JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state,
"verified",
);
});
test("runtime provisioning leaves seed-pending in place when ensure-seeded fails", () => {
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0, ensureExit: 4 });
const worktreeCwd = makeTempDir("paperclip-provision-runtime-failure-");
@@ -290,3 +408,22 @@ test("runtime provisioning leaves seed-pending in place when ensure-seeded fails
assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending")));
assert.ok(!fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-complete")));
});
test("runtime provisioning does not trust a truncated verified manifest", () => {
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0, ensureExit: 4 });
const worktreeCwd = makeTempDir("paperclip-provision-runtime-truncated-");
fs.mkdirSync(path.join(worktreeCwd, ".paperclip"), { recursive: true });
fs.writeFileSync(path.join(worktreeCwd, ".paperclip", "config.json"), "{}\n");
fs.writeFileSync(
path.join(worktreeCwd, ".paperclip", "seed-manifest.json"),
JSON.stringify({ version: 2, state: "verified" }),
);
const result = runRuntimeProvision(baseCwd, worktreeCwd);
assert.equal(result.status, 4, result.stderr);
assert.equal(
readCliInvocations(baseCwd)
.filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded").length,
1,
);
});
+35 -9
View File
@@ -70,28 +70,54 @@ if ! git -C "$repo_dir" rev-parse --verify "refs/tags/${beta_tag}" >/dev/null 2>
release_fail "beta tag ${beta_tag} does not exist. Draft notes are generated from a published beta."
fi
source_sha="$(git -C "$repo_dir" rev-parse "${beta_tag}^{commit}")"
source_short="$(git -C "$repo_dir" rev-parse --short "$source_sha")"
if [ -z "$out_file" ]; then
out_file="${repo_dir}/releases/beta/v${beta_version}.md"
fi
# Range start: the newest stable tag reachable from the source commit —
# not the newest by version, which can sit on a divergent lineage (a
# stable cut from a candidate branch, or a source that predates it) and
# would produce an empty or wrong range. Before the first reachable
# stable, fall back to the nearest beta tag strictly before the source;
# with no marker at all, cover the source commit's full history.
range_start="$(git -C "$repo_dir" describe --tags --match 'v[0-9]*' --abbrev=0 "$source_sha" 2>/dev/null || true)"
range_label="$range_start"
# Range start: walk stable tags newest-first and take the first whose
# merge-base with the source is a proper ancestor of the source. A stable
# cut from a candidate branch is not itself an ancestor of master, but
# its merge-base with the source is the promoted source commit — exactly
# the point the shipped stable's content diverges from. A stable that
# already contains the source (promoting an older commit) is skipped so
# the range never collapses to empty. Before the first stable, fall back
# to the nearest beta tag strictly before the source; with no marker at
# all, cover the source commit's full history.
range_start=""
range_label=""
range_cmd_start=""
while IFS= read -r stable_tag; do
[ -n "$stable_tag" ] || continue
mb="$(git -C "$repo_dir" merge-base "$stable_tag" "$source_sha" 2>/dev/null || true)"
[ -n "$mb" ] || continue
if [ "$mb" != "$source_sha" ]; then
range_start="$mb"
if [ "$mb" = "$(git -C "$repo_dir" rev-parse "${stable_tag}^{commit}")" ]; then
range_label="$stable_tag"
range_cmd_start="$stable_tag"
else
# rev-parse --short picks an abbreviation that is unambiguous in
# this repository, unlike a fixed nine-character truncation.
mb_short="$(git -C "$repo_dir" rev-parse --short "$mb")"
range_label="${stable_tag} (merge-base ${mb_short})"
range_cmd_start="$mb_short"
fi
break
fi
done < <(git -C "$repo_dir" tag -l 'v[0-9]*' --sort=-v:refname)
if [ -z "$range_start" ]; then
range_start="$(git -C "$repo_dir" describe --tags --match 'beta/v*' --abbrev=0 "${source_sha}^" 2>/dev/null || true)"
range_label="$range_start"
range_cmd_start="$range_start"
fi
if [ -n "$range_start" ]; then
range="${range_start}..${source_sha}"
else
range="$source_sha"
range_label="the beginning of history"
range_cmd_start="the beginning of history"
release_info "No stable or prior beta tag found; drafting from full history."
fi
@@ -114,7 +140,7 @@ conventional='^(feat|fix)(\([^)]*\))?!?: '
mkdir -p "$(dirname "$out_file")"
{
printf '# Paperclip stable draft — from beta %s\n\n' "$beta_version"
printf '> Auto-generated at beta publish from `git log %s..%s`.\n' "${range_label}" "${source_sha:0:9}"
printf '> Auto-generated at beta publish from `git log %s..%s` (baseline: %s).\n' "${range_cmd_start}" "${source_short}" "${range_label}"
printf '> Edit freely during the soak: rewrite for release-notes voice,\n'
printf '> fold noise, and call out anything a self-hoster must act on.\n'
printf '> The stable promotion reads this file from master and publishes\n'
+20
View File
@@ -79,6 +79,26 @@ test("uses the nearest ancestor stable tag, not the newest by version", () => {
assert.doesNotMatch(body, /new work/);
});
test("starts from the merge-base when the last stable was cut from a candidate branch", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: shipped in the stable (#1)");
git(dir, "checkout", "-q", "-b", "candidate");
commit(dir, "docs: release notes only (#2)");
git(dir, "tag", "v2026.200.0");
git(dir, "checkout", "-q", "master");
commit(dir, "feat: next-release work (#3)");
git(dir, "tag", "beta/v2026.201.0-beta.0");
// The stable tag sits on the unmerged candidate branch, so it is not an
// ancestor of the beta source. The range must start at its merge-base
// with the source (the promoted commit), not fall back past it.
const { body } = runDraft(dir, "2026.201.0-beta.0");
assert.match(body, /- feat: next-release work \(#3\)/);
assert.doesNotMatch(body, /shipped in the stable/);
assert.doesNotMatch(body, /release notes only/);
});
test("falls back to the previous beta tag when no stable tag exists", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: first-train work (#1)");
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff. Show more