Merge master and resolve ACPX config handling conflict

This commit is contained in:
Dotta committed 2026-07-11 01:40:23 +00:00
commit 08089e7ef3
203 files changed
+16348 -1023

No files matched your search

+3 -5
View File
@@ -1,11 +1,9 @@
---
name: check-pr
description: >
Checks a GitHub, GitLab, or Perforce (p4) pull request (or merge request, or shelved changelist)
for unresolved review comments, failing status checks, and incomplete PR descriptions. Waits for
pending checks to complete, categorizes issues as actionable or informational, and optionally fixes
and resolves them. Use when the user wants to check a PR/MR/CL, address review feedback, or prepare
a change for submission.
Check a GitHub, GitLab, or Perforce PR/MR/CL for review comments, failing
checks, and PR-body gaps. Use when asked to inspect, fix, or prepare a change
for submission.
license: MIT
compatibility: Requires git and gh (GitHub CLI), glab (GitLab CLI), or p4 (Perforce CLI) installed and authenticated.
metadata:
+3 -9
View File
@@ -1,15 +1,9 @@
---
name: company-creator
description: >
Create agent company packages conforming to the Agent Companies specification
(agentcompanies/v1). Use when a user wants to create a new agent company from
scratch, build a company around an existing git repo or skills collection, or
scaffold a team/department of agents. Triggers on: "create a company", "make me
a company", "build a company from this repo", "set up an agent company",
"create a team of agents", "hire some agents", or when given a repo URL and
asked to turn it into a company. Do NOT use for importing an existing company
package (use the CLI import command instead) or for modifying a company that
is already running in Paperclip.
Create agent company packages that conform to agentcompanies/v1. Use when asked
to create a company, scaffold an agent team, hire agents, or turn a repo/skills
collection into a company package.
---
# Company Creator
+3 -5
View File
@@ -1,11 +1,9 @@
---
name: create-agent-adapter
description: >
Technical guide for creating a new Paperclip agent adapter. Use when building
a new adapter package, adding support for a new AI coding tool (e.g. a new
CLI agent, API-based agent, or custom process), or when modifying the adapter
system. Covers the required interfaces, module structure, registration points,
and conventions derived from the existing claude-local and codex-local adapters.
Create or modify Paperclip agent adapters across server, UI, and CLI surfaces.
Use when adding support for a new CLI agent, API agent, custom process, or
adapter package.
---
# Creating a Paperclip Agent Adapter
@@ -1,18 +1,14 @@
---
name: create-issue-interaction-ui
description: >
Developer/maintainer skill for adding a new issue-thread interaction kind to
the Paperclip codebase end-to-end: shared contract, server service/routes,
UI card, fixtures/Storybook, CLI/MCP/plugin SDK helpers, agent guidance, and
tests. Use when a Paperclip contributor is asked to introduce a new
interaction family (something analogous to `request_confirmation`,
`request_checkbox_confirmation`, `ask_user_questions`, or `suggest_tasks`)
or to extend the issue-thread interaction system with a new card type. Do
NOT install this on production Paperclip agents — it is for repo work, not
agent runtime behavior.
Add a new Paperclip issue-thread interaction kind end-to-end. Use when repo
work introduces or extends interaction cards like request_confirmation,
checkbox confirmations, ask_user_questions, or suggest_tasks.
---
# Create a new issue-thread interaction UI (developer skill)
# Create a new issue-thread interaction UI (Developer/maintainer skill)
Developer/maintainer skill. Do NOT install this on production Paperclip agents.
This skill walks a Paperclip contributor through introducing a new issue-thread
interaction kind from shared contract to issue-detail wiring, helpers, and
@@ -20,6 +16,8 @@ docs. It is intentionally a developer/maintainer skill: the audience is a
human or coding agent making code changes inside `paperclipai/paperclip`, not
the operational agents that run inside a deployed Paperclip company.
Do NOT install this on production Paperclip agents. This guide is for repository contributors changing Paperclip itself.
## When to use
- A new interaction kind is being introduced (compact picker, structured
@@ -1,10 +1,9 @@
---
name: deal-with-security-advisory
description: >
Handle a GitHub Security Advisory response for Paperclip, including
confidential fix development in a temporary private fork, human coordination
on advisory-thread comments, CVE request, synchronized advisory publication,
and immediate security release steps.
Handle confidential GitHub Security Advisory response for Paperclip. Use when
coordinating advisory triage, private-fork fixes, CVE/publication steps, and
immediate security releases.
---
# Security Vulnerability Response Instructions
@@ -1,13 +1,9 @@
---
name: diagnose-why-work-stopped
description: >
How to handle "why did this work stop / why is this looping?" assignments.
Forensics first on the named tree, surface the exact stop-point, frame the
fix as a general product rule that respects three invariants (productive
work continues, only real blockers stop work, no infinite loops), and
deliver a plan — no code changes — gated by board/CTO approval before
child issues are created. Use whenever the issue title or body asks for
forensics on a stalled, looping, or "went too deep" tree.
Diagnose stalled, looping, or over-recovered Paperclip issue trees and propose
a no-code product-rule plan. Use when asked why work stopped, why it looped, or
how to prevent a tree from going too deep.
---
# Diagnose Why Work Stopped
+3 -5
View File
@@ -1,11 +1,9 @@
---
name: doc-maintenance
description: >
Audit top-level documentation (README, SPEC, PRODUCT) against recent git
history to find drift — shipped features missing from docs or features
listed as upcoming that already landed. Proposes minimal edits, creates
a branch, and opens a PR. Use when asked to review docs for accuracy,
after major feature merges, or on a periodic schedule.
Audit README, SPEC, and PRODUCT docs against recent git history for drift and
make minimal PR-ready edits. Use when asked to review docs for accuracy, after
major feature merges, or on a schedule.
---
# Doc Maintenance Skill
@@ -1,11 +1,9 @@
---
name: paperclip-create-plugin
description: >
Create and develop external Paperclip plugins with the CLI-first workflow.
Use when scaffolding a new plugin, working on a local plugin against a running
Paperclip instance, or updating plugin authoring docs. Covers `paperclipai
plugin init`, the local install loop via `paperclipai plugin install <path>`,
worker/UI rebuild and reload semantics, and the required success checklist.
Create and develop external Paperclip plugins with the CLI-first workflow. Use
when scaffolding a plugin, iterating on a local plugin, installing it into
Paperclip, or updating plugin authoring docs.
---
# Create and develop a Paperclip plugin
@@ -1,12 +1,9 @@
---
name: paperclip-dev-workspace-run-verify-fix
description: >
Run, verify, reseed, and repair a Paperclip isolated dev workspace service.
Use when asked to start or fix a Paperclip project/worktree service and prove
that it is managed by the Paperclip runtime, has the full bootstrapped cloned
database, is healthy, accepts normal dev credentials, exposes populated app
data, and is visible as running from both the control-plane and the served
workspace app.
Run, verify, reseed, and repair Paperclip isolated dev workspace services. Use
when asked to start or fix a managed project/worktree service and prove health,
login readiness, cloned data, and runtime visibility.
---
# Paperclip Dev Workspace Run / Verify / Fix
+3 -4
View File
@@ -1,10 +1,9 @@
---
name: paperclip-page
description: >
Publish static HTML pages and asset folders to the Paperclip-approved S3 and
CloudFront website host. Use when asked to deploy, publish, host, or share a
persistent Paperclip page, wireframe viewer, prototype, report, or other static
site without using here.now.
Publish static HTML pages and asset folders to the Paperclip S3/CloudFront page
host. Use when asked to deploy, host, or share a persistent page, viewer,
prototype, report, or static site without here.now.
---
# Paperclip Page
+3 -4
View File
@@ -1,10 +1,9 @@
---
name: pr-report
description: >
Review a pull request or contribution deeply, explain it tutorial-style for a
maintainer, and produce a polished report artifact such as HTML or Markdown.
Use when asked to analyze a PR, explain a contributor's design decisions,
compare it with similar systems, or prepare a merge recommendation.
Produce a maintainer-grade PR or contribution report as HTML or Markdown. Use
when asked to deeply review a PR, explain a design, compare systems, or prepare
a merge recommendation.
---
# PR Report Skill
+3 -6
View File
@@ -1,12 +1,9 @@
---
name: prcheckloop
description: >
Iteratively gets a GitHub pull request's checks green. Detects the PR for the
current branch or uses a provided PR number, waits for every check on the
latest head SHA to appear and finish, investigates failing checks, fixes
actionable code or test issues, pushes, and repeats. Escalates with a precise
blocker when failures are external, flaky, or not safely fixable. Use when a
PR still has unsuccessful checks after review fixes, including after greploop.
Iterate on a GitHub PR until latest-head checks are green or a precise blocker
is named. Use when a PR still has failing or pending checks after review fixes,
including after greploop.
---
# PRCheckloop
@@ -1,11 +1,9 @@
---
name: release-changelog-discord-message
description: >
Write the Discord release announcement for a stable Paperclip release. Companion
to `release-changelog` — that skill produces the file at `releases/vYYYY.MDD.P.md`;
this one turns that file into a single copy-pasteable Discord post in dotta's
voice and attaches it as the `discord_announcement` document on the release
issue.
Write the Discord announcement for a stable Paperclip release from the release
changelog. Use when a release issue needs a copy-pasteable dotta-voice Discord
post or refreshed discord_announcement document.
---
# Release Discord Announcement Skill
+3 -11
View File
@@ -1,17 +1,9 @@
---
name: terminal-bench-loop
description: >
Run a single Terminal-Bench problem through Paperclip in a bounded,
human-in-the-loop improvement cycle until the smoke passes, the board
rejects the next fix, the iteration budget is exhausted, or a real
blocker is named. Each iteration runs a bounded smoke against an
isolated Paperclip App worktree, captures artifacts, diagnoses the
exact stop point with `/diagnose-why-work-stopped`, requests board
confirmation before any product fix, then reruns against the same
worktree. Use whenever an issue asks to "run Terminal-Bench in a
loop", "drive Terminal-Bench until it passes", "loop fix-git through
Paperclip", or otherwise points at a Terminal-Bench task and asks for
bounded iteration with diagnosis.
Run one Terminal-Bench task through a bounded Paperclip smoke/diagnosis/fix
loop. Use when asked to drive Terminal-Bench until it passes, rerun a
Terminal-Bench loop, or iterate with board-gated fixes and diagnosis.
---
# Terminal-Bench Loop
+4
View File
@@ -491,6 +491,10 @@ V1 non-terminal liveness rule:
- agent-owned `todo`, `in_progress`, `in_review`, and `blocked` issues must have a live execution path, an explicit waiting path, or an explicit recovery path
- `in_review` is healthy only when a typed execution participant, pending issue-thread interaction or approval, user owner, active run, queued wake, or explicit recovery action owns the next action
- a blocked chain is covered only when each unresolved leaf issue is live or explicitly waiting
- 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
- 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
+26 -1
View File
@@ -258,6 +258,29 @@ The valid action-path primitives are:
- a first-class blocker chain whose unresolved leaf issues are themselves healthy
- an open explicit recovery action that names the owner and action needed to restore liveness
### Durable external waits and heartbeat finalization
An external wait counts as a live or waiting path only when the next move survives the current heartbeat and is represented in Paperclip's durable control-plane state. Valid external-wait shapes are:
- a one-shot issue monitor or other persisted scheduled wake that names the responsible assignee, next check time, and bounded timeout/attempt policy
- a first-class blocker or `blocked` disposition that names the external owner and concrete action required to unblock the issue
- a delegated child issue with a responsible owner and its own healthy action path, plus a blocker edge when the source issue must wait for that child; `parentId` alone is not a dependency
An unmanaged local process is not a durable action path. Shell jobs started with `&`, `nohup`, local polling loops, detached PTY sessions, adapter child processes, or similar background watchers do not keep an issue live unless Paperclip persists them as a run or pairs a managed runtime service with a monitor, scheduled wake, blocker, or delegated issue that owns the next check. A PID, session id, log file, comment, or promise to check later is evidence only. The process may be killed when the adapter invocation or heartbeat exits and cannot be assumed observable or recoverable by another worker.
Before a heartbeat finalizes, its issue disposition must therefore be evaluated from durable Paperclip state, not from processes still visible only to that heartbeat. An agent-owned issue may remain `in_progress` after the heartbeat only when another valid action-path primitive already exists. If the only claimed continuation is a local/background watcher, finalization treats the issue as having no live path even when the process has not yet been observed exiting.
If useful deliverable work can continue without the external result, the agent should continue that work or delegate it rather than parking the issue. Use `blocked` only for a real dependency that prevents productive progress. Use a monitor when the assignee owns a bounded future check, and use delegated child work when another owner can make progress independently.
Recovery from an invalid external wait is bounded and idempotent:
1. Record bounded evidence that the completed heartbeat left no durable action path, including the terminal run and any reported local watcher metadata without treating that metadata as liveness.
2. Queue at most one normal-model continuation for the same source state and recovery fingerprint so the assignee can inspect the external result, replace the watcher with a durable wait, continue productive work, or choose a valid disposition.
3. If that continuation also exits without creating a durable path, do not queue another equivalent continuation. Move the issue to `blocked` only when a real external dependency can be named; otherwise open or update an explicit recovery action with a named owner and concrete repair/escalation action.
4. New durable source activity may produce a new recovery fingerprint, but unchanged killed/local-watcher evidence must not create an infinite wake/recovery loop.
This rule is intentionally conservative: local watcher evidence can help the recovery owner decide what happened, but only persisted control-plane state can prove that the work will move again.
### Comment and document activity wake sources
Issue-thread comments and document-scoped comments have different wake semantics.
@@ -383,7 +406,7 @@ A healthy active-work state means at least one of these is true:
- there is an active one-shot monitor that will wake the assignee for a future check
- there is an open explicit recovery action for the lost execution path
An agent-owned `in_progress` issue is stalled when it has no active run, no queued continuation, and no explicit recovery surface. A still-running but silent process is not automatically stalled; it is handled by the active-run watchdog contract.
An agent-owned `in_progress` issue is stalled when it has no active run, no queued continuation, no persisted monitor, and no explicit recovery surface. An unmanaged local/background watcher does not satisfy any of those conditions. A Paperclip-tracked run that is still running but silent is not automatically stalled; it is handled by the active-run watchdog contract.
### `in_review`
@@ -478,6 +501,8 @@ Recovery rule:
This is an active-work continuity recovery.
The same bounded rule applies when the previous heartbeat reported waiting on a local/background watcher and that watcher was killed, disappeared, or was never represented by a durable Paperclip primitive. Paperclip queues at most one continuation for the same recovery fingerprint. If the continuation also leaves only local watcher evidence, Paperclip must surface a real blocker or explicit recovery action instead of repeating continuation recovery. A new monitor, scheduled wake, healthy delegated blocker issue, or other durable source mutation resolves that recovery fingerprint normally.
#### Deliberate wait is not a lost run
A continuation that the staleness gate cancelled with `issue_continuation_waiting_on_review` is a *deliberate park*, not a disappeared execution path. The latest run reported that the issue is waiting for review/approval (for example, an umbrella issue whose work was just decomposed into sub-tasks). Treating that park as a stranded run would retry it, then escalate it to `blocked` with a recovery action and an operator-facing failure notice — even though nothing failed and there is nothing for a human to do.
@@ -13,6 +13,7 @@ import {
parseGeminiVersionParts,
rewriteGeminiAcpFlagForVersion,
} from "./execute.js";
import { runChildProcess } from "../server-utils.js";
const execFileAsync = promisify(execFile);
@@ -50,6 +51,44 @@ async function createSkill(root: string, name: string, body = `---\nrequired: fa
};
}
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() {
return {
ensureSession: async () => ({
@@ -159,6 +198,61 @@ describe("shared ACPX engine runtime behavior", () => {
expect(prompt).not.toContain("$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID");
});
it("emits ACP text deltas as stdout transcript records", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const logs: Array<{ stream: string; text: string }> = [];
const execute = createAcpxEngineExecutor({
createRuntime: () => ({
ensureSession: async () => ({
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
}),
startTurn: () => ({
events: (async function* () {
yield {
type: "text_delta",
text: "streamed hello",
stream: "output",
tag: "agent_message_chunk",
};
yield { type: "done", stopReason: "end_turn" };
})(),
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
}),
close: async () => {},
}) as never,
});
const result = await execute({
runId: "run-streaming-text-delta",
agent: {
id: "agent-1",
companyId: "company-1",
},
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
context: {},
onLog: async (stream: "stdout" | "stderr", text: string) => {
logs.push({ stream, text });
},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(0);
expect(logs).toContainEqual({
stream: "stdout",
text: `${JSON.stringify({
type: "acpx.text_delta",
text: "streamed hello",
channel: "output",
tag: "agent_message_chunk",
})}\n`,
});
});
it.skipIf(process.platform === "win32")("materializes ACPX Claude skills without symlinked descendants", async () => {
const root = await makeTempRoot();
const skillRoot = path.join(root, "skills");
@@ -559,6 +653,57 @@ describe("shared ACPX engine runtime behavior", () => {
expect(wrapper).toContain("exec node ./fake-acp.js");
});
it("starts sandbox ACP process sessions in the remote execution cwd", async () => {
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 });
let sessionPayload: Record<string, unknown> | null = null;
const runner = createLocalSandboxRunner(
(input: { args?: string[]; env?: Record<string, string> }) => {
if (input.env?.PAPERCLIP_SANDBOX_EXEC_CHANNEL === "bridge") {
const script = input.args?.[1] ?? "";
const match = script.match(/PAPERCLIP_PROCESS_SESSION_COMMAND_B64='([^']+)'/);
if (match) {
sessionPayload = JSON.parse(Buffer.from(match[1]!, "base64").toString("utf8")) as Record<string, unknown>;
}
}
},
);
await runExecutor(
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
{
authToken: "real-run-jwt",
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd,
runner,
},
},
);
expect(sessionPayload).toMatchObject({
command: "sh",
args: ["-lc", "exec node ./fake-acp.js"],
cwd: remoteCwd,
});
const payloadEnv = ((sessionPayload as Record<string, unknown> | null)?.env ?? {}) as Record<string, unknown>;
expect(payloadEnv).toMatchObject({
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
});
expect(String(payloadEnv.PAPERCLIP_API_URL ?? "")).toMatch(
/^http:\/\/127\.0\.0\.1:\d+$/,
);
expect(payloadEnv.PAPERCLIP_API_KEY).toBeTruthy();
expect(payloadEnv.PAPERCLIP_API_KEY).not.toBe("real-run-jwt");
});
it.skipIf(process.platform === "win32")("drops benign ACP nes/close cleanup stderr but keeps it in the run log", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
@@ -12,6 +12,10 @@ import {
formatAdapterExecutionTimeoutStartLogLine,
readAdapterExecutionTarget,
resolveAdapterExecutionTargetTimeout,
startAdapterExecutionTargetPaperclipBridge,
startAdapterExecutionTargetProcessSessionBridge,
type AdapterExecutionTargetPaperclipBridgeHandle,
type AdapterExecutionTargetProcessSessionBridgeHandle,
type AdapterExecutionTargetTimeoutResolution,
} from "@paperclipai/adapter-utils/execution-target";
import {
@@ -112,6 +116,8 @@ interface AcpxPreparedRuntime {
fingerprint: string;
agentCommand: string | null;
agentRegistry: AcpAgentRegistry;
processSessionBridge: AdapterExecutionTargetProcessSessionBridgeHandle | null;
paperclipBridge: AdapterExecutionTargetPaperclipBridgeHandle | null;
remoteExecutionIdentity: Record<string, unknown> | null;
skillPromptInstructions: string;
skillsIdentity: Record<string, unknown>;
@@ -180,12 +186,20 @@ interface BuiltInAgentCommand {
shellCommand: string;
}
async function resolveBuiltInAgentCommand(agent: string, packageRootDir: string): Promise<BuiltInAgentCommand | null> {
async function resolveBuiltInAgentCommand(input: {
agent: string;
packageRootDir: string;
executionTargetIsRemote: boolean;
}): Promise<BuiltInAgentCommand | null> {
const { agent, packageRootDir, executionTargetIsRemote } = input;
if (agent === "gemini") {
return { command: "gemini --acp", shellCommand: "gemini --acp" };
}
const binName = agent === "claude" ? "claude-agent-acp" : agent === "codex" ? "codex-acp" : null;
if (!binName) return null;
if (executionTargetIsRemote) {
return { command: binName, shellCommand: binName };
}
const resolved = (await findAncestorBin(packageRootDir, binName)) ?? binName;
return { command: resolved, shellCommand: shellQuote(resolved) };
}
@@ -1062,7 +1076,11 @@ async function buildRuntime(input: {
}
const configuredCommand = asString(config.agentCommand, "").trim();
const builtInCommand = await resolveBuiltInAgentCommand(acpxAgent, input.engine.packageRootDir);
const builtInCommand = await resolveBuiltInAgentCommand({
agent: acpxAgent,
packageRootDir: input.engine.packageRootDir,
executionTargetIsRemote,
});
let agentCommand = configuredCommand || builtInCommand?.command || null;
let agentCommandShell = configuredCommand || builtInCommand?.shellCommand || "";
if (acpxAgent === "gemini" && agentCommandShell) {
@@ -1097,7 +1115,58 @@ async function buildRuntime(input: {
})
: null;
const wrapperPath = wrapper?.wrapperPath ?? null;
const overrides = wrapperPath ? { [acpxAgent]: wrapperPath } : undefined;
let paperclipBridge: AdapterExecutionTargetPaperclipBridgeHandle | null = null;
if (
executionTarget?.kind === "remote" &&
executionTarget.transport === "sandbox" &&
Boolean(executionTarget.runner) &&
agentCommandShell
) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: { ...executionTarget, streamRunLogs: false },
runtimeRootDir: null,
adapterKey: input.engine.adapterType,
timeoutSec,
hostApiToken: env.PAPERCLIP_API_KEY,
onLog: input.ctx.onLog,
});
if (paperclipBridge) {
Object.assign(env, paperclipBridge.env);
await input.ctx.onLog("stdout", "[paperclip] Sandbox ACP API callback bridge enabled for this run.\n");
}
}
const runtimeEnv = Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
let processSessionBridge: AdapterExecutionTargetProcessSessionBridgeHandle | null = null;
try {
processSessionBridge =
executionTarget?.kind === "remote" &&
executionTarget.transport === "sandbox" &&
Boolean(executionTarget.runner) &&
agentCommandShell
? await startAdapterExecutionTargetProcessSessionBridge({
runId,
target: executionTarget,
runtimeRootDir: null,
adapterKey: input.engine.adapterType,
command: "sh",
args: ["-lc", `exec ${agentCommandShell}`],
cwd: effectiveExecutionCwd,
env: runtimeEnv,
timeoutSec,
onLog: input.ctx.onLog,
})
: null;
} catch (err) {
await paperclipBridge?.stop().catch(() => {});
throw err;
}
const overrideCommand = processSessionBridge?.agentCommand ?? wrapperPath;
const overrides = overrideCommand ? { [acpxAgent]: overrideCommand } : undefined;
const agentRegistry = createAgentRegistry({ overrides });
const fingerprint = shortHash({
acpxAgent,
@@ -1123,7 +1192,6 @@ async function buildRuntime(input: {
});
const taskKey = asString(input.ctx.runtime.taskKey, "") || wakeTaskId || workspaceId || "default";
const sessionKey = `paperclip:${agent.companyId}:${agent.id}:${taskKey}:${fingerprint}`;
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
@@ -1151,6 +1219,8 @@ async function buildRuntime(input: {
fingerprint,
agentCommand,
agentRegistry,
processSessionBridge,
paperclipBridge,
remoteExecutionIdentity,
skillPromptInstructions,
skillsIdentity: {
@@ -1229,6 +1299,13 @@ async function applySessionConfigOptions(input: {
return notes;
}
async function cleanupRemoteBridges(prepared: AcpxPreparedRuntime): Promise<void> {
await Promise.allSettled([
prepared.processSessionBridge?.stop(),
prepared.paperclipBridge?.stop(),
]);
}
function renderPaperclipEnvNote(env: Record<string, string>): string {
const paperclipKeys = Object.keys(env)
.filter((key) => key.startsWith("PAPERCLIP_"))
@@ -1718,6 +1795,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
err,
phase: "ensure_session",
});
await cleanupRemoteBridges(prepared);
return {
exitCode: 1,
signal: null,
@@ -1733,6 +1811,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
}
if (!handle) {
await cleanupRemoteBridges(prepared);
return {
exitCode: 1,
signal: null,
@@ -1848,7 +1927,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
discardPersistentState: terminal.status === "cancelled" || timedOut,
}).catch(() => {});
}
} else if (prepared.mode === "persistent" && warmIdleMs > 0) {
} else if (prepared.mode === "persistent" && warmIdleMs > 0 && !prepared.processSessionBridge) {
const existing = warmHandles.get(prepared.sessionKey);
if (existing && !warmHandleMatches(existing, runtime, sessionHandle)) {
await runtime.close({
@@ -1900,6 +1979,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
stopReason: terminalStopReason,
message: errorMessage,
});
await cleanupRemoteBridges(prepared);
return {
exitCode: terminal.status === "completed" ? 0 : 1,
signal: timedOut ? "SIGTERM" : null,
@@ -1951,6 +2031,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
phase: "turn",
messageOverride,
});
await cleanupRemoteBridges(prepared);
return {
exitCode: 1,
signal: timedOut ? "SIGTERM" : null,
@@ -1,7 +1,10 @@
import { createServer } from "node:http";
import { mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import net from "node:net";
import { execFile, spawn } from "node:child_process";
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
@@ -16,6 +19,7 @@ import {
resolveAdapterExecutionTargetTimeoutSec,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetProcessSessionBridge,
startAdapterExecutionTargetPaperclipBridge,
type AdapterSandboxExecutionTarget,
} from "./execution-target.js";
@@ -23,6 +27,8 @@ import { createSandboxRunLogTailFactory } from "./sandbox-run-log-stream.js";
import { runChildProcess } from "./server-utils.js";
import { shellQuote } from "./ssh.js";
const execFileAsync = promisify(execFile);
describe("sandbox adapter execution targets", () => {
const cleanupDirs: string[] = [];
@@ -90,8 +96,8 @@ describe("sandbox adapter execution targets", () => {
].join("\n");
}
async function waitForCondition(predicate: () => boolean, message: string): Promise<void> {
const deadline = Date.now() + 1000;
async function waitForCondition(predicate: () => boolean, message: string, timeoutMs = 1000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 5));
@@ -99,6 +105,36 @@ describe("sandbox adapter execution targets", () => {
throw new Error(message);
}
async function runProxyWithInput(command: string, input: string): Promise<{ stdout: string; stderr: string; code: number | null }> {
const child = spawn(command, [], { stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.stdin.end(input);
const code = await new Promise<number | null>((resolve, reject) => {
const timeout = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("Timed out waiting for process session proxy."));
}, 5000);
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("exit", (exitCode) => {
clearTimeout(timeout);
resolve(exitCode);
});
});
return { stdout, stderr, code };
}
function combinedStream(
events: Array<{ stream: "stdout" | "stderr"; chunk: string }>,
stream: "stdout" | "stderr",
@@ -158,6 +194,301 @@ describe("sandbox adapter execution targets", () => {
});
});
it("bridges bidirectional sandbox process sessions through a local ACPX-spawnable proxy", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-"));
cleanupDirs.push(rootDir);
const childPath = path.join(rootDir, "fake-acp-child.mjs");
await writeFile(
childPath,
[
"process.stdin.on('data', (chunk) => {",
" process.stdout.write('out:' + chunk.toString());",
" process.stderr.write('err:' + chunk.toString());",
"});",
].join("\n"),
"utf8",
);
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "local-test",
remoteCwd: rootDir,
timeoutMs: 30_000,
runner: createLocalSandboxRunner(),
};
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-process-session",
target,
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
adapterKey: "acpx",
command: process.execPath,
args: [childPath],
cwd: rootDir,
env: {},
timeoutSec: 5,
onLog: async () => {},
});
expect(bridge).not.toBeNull();
try {
const result = await runProxyWithInput(bridge!.agentCommand, "hello\n");
expect(result.code).toBe(0);
expect(result.stdout).toBe("out:hello\n");
expect(result.stderr).toBe("err:hello\n");
} finally {
await bridge?.stop();
}
});
it("buffers sandbox process session output until the local proxy connects", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-buffer-"));
cleanupDirs.push(rootDir);
const childPath = path.join(rootDir, "fast-acp-child.mjs");
await writeFile(
childPath,
[
"process.stdout.write('early-out\\n');",
"process.stderr.write('early-err\\n');",
"setTimeout(() => process.exit(0), 20);",
].join("\n"),
"utf8",
);
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "local-test",
remoteCwd: rootDir,
timeoutMs: 30_000,
runner: createLocalSandboxRunner(),
};
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-process-session-buffer",
target,
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
adapterKey: "acpx",
command: process.execPath,
args: [childPath],
cwd: rootDir,
env: {},
timeoutSec: 5,
onLog: async () => {},
});
expect(bridge).not.toBeNull();
try {
await new Promise((resolve) => setTimeout(resolve, 300));
const result = await runProxyWithInput(bridge!.agentCommand, "");
expect(result.code).toBe(0);
expect(result.stdout).toBe("early-out\n");
expect(result.stderr).toBe("early-err\n");
} finally {
await bridge?.stop();
}
});
it("delivers full output when the sandbox child exits immediately after writing", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-fast-exit-"));
cleanupDirs.push(rootDir);
const childPath = path.join(rootDir, "instant-exit-acp-child.mjs");
await writeFile(
childPath,
[
"process.stdout.write('final-out\\n');",
"process.stderr.write('final-err\\n');",
].join("\n"),
"utf8",
);
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "local-test",
remoteCwd: rootDir,
timeoutMs: 30_000,
runner: createLocalSandboxRunner(),
};
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-process-session-fast-exit",
target,
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
adapterKey: "acpx",
command: process.execPath,
args: [childPath],
cwd: rootDir,
env: {},
timeoutSec: 5,
onLog: async () => {},
});
expect(bridge).not.toBeNull();
try {
const result = await runProxyWithInput(bridge!.agentCommand, "");
expect(result.code).toBe(0);
expect(result.stdout).toBe("final-out\n");
expect(result.stderr).toBe("final-err\n");
} finally {
await bridge?.stop();
}
});
it("ignores unauthenticated connections to the process session bridge", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-auth-"));
cleanupDirs.push(rootDir);
const childPath = path.join(rootDir, "guarded-acp-child.mjs");
await writeFile(childPath, "process.stdout.write('guarded-out\\n');", "utf8");
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "local-test",
remoteCwd: rootDir,
timeoutMs: 30_000,
runner: createLocalSandboxRunner(),
};
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-process-session-auth",
target,
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
adapterKey: "acpx",
command: process.execPath,
args: [childPath],
cwd: rootDir,
env: {},
timeoutSec: 5,
onLog: async () => {},
});
expect(bridge).not.toBeNull();
let squatter: net.Socket | null = null;
try {
const proxySource = await readFile(bridge!.agentCommand, "utf8");
const port = Number(/port: (\d+)/.exec(proxySource)?.[1] ?? Number.NaN);
expect(Number.isFinite(port)).toBe(true);
// An idle local connection must not claim the session or see buffered output.
const squatterSocket = net.createConnection({ host: "127.0.0.1", port });
squatter = squatterSocket;
let squatterReceived = "";
squatterSocket.setEncoding("utf8");
squatterSocket.on("data", (chunk: string) => {
squatterReceived += chunk;
});
squatterSocket.on("error", () => undefined);
await new Promise<void>((resolve, reject) => {
squatterSocket.once("connect", () => resolve());
squatterSocket.once("error", reject);
});
// A peer presenting the wrong token is disconnected outright.
const badPeer = net.createConnection({ host: "127.0.0.1", port });
badPeer.on("error", () => undefined);
const badPeerClosed = new Promise<void>((resolve) => badPeer.once("close", () => resolve()));
badPeer.once("connect", () => badPeer.write(`${JSON.stringify({ token: "wrong-token", type: "stdinEnd" })}\n`));
await badPeerClosed;
// The authenticated proxy still attaches and receives the buffered output.
const result = await runProxyWithInput(bridge!.agentCommand, "");
expect(result.code).toBe(0);
expect(result.stdout).toBe("guarded-out\n");
expect(squatterReceived).toBe("");
} finally {
squatter?.destroy();
await bridge?.stop();
}
});
it("streams sandbox process session output before the remote child exits", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-"));
cleanupDirs.push(rootDir);
const childPath = path.join(rootDir, "streaming-acp-child.mjs");
await writeFile(
childPath,
[
"process.stdin.setEncoding('utf8');",
"process.stdin.on('data', (chunk) => {",
" if (chunk.includes('ping')) {",
" process.stdout.write('delta:ping\\n');",
" process.stderr.write('trace:ping\\n');",
" }",
" if (chunk.includes('finish')) process.exit(0);",
"});",
"process.stdin.resume();",
].join("\n"),
"utf8",
);
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "local-test",
remoteCwd: rootDir,
timeoutMs: 30_000,
runner: createLocalSandboxRunner(),
};
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-process-session-stream",
target,
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
adapterKey: "acpx",
command: process.execPath,
args: [childPath],
cwd: rootDir,
env: {},
timeoutSec: 5,
onLog: async () => {},
});
expect(bridge).not.toBeNull();
const child = spawn(bridge!.agentCommand, [], { stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
let exited = false;
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
const exitPromise = new Promise<number | null>((resolve, reject) => {
const timeout = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("Timed out waiting for streaming process session proxy."));
}, 5000);
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("exit", (exitCode) => {
exited = true;
clearTimeout(timeout);
resolve(exitCode);
});
});
try {
child.stdin.write("ping\n");
await waitForCondition(
() => stdout.includes("delta:ping\n") && stderr.includes("trace:ping\n"),
"Timed out waiting for live process session output.",
3000,
);
expect(exited).toBe(false);
child.stdin.end("finish\n");
await expect(exitPromise).resolves.toBe(0);
} finally {
if (!exited) {
child.kill("SIGKILL");
await exitPromise.catch(() => undefined);
}
await bridge?.stop();
}
});
it("applies the remote sandbox fallback when adapter timeoutSec is unset", () => {
const sandboxTarget: AdapterSandboxExecutionTarget = {
kind: "remote",
@@ -1,4 +1,8 @@
import fs from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import type { SshRemoteExecutionSpec } from "./ssh.js";
import {
prepareCommandManagedRuntime,
@@ -125,6 +129,11 @@ export interface AdapterExecutionTargetPaperclipBridgeHandle {
stop(): Promise<void>;
}
export interface AdapterExecutionTargetProcessSessionBridgeHandle {
agentCommand: string;
stop(): Promise<void>;
}
export { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js";
// 4-hour wall-clock backstop for sandbox-backed adapter runs. This is a
@@ -1200,6 +1209,412 @@ async function readBridgeForwardResponseBody(response: Response, maxBodyBytes: n
return Buffer.concat(chunks, totalBytes).toString("utf8");
}
const PROCESS_SESSION_PROXY_SCRIPT = "paperclip-process-session-proxy.mjs";
const PROCESS_SESSION_REMOTE_SCRIPT = "paperclip-process-session-remote.mjs";
const PROCESS_SESSION_AUTH_TIMEOUT_MS = 5_000;
function jsonLine(value: unknown): string {
return `${JSON.stringify(value)}\n`;
}
function splitJsonLines(buffer: string): { lines: string[]; rest: string } {
const parts = buffer.split(/\n/);
return { lines: parts.slice(0, -1), rest: parts.at(-1) ?? "" };
}
async function writeProcessSessionProxyScript(dir: string, port: number, token: string): Promise<string> {
await fs.mkdir(dir, { recursive: true });
const proxyPath = path.join(dir, PROCESS_SESSION_PROXY_SCRIPT);
await fs.writeFile(proxyPath, getProcessSessionProxySource({ port, token }), { mode: 0o700 });
return proxyPath;
}
async function syncProcessSessionRemoteScript(input: {
client: ReturnType<typeof createCommandManagedSandboxCallbackBridgeQueueClient>;
remoteScriptPath: string;
}): Promise<void> {
await input.client.writeTextFile(input.remoteScriptPath, getProcessSessionRemoteSource());
}
async function readRemoteJsonFiles(input: {
client: ReturnType<typeof createCommandManagedSandboxCallbackBridgeQueueClient>;
dir: string;
}): Promise<Array<{ name: string; body: string }>> {
const names = await input.client.listJsonFiles(input.dir);
const out: Array<{ name: string; body: string }> = [];
for (const name of names) {
const filePath = path.posix.join(input.dir, name);
const body = await input.client.readTextFile(filePath);
await input.client.remove(filePath).catch(() => undefined);
out.push({ name, body });
}
return out;
}
async function waitForLocalServerListen(server: net.Server): Promise<number> {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Process session bridge did not expose a TCP port.");
}
return address.port;
}
export async function startAdapterExecutionTargetProcessSessionBridge(input: {
runId: string;
target: AdapterExecutionTarget | null | undefined;
runtimeRootDir: string | null | undefined;
adapterKey: string;
command: string;
args: string[];
cwd: string;
env: Record<string, string>;
timeoutSec?: number | null;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}): Promise<AdapterExecutionTargetProcessSessionBridgeHandle | null> {
if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") {
return null;
}
const target = input.target;
const onLog = input.onLog ?? (async () => {});
const runner = requireSandboxRunner(target);
const shellCommand = preferredSandboxShell(target);
const timeoutMs =
typeof input.timeoutSec === "number" && Number.isFinite(input.timeoutSec) && input.timeoutSec > 0
? Math.trunc(input.timeoutSec * 1000)
: target.timeoutMs ?? undefined;
const bridgeRuntimeDir = path.posix.join(
input.runtimeRootDir?.trim() || path.posix.join(target.remoteCwd, ".paperclip-runtime", input.adapterKey),
"process-sessions",
);
const sessionId = randomUUID();
const sessionDir = path.posix.join(bridgeRuntimeDir, sessionId);
const stdinDir = path.posix.join(sessionDir, "stdin");
const eventsDir = path.posix.join(sessionDir, "events");
const remoteScriptPath = path.posix.join(bridgeRuntimeDir, PROCESS_SESSION_REMOTE_SCRIPT);
const client = createCommandManagedSandboxCallbackBridgeQueueClient({
runner,
remoteCwd: target.remoteCwd,
timeoutMs,
shellCommand,
});
await client.makeDir(stdinDir);
await client.makeDir(eventsDir);
await syncProcessSessionRemoteScript({ client, remoteScriptPath });
const commandPayload = Buffer.from(JSON.stringify({
command: input.command,
args: input.args,
cwd: input.cwd || target.remoteCwd,
env: sanitizeRemoteExecutionEnv(input.env),
}), "utf8").toString("base64");
await onLog("stdout", `[paperclip] Starting ACP process session bridge in sandbox (${target.providerKey ?? "provider"}).\n`);
const startResult = await runner.execute({
command: shellCommand,
args: shellCommandArgs(
[
`mkdir -p ${shellQuote(stdinDir)} ${shellQuote(eventsDir)}`,
`PAPERCLIP_PROCESS_SESSION_DIR=${shellQuote(sessionDir)} ` +
`PAPERCLIP_PROCESS_SESSION_COMMAND_B64=${shellQuote(commandPayload)} ` +
`nohup node ${shellQuote(remoteScriptPath)} >/dev/null 2>&1 < /dev/null &`,
"printf '%s\\n' \"$!\"",
].join("\n"),
),
cwd: target.remoteCwd,
env: {
PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge",
},
timeoutMs,
});
if (startResult.timedOut || (startResult.exitCode ?? 1) !== 0) {
throw new Error(`Failed to start sandbox ACP process session bridge: ${startResult.stderr || startResult.stdout}`);
}
let socket: net.Socket | null = null;
let stopping = false;
let stdinSeq = 0;
let pollTimer: NodeJS.Timeout | null = null;
const pendingRemoteEvents: Array<{
type?: string;
stream?: "stdout" | "stderr";
data?: string;
code?: number | null;
signal?: string | null;
message?: string;
}> = [];
const token = createSandboxCallbackBridgeToken(18);
const proxyDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-proxy-"));
const writeRemoteEventToSocket = (event: (typeof pendingRemoteEvents)[number]) => {
if (!socket) return false;
socket.write(jsonLine(event));
if (event.type === "exit") {
stopping = true;
socket.end();
} else if (event.type === "error") {
stopping = true;
socket.destroy();
}
return true;
};
const deliverRemoteEvent = (event: (typeof pendingRemoteEvents)[number]) => {
if (socket) {
writeRemoteEventToSocket(event);
return;
}
pendingRemoteEvents.push(event);
if (event.type === "exit" || event.type === "error") {
stopping = true;
}
};
const flushPendingRemoteEvents = () => {
if (!socket) return;
while (pendingRemoteEvents.length > 0 && socket) {
const event = pendingRemoteEvents.shift();
if (event) writeRemoteEventToSocket(event);
}
};
const liveSockets = new Set<net.Socket>();
const server = net.createServer((nextSocket) => {
liveSockets.add(nextSocket);
nextSocket.setEncoding("utf8");
nextSocket.on("error", () => undefined);
let connectionBuffer = "";
let authenticated = false;
// Connections own the session (and receive buffered process output) only
// after presenting the bridge token; idle unauthenticated peers are dropped.
const authTimer = setTimeout(() => {
if (!authenticated) nextSocket.destroy();
}, PROCESS_SESSION_AUTH_TIMEOUT_MS);
authTimer.unref?.();
nextSocket.on("close", () => {
clearTimeout(authTimer);
liveSockets.delete(nextSocket);
});
nextSocket.on("data", (chunk) => {
connectionBuffer += chunk;
const split = splitJsonLines(connectionBuffer);
connectionBuffer = split.rest;
for (const line of split.lines) {
if (!line.trim()) continue;
let message: { token?: string; type?: string; data?: string };
try {
message = JSON.parse(line) as { token?: string; type?: string; data?: string };
} catch {
nextSocket.destroy();
return;
}
if (message.token !== token) {
nextSocket.destroy();
return;
}
if (!authenticated) {
if (socket) {
nextSocket.destroy();
return;
}
authenticated = true;
clearTimeout(authTimer);
socket = nextSocket;
flushPendingRemoteEvents();
}
void (async () => {
if (message.type === "stdin" && typeof message.data === "string") {
stdinSeq += 1;
const name = `${String(stdinSeq).padStart(12, "0")}.json`;
await client.writeTextFile(path.posix.join(stdinDir, name), jsonLine({ type: "stdin", data: message.data }));
} else if (message.type === "stdinEnd") {
stdinSeq += 1;
const name = `${String(stdinSeq).padStart(12, "0")}.json`;
await client.writeTextFile(path.posix.join(stdinDir, name), jsonLine({ type: "stdinEnd" }));
}
})().catch((error) => {
nextSocket.write(jsonLine({ type: "error", message: error instanceof Error ? error.message : String(error) }));
nextSocket.destroy();
});
}
});
});
const poll = async () => {
if (stopping) return;
try {
const events = await readRemoteJsonFiles({ client, dir: eventsDir });
for (const event of events) {
const parsed = JSON.parse(event.body) as {
type?: string;
stream?: "stdout" | "stderr";
data?: string;
code?: number | null;
signal?: string | null;
message?: string;
};
deliverRemoteEvent(parsed);
if (parsed.type === "exit" || parsed.type === "error") return;
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await onLog("stderr", `[paperclip] ACP process session bridge poll failed: ${message}\n`);
deliverRemoteEvent({ type: "error", message });
return;
} finally {
if (!stopping) {
pollTimer = setTimeout(() => void poll(), 100);
pollTimer.unref?.();
}
}
};
const port = await waitForLocalServerListen(server);
const agentCommand = await writeProcessSessionProxyScript(proxyDir, port, token);
pollTimer = setTimeout(() => void poll(), 100);
pollTimer.unref?.();
return {
agentCommand,
stop: async () => {
stopping = true;
if (pollTimer) clearTimeout(pollTimer);
for (const liveSocket of liveSockets) liveSocket.destroy();
await new Promise<void>((resolve) => server.close(() => resolve())).catch(() => undefined);
await client.writeTextFile(
path.posix.join(stdinDir, `${String(stdinSeq + 1).padStart(12, "0")}.json`),
jsonLine({ type: "stdinEnd" }),
).catch(() => undefined);
await client.remove(sessionDir).catch(() => undefined);
await fs.rm(proxyDir, { recursive: true, force: true }).catch(() => undefined);
},
};
}
function getProcessSessionProxySource(input: { port: number; token: string }): string {
return `#!/usr/bin/env node
import net from "node:net";
const socket = net.createConnection({ host: "127.0.0.1", port: ${input.port} });
const token = ${JSON.stringify(input.token)};
let buffer = "";
let exiting = false;
function send(message) {
socket.write(JSON.stringify({ token, ...message }) + "\\n");
}
socket.on("connect", () => send({ type: "hello" }));
process.stdin.on("data", (chunk) => send({ type: "stdin", data: Buffer.from(chunk).toString("base64") }));
process.stdin.on("end", () => send({ type: "stdinEnd" }));
process.stdin.resume();
socket.setEncoding("utf8");
socket.on("data", (chunk) => {
buffer += chunk;
const parts = buffer.split(/\\n/);
buffer = parts.pop() || "";
for (const line of parts) {
if (!line.trim()) continue;
const message = JSON.parse(line);
if (message.type === "data") {
const out = Buffer.from(message.data || "", "base64");
(message.stream === "stderr" ? process.stderr : process.stdout).write(out);
} else if (message.type === "error") {
process.stderr.write(String(message.message || "Process session bridge failed.") + "\\n");
exiting = true;
process.exitCode = 1;
socket.end();
} else if (message.type === "exit") {
exiting = true;
process.exitCode = typeof message.code === "number" ? message.code : 1;
socket.end();
}
}
});
socket.on("close", () => {
if (!exiting) process.exit(1);
});
`;
}
function getProcessSessionRemoteSource(): string {
return `import { spawn } from "node:child_process";
import { promises as fs } from "node:fs";
import path from "node:path";
const sessionDir = process.env.PAPERCLIP_PROCESS_SESSION_DIR;
const commandPayload = process.env.PAPERCLIP_PROCESS_SESSION_COMMAND_B64;
if (!sessionDir || !commandPayload) throw new Error("Missing process session bridge env.");
const stdinDir = path.posix.join(sessionDir, "stdin");
const eventsDir = path.posix.join(sessionDir, "events");
let seq = 0;
let stdinClosed = false;
const config = JSON.parse(Buffer.from(commandPayload, "base64").toString("utf8"));
await fs.mkdir(stdinDir, { recursive: true });
await fs.mkdir(eventsDir, { recursive: true });
let writeChain = Promise.resolve();
function writeEvent(event) {
seq += 1;
const file = path.posix.join(eventsDir, String(seq).padStart(12, "0") + ".json");
const write = writeChain.then(async () => {
await fs.writeFile(file + ".tmp", JSON.stringify(event) + "\\n", "utf8");
await fs.rename(file + ".tmp", file);
});
writeChain = write.catch(() => undefined);
return write;
}
const child = spawn(config.command, Array.isArray(config.args) ? config.args : [], {
cwd: config.cwd || process.cwd(),
env: { ...process.env, ...(config.env || {}) },
stdio: ["pipe", "pipe", "pipe"],
});
child.stdout.on("data", (chunk) => void writeEvent({ type: "data", stream: "stdout", data: Buffer.from(chunk).toString("base64") }));
child.stderr.on("data", (chunk) => void writeEvent({ type: "data", stream: "stderr", data: Buffer.from(chunk).toString("base64") }));
child.on("error", (error) => void writeEvent({ type: "error", message: error.message }));
// "close" (not "exit") so stdout/stderr fully drain before the exit event;
// the write chain then guarantees the exit file lands after every data file.
child.on("close", (code, signal) => void writeEvent({ type: "exit", code, signal }));
async function pollStdin() {
while (!stdinClosed) {
const entries = (await fs.readdir(stdinDir).catch(() => [])).filter((name) => name.endsWith(".json")).sort();
for (const name of entries) {
const file = path.posix.join(stdinDir, name);
const raw = await fs.readFile(file, "utf8").catch(() => null);
await fs.rm(file, { force: true }).catch(() => undefined);
if (!raw) continue;
const message = JSON.parse(raw);
if (message.type === "stdin" && typeof message.data === "string") {
child.stdin.write(Buffer.from(message.data, "base64"));
} else if (message.type === "stdinEnd") {
stdinClosed = true;
child.stdin.end();
break;
}
}
if (!stdinClosed) await new Promise((resolve) => setTimeout(resolve, 50));
}
}
void pollStdin().catch((error) => void writeEvent({ type: "error", message: error instanceof Error ? error.message : String(error) }));
`;
}
export async function startAdapterExecutionTargetPaperclipBridge(input: {
runId: string;
target: AdapterExecutionTarget | null | undefined;
@@ -896,6 +896,9 @@ describe("sandbox callback bridge", () => {
{ method: "POST", path: "/api/issues/issue-1/release" },
{ method: "PATCH", path: "/api/issues/issue-1" },
{ method: "GET", path: "/api/issues/issue-1/approvals" },
{ method: "GET", path: "/api/issues/issue-1/work-products" },
{ method: "POST", path: "/api/issues/issue-1/work-products" },
{ method: "PATCH", path: "/api/work-products/wp-1" },
{ method: "GET", path: "/api/issues/issue-1/interactions" },
{ method: "GET", path: "/api/issues/issue-1/interactions/inter-1" },
{ method: "POST", path: "/api/issues/issue-1/interactions" },
@@ -940,6 +943,7 @@ describe("sandbox callback bridge", () => {
{ method: "POST", path: "/api/companies/co-1/archive" },
{ method: "DELETE", path: "/api/issues/issue-1/documents/plan" },
{ method: "DELETE", path: "/api/issues/issue-1/approvals/ap-1" },
{ method: "DELETE", path: "/api/work-products/wp-1" },
{ method: "POST", path: "/api/approvals/ap-1/approve" },
{ method: "POST", path: "/api/approvals/ap-1/reject" },
{ method: "POST", path: "/api/companies/co-1/logo" },
@@ -67,6 +67,11 @@ export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCa
{ method: "PATCH", path: /^\/api\/issues\/[^/]+$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/approvals$/ },
// Work products: publish branch/commit/artifact metadata for completed work.
{ method: "GET", path: /^\/api\/issues\/[^/]+\/work-products$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/work-products$/ },
{ method: "PATCH", path: /^\/api\/work-products\/[^/]+$/ },
// Issue-thread interactions (suggest tasks, ask questions, request confirmation)
{ method: "GET", path: /^\/api\/issues\/[^/]+\/interactions(?:\/[^/]+)?$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/interactions$/ },
@@ -1,3 +1,4 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
@@ -16,9 +17,12 @@ import {
runningProcesses,
runChildProcess,
sanitizeSshRemoteEnv,
signalRunningProcess,
shapePaperclipWorkspaceEnvForExecution,
rewriteWorkspaceCwdEnvVarsForExecution,
stringifyPaperclipWakePayload,
UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON,
UNMANAGED_BACKGROUND_TASK_STOP_REASON,
WATCHDOG_DEFAULT_MANDATE,
} from "./server-utils.js";
@@ -470,6 +474,97 @@ describe("runChildProcess", () => {
expect(await waitForPidExit(descendantPid!, 2_000)).toBe(true);
});
it.skipIf(process.platform === "win32")(
"force-kills a child that ignores SIGTERM once the grace window elapses",
async () => {
// Residual hang case: a child that installs a SIGTERM handler which
// swallows the signal and keeps running. The timeout sends SIGTERM at
// timeoutSec, then must escalate to SIGKILL graceSec later. If the
// escalation were gated on `child.killed` (which is true the instant
// SIGTERM is *sent*, not when the process exits) the SIGKILL would be
// suppressed and this child would outlive its deadline.
const result = await runChildProcess(
randomUUID(),
process.execPath,
[
"-e",
[
"process.on('SIGTERM', () => {});",
"process.stdout.write(String(process.pid));",
"setInterval(() => {}, 1000);",
].join(" "),
],
{
cwd: process.cwd(),
env: {},
timeoutSec: 1,
graceSec: 1,
onLog: async () => {},
onSpawn: async () => {},
},
);
const childPid = Number.parseInt(result.stdout.trim(), 10);
expect(result.timedOut).toBe(true);
expect(result.signal).toBe("SIGKILL");
expect(Number.isInteger(childPid) && childPid > 0).toBe(true);
expect(await waitForPidExit(childPid, 2_000)).toBe(true);
},
);
it.skipIf(process.platform === "win32")(
"signalRunningProcess escalates SIGKILL on the direct-child fallback after SIGTERM is sent",
async () => {
// Directly cover the branch this PR changed: the direct-child fallback
// (processGroupId === null), which runChildProcess's POSIX timeout tests
// never reach because they always spawn detached and take the
// process-group path. This reproduces the exact regression: once SIGTERM
// has been *sent*, `child.killed` is already true, so the old
// `!child.killed` guard would suppress the SIGKILL escalation and leave a
// SIGTERM-ignoring child alive. The liveness guard
// (exitCode === null && signalCode === null) must still let SIGKILL through.
const child = spawn(
process.execPath,
[
"-e",
[
"process.on('SIGTERM', () => {});",
"process.stdout.write(String(process.pid));",
"setInterval(() => {}, 1000);",
].join(" "),
],
{ detached: false, stdio: ["ignore", "pipe", "ignore"] },
);
try {
const pid = await new Promise<number>((resolvePid, rejectPid) => {
child.stdout!.on("data", (d) => resolvePid(Number.parseInt(String(d).trim(), 10)));
child.on("error", rejectPid);
});
expect(Number.isInteger(pid) && pid > 0).toBe(true);
// First SIGTERM via the fallback (no process group). The child swallows
// it and stays alive — but child.killed is now true.
signalRunningProcess({ child, processGroupId: null }, "SIGTERM");
await new Promise((r) => setTimeout(r, 300));
expect(child.killed).toBe(true); // signal was sent…
expect(isPidAlive(pid)).toBe(true); // …but the process ignored it and lives
// Escalation: with the old `!child.killed` guard this would be a no-op
// and the child would survive. The liveness guard must still fire.
signalRunningProcess({ child, processGroupId: null }, "SIGKILL");
expect(await waitForPidExit(pid, 2_000)).toBe(true);
} finally {
if (child.exitCode === null && child.signalCode === null) {
try {
child.kill("SIGKILL");
} catch {
/* already gone */
}
}
}
},
);
it.skipIf(process.platform === "win32")("cleans up a lingering process group after terminal output and child exit", async () => {
const result = await runChildProcess(
randomUUID(),
@@ -500,6 +595,13 @@ describe("runChildProcess", () => {
const descendantPid = Number.parseInt(result.stdout.match(/descendant:(\d+)/)?.[1] ?? "", 10);
expect(result.timedOut).toBe(false);
expect(result.exitCode).toBe(0);
expect(result.terminalResultCleanup).toMatchObject({
kind: "terminal_result_cleanup",
stopped: true,
stopReason: UNMANAGED_BACKGROUND_TASK_STOP_REASON,
reason: UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON,
terminalResultSeen: true,
});
expect(Number.isInteger(descendantPid) && descendantPid > 0).toBe(true);
expect(await waitForPidExit(descendantPid, 2_000)).toBe(true);
});
@@ -530,6 +632,14 @@ describe("runChildProcess", () => {
expect(result.timedOut).toBe(false);
expect(result.signal).toBe("SIGTERM");
expect(result.terminalResultCleanup).toMatchObject({
kind: "terminal_result_cleanup",
stopped: true,
stopReason: UNMANAGED_BACKGROUND_TASK_STOP_REASON,
reason: UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON,
terminalResultSeen: true,
signal: "SIGTERM",
});
expect(result.stdout).toContain('"type":"result"');
});
+37 -2
View File
@@ -19,6 +19,7 @@ export interface RunProcessResult {
stderr: string;
pid: number | null;
startedAt: string | null;
terminalResultCleanup?: TerminalResultCleanupEvidence | null;
}
export interface TerminalResultCleanupOptions {
@@ -26,6 +27,20 @@ export interface TerminalResultCleanupOptions {
graceMs?: number;
}
export const UNMANAGED_BACKGROUND_TASK_STOP_REASON = "unmanaged_background_task_stopped";
export const UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON =
"unmanaged background task stopped; no durable live path";
export interface TerminalResultCleanupEvidence {
kind: "terminal_result_cleanup";
stopped: true;
stopReason: typeof UNMANAGED_BACKGROUND_TASK_STOP_REASON;
reason: typeof UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON;
terminalResultSeen: boolean;
signal: NodeJS.Signals | null;
forceKilled: boolean;
}
interface RunningProcess {
child: ChildProcess;
graceSec: number;
@@ -58,7 +73,8 @@ function resolveProcessGroupId(child: ChildProcess) {
return typeof child.pid === "number" && child.pid > 0 ? child.pid : null;
}
function signalRunningProcess(
// Exported so the direct-child fallback branch can be unit-tested directly.
export function signalRunningProcess(
running: Pick<RunningProcess, "child" | "processGroupId">,
signal: NodeJS.Signals,
) {
@@ -70,7 +86,10 @@ function signalRunningProcess(
// Fall back to the direct child signal if group signaling fails.
}
}
if (!running.child.killed) {
// Gate on real liveness: `child.killed` only means a signal was sent, not that
// the process exited, so escalating on it would suppress a follow-up SIGKILL.
// `exitCode`/`signalCode` are null until the child actually closes.
if (running.child.exitCode === null && running.child.signalCode === null) {
running.child.kill(signal);
}
}
@@ -2917,6 +2936,8 @@ export async function runChildProcess(
let logChain: Promise<void> = Promise.resolve();
let terminalResultSeen = false;
let terminalCleanupStarted = false;
let terminalCleanupSignal: NodeJS.Signals | null = null;
let terminalCleanupForceKilled = false;
let terminalCleanupTimer: NodeJS.Timeout | null = null;
let terminalCleanupKillTimer: NodeJS.Timeout | null = null;
let terminalResultStdoutScanOffset = 0;
@@ -2956,9 +2977,12 @@ export async function runChildProcess(
terminalCleanupTimer = null;
if (terminalCleanupStarted || timedOut) return;
terminalCleanupStarted = true;
terminalCleanupSignal = "SIGTERM";
signalRunningProcess({ child, processGroupId }, "SIGTERM");
terminalCleanupKillTimer = setTimeout(() => {
terminalCleanupKillTimer = null;
terminalCleanupSignal = "SIGKILL";
terminalCleanupForceKilled = true;
signalRunningProcess({ child, processGroupId }, "SIGKILL");
}, Math.max(1, opts.graceSec) * 1000);
}, graceMs);
@@ -3051,6 +3075,17 @@ export async function runChildProcess(
stderr,
pid: child.pid ?? null,
startedAt,
terminalResultCleanup: terminalCleanupStarted
? {
kind: "terminal_result_cleanup",
stopped: true,
stopReason: UNMANAGED_BACKGROUND_TASK_STOP_REASON,
reason: UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON,
terminalResultSeen,
signal: terminalCleanupSignal,
forceKilled: terminalCleanupForceKilled,
}
: null,
});
});
});
@@ -258,6 +258,79 @@ describe("claude_local ACP lane", () => {
).resolves.toEqual({ engine: "acp", explicit: true });
});
it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => {
setNodeVersion("v22.12.0");
await expect(
resolveClaudeExecutionEngineForRun({
config: { agentCommand: "claude-agent-acp" },
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd: "/work",
runner: {
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
}),
},
},
}),
).resolves.toEqual({ engine: "acp", explicit: false });
});
it("falls back to the CLI lane for one-shot sandbox auto runs", async () => {
setNodeVersion("v22.12.0");
await expect(
resolveClaudeExecutionEngineForRun({
config: {},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd: "/work",
},
}),
).resolves.toMatchObject({
engine: "cli",
explicit: false,
fallbackReason: expect.stringContaining("bidirectional remote process"),
});
});
it("falls back to the CLI lane for non-sandbox remote auto runs", async () => {
setNodeVersion("v22.12.0");
await expect(
resolveClaudeExecutionEngineForRun({
config: {},
executionTarget: {
kind: "remote",
transport: "ssh",
remoteCwd: "/work",
spec: {
host: "127.0.0.1",
port: 22,
username: "fixture",
remoteCwd: "/work",
remoteWorkspacePath: "/work",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
},
}),
).resolves.toMatchObject({
engine: "cli",
explicit: false,
fallbackReason: expect.stringContaining("sandbox remote targets only"),
});
});
it("reports ACP prerequisites for the ACP lane", async () => {
const root = await makeTempRoot("paperclip-claude-acp-env-");
const commandPath = path.join(root, "bin", "claude-agent-acp");
@@ -8,7 +8,11 @@ import type {
AdapterExecutionContext,
AdapterExecutionResult,
} from "@paperclipai/adapter-utils";
import { readAdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import {
ensureAdapterExecutionTargetCommandResolvable,
readAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
} from "@paperclipai/adapter-utils/execution-target";
import {
DEFAULT_ACP_ENGINE_MODE,
DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS,
@@ -179,10 +183,30 @@ async function findAncestorBin(startDir: string, binName: string): Promise<strin
}
}
async function commandIsResolvable(command: string): Promise<boolean> {
async function commandIsResolvable(
command: string,
input?: ClaudeEngineResolutionInput,
): Promise<boolean> {
const trimmed = command.trim();
if (!trimmed) return false;
if (looksLikeShellCommand(trimmed)) return true;
const target = readAdapterExecutionTarget({
executionTarget: input?.executionTarget,
legacyRemoteExecution: input?.executionTransport?.remoteExecution,
});
if (target?.kind === "remote") {
try {
await ensureAdapterExecutionTargetCommandResolvable(
trimmed,
target,
resolveAdapterExecutionTargetCwd(target, asString(input?.config.cwd, ""), process.cwd()),
process.env,
);
return true;
} catch {
return false;
}
}
if (path.isAbsolute(trimmed) || hasPathSeparator(trimmed)) return pathExists(trimmed);
return (await findCommandOnPath(trimmed)) !== null;
}
@@ -197,6 +221,22 @@ async function resolveClaudeAcpCommand(config: Record<string, unknown>): Promise
);
}
function sandboxTargetHasProcessSessionBridge(
target: ReturnType<typeof readAdapterExecutionTarget>,
): boolean {
return target?.kind === "remote" && target.transport === "sandbox" && Boolean(target.runner);
}
async function resolveClaudeAcpCommandForTarget(
config: Record<string, unknown>,
target: ReturnType<typeof readAdapterExecutionTarget>,
): Promise<string> {
const configured = firstNonEmptyString(config.agentCommand, config.acpAgentCommand);
if (configured) return configured;
if (target?.kind === "remote") return "claude-agent-acp";
return resolveClaudeAcpCommand(config);
}
async function defaultClaudeAcpFallbackReason(
input: ClaudeEngineResolutionInput,
): Promise<string | null> {
@@ -204,14 +244,17 @@ async function defaultClaudeAcpFallbackReason(
executionTarget: input.executionTarget,
legacyRemoteExecution: input.executionTransport?.remoteExecution,
});
if (target?.kind === "remote") {
return "Claude ACP currently supports only the local Paperclip host, but this run targets a remote environment.";
if (target?.kind === "remote" && !sandboxTargetHasProcessSessionBridge(target)) {
if (target.transport === "sandbox") {
return "Claude ACP requires a bidirectional remote process target; this sandbox exposes only one-shot command execution.";
}
return "Claude ACP supports sandbox remote targets only; this run targets a non-sandbox remote environment.";
}
if (!nodeVersionMeetsClaudeAcpMinimum()) {
return `Node ${process.version} does not satisfy Claude ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`;
}
const command = await resolveClaudeAcpCommand(input.config);
if (!(await commandIsResolvable(command))) {
const command = await resolveClaudeAcpCommandForTarget(input.config, target);
if (!(await commandIsResolvable(command, input))) {
return `Claude ACP server command is not available: ${command}.`;
}
return null;
@@ -244,10 +287,10 @@ export async function testClaudeAcpEnvironment(
if (targetIsRemote) {
checks.push({
code: "claude_acp_remote_target_unsupported",
level: "error",
message: "Claude ACP currently runs on the local Paperclip host and cannot target a remote execution environment.",
hint: "Use engine=cli for remote or sandbox Claude runs.",
code: "claude_acp_remote_target",
level: "info",
message: "Claude ACP will run against the remote execution environment.",
hint: "Remote ACP requires a bidirectional process target such as SSH or Paperclip's sandbox process-session bridge.",
});
}
@@ -279,8 +322,11 @@ export async function testClaudeAcpEnvironment(
: `Run Claude ACP with Node >=${MIN_ACP_NODE_VERSION} or switch engine=cli.`,
});
const command = await resolveClaudeAcpCommand(config);
const commandResolvable = await commandIsResolvable(command);
const command = await resolveClaudeAcpCommandForTarget(config, target);
const commandResolvable = await commandIsResolvable(command, {
config,
executionTarget: ctx.executionTarget,
});
checks.push({
code: commandResolvable ? "claude_acp_command_resolvable" : "claude_acp_command_missing",
level: commandResolvable ? "info" : "error",
@@ -921,6 +921,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
...(providerQuota && transientRetryNotBefore
? { providerQuotaRetryNotBefore: transientRetryNotBefore.toISOString() }
: {}),
...(proc.terminalResultCleanup ? { unmanagedBackgroundTask: proc.terminalResultCleanup } : {}),
},
clearSession: Boolean(opts.clearSessionOnMissingSession),
};
@@ -1037,6 +1038,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
...(transientRetryNotBefore ? { retryNotBefore: transientRetryNotBefore.toISOString() } : {}),
...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}),
...(providerQuota && transientRetryNotBefore ? { providerQuotaRetryNotBefore: transientRetryNotBefore.toISOString() } : {}),
...(proc.terminalResultCleanup ? { unmanagedBackgroundTask: proc.terminalResultCleanup } : {}),
};
return {
@@ -88,7 +88,7 @@ describe("claude sandbox hello probe diagnostics", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: { command: "claude", model: "claude-opus-4-8" },
config: { engine: "cli", command: "claude", model: "claude-opus-4-8" },
executionTarget: sandboxTarget,
environmentName: "Daytona",
});
@@ -114,7 +114,7 @@ describe("claude sandbox hello probe diagnostics", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: { command: "claude" },
config: { engine: "cli", command: "claude" },
executionTarget: sandboxTarget,
environmentName: "Daytona",
});
@@ -133,7 +133,7 @@ describe("claude sandbox hello probe diagnostics", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: { command: "claude" },
config: { engine: "cli", command: "claude" },
executionTarget: sandboxTarget,
environmentName: "Daytona",
});
@@ -152,7 +152,7 @@ describe("claude sandbox hello probe diagnostics", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: { command: "claude" },
config: { engine: "cli", command: "claude" },
executionTarget: sandboxTarget,
environmentName: "Daytona",
});
@@ -1,9 +1,18 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_CODEX_LOCAL_MODEL, models } from "./index.js";
import { DEFAULT_CODEX_LOCAL_MODEL, isCodexLocalFastModeSupported, models } from "./index.js";
describe("codex local adapter metadata", () => {
it("does not advertise the ChatGPT-unsupported gpt-5.3-codex model as a default option", () => {
expect(DEFAULT_CODEX_LOCAL_MODEL).toBe("gpt-5.5");
expect(models.map((model) => model.id)).not.toContain("gpt-5.3-codex");
it("advertises current GPT-5.6 Codex-capable OpenAI models by default", () => {
const modelIds = models.map((model) => model.id);
expect(DEFAULT_CODEX_LOCAL_MODEL).toBe("gpt-5.6");
expect(modelIds.slice(0, 4)).toEqual([
"gpt-5.6",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
]);
expect(isCodexLocalFastModeSupported(DEFAULT_CODEX_LOCAL_MODEL)).toBe(true);
expect(modelIds).not.toContain("gpt-5.3-codex");
});
});
+9 -7
View File
@@ -5,9 +5,9 @@ export const label = "Codex";
export const SANDBOX_INSTALL_COMMAND = "npm install -g @openai/codex";
export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.5";
export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.6";
export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true;
export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = ["gpt-5.5", "gpt-5.4"] as const;
export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = ["gpt-5.6", "gpt-5.5", "gpt-5.4"] as const;
function normalizeModelId(model: string | null | undefined): string {
return typeof model === "string" ? model.trim() : "";
@@ -28,9 +28,8 @@ export function isCodexLocalFastModeSupported(model: string | null | undefined):
if (isCodexLocalManualModel(model)) return true;
const normalizedModel = typeof model === "string" ? model.trim() : "";
// Empty means we're omitting --model so the Codex CLI picks its own default.
// On subscription auth that's gpt-5.5 (fast-mode capable); manual model IDs
// are also treated as supported. Match that policy: pass the fast-mode
// overrides through and let the CLI reject them if the chosen model can't use them.
// Manual model IDs are also treated as supported: pass the fast-mode overrides
// through and let the CLI reject them if the chosen model can't use them.
if (!normalizedModel) return true;
return CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS.includes(
normalizedModel as (typeof CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS)[number],
@@ -39,6 +38,9 @@ export function isCodexLocalFastModeSupported(model: string | null | undefined):
export const models = [
{ id: DEFAULT_CODEX_LOCAL_MODEL, label: DEFAULT_CODEX_LOCAL_MODEL },
{ id: "gpt-5.6-sol", label: "gpt-5.6-sol" },
{ id: "gpt-5.6-terra", label: "gpt-5.6-terra" },
{ id: "gpt-5.6-luna", label: "gpt-5.6-luna" },
{ id: "gpt-5.4", label: "gpt-5.4" },
{ id: "gpt-5.4-mini", label: "gpt-5.4-mini" },
{ id: "gpt-5.3-codex-spark", label: "gpt-5.3-codex-spark" },
@@ -77,7 +79,7 @@ Core fields:
- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high|xhigh) passed via -c model_reasoning_effort=...
- promptTemplate (string, optional): run prompt template
- search (boolean, optional): run codex with --search
- fastMode (boolean, optional): enable Codex Fast mode; supported on GPT-5.5, GPT-5.4 and passed through for manual model IDs
- fastMode (boolean, optional): enable Codex Fast mode; supported on GPT-5.6, GPT-5.5, GPT-5.4 and passed through for manual model IDs
- dangerouslyBypassApprovalsAndSandbox (boolean, optional): run with bypass flag
- command (string, optional): defaults to "codex"
- extraArgs (string[], optional): additional CLI args
@@ -102,7 +104,7 @@ Notes:
- Paperclip injects desired local skills into the effective CODEX_HOME/skills/ directory at execution time so Codex can discover "$paperclip" and related skills without polluting the project working directory. For new and updated agents, Paperclip assigns an isolated managed home at ~/.paperclip/instances/<id>/companies/<companyId>/agents/<agentId>/codex-home/skills/; when CODEX_HOME is explicitly overridden in adapter config, that override is used instead.
- New and updated codex_local agents persist an empty OPENAI_API_KEY override by default so a host-level OPENAI_API_KEY cannot leak into Codex runs through process inheritance. Explicit CODEX_HOME overrides must not point at the shared company codex-home, $CODEX_HOME, or ~/.codex.
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
- Fast mode is supported on GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`.
- Fast mode is supported on GPT-5.6, GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`.
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
- Codex ACP is the preferred auto lane when Node >=22.13.0 and the Codex ACP server are available. It reuses shared ACP prompt/runtime guidance, selected skill materialization into CODEX_HOME/skills, model/reasoning/fast-mode session config, and existing quota-window reporting. The configured model is applied through a \`-c model=...\` startup config override, which accepts manual model ids; other session config options are best-effort, so a rejected option is logged and the run continues on the agent default instead of failing. Auto selection falls back to CLI only when ACP prerequisites are unavailable; explicit engine="acp" fails loudly.
`;
@@ -245,6 +245,79 @@ describe("codex_local ACP lane", () => {
).resolves.toEqual({ engine: "acp", explicit: true });
});
it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => {
setNodeVersion("v22.13.0");
await expect(
resolveCodexExecutionEngineForRun({
config: { agentCommand: "codex-acp" },
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd: "/work",
runner: {
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
}),
},
},
}),
).resolves.toEqual({ engine: "acp", explicit: false });
});
it("falls back to the CLI lane for one-shot sandbox auto runs", async () => {
setNodeVersion("v22.13.0");
await expect(
resolveCodexExecutionEngineForRun({
config: {},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd: "/work",
},
}),
).resolves.toMatchObject({
engine: "cli",
explicit: false,
fallbackReason: expect.stringContaining("bidirectional remote process"),
});
});
it("falls back to the CLI lane for non-sandbox remote auto runs", async () => {
setNodeVersion("v22.13.0");
await expect(
resolveCodexExecutionEngineForRun({
config: {},
executionTarget: {
kind: "remote",
transport: "ssh",
remoteCwd: "/work",
spec: {
host: "127.0.0.1",
port: 22,
username: "fixture",
remoteCwd: "/work",
remoteWorkspacePath: "/work",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
},
}),
).resolves.toMatchObject({
engine: "cli",
explicit: false,
fallbackReason: expect.stringContaining("sandbox remote targets only"),
});
});
it("maps Codex config to the ACPX Codex target", () => {
expect(buildCodexAcpConfig({
engine: "acp",
+58 -12
View File
@@ -8,7 +8,11 @@ import type {
AdapterExecutionContext,
AdapterExecutionResult,
} from "@paperclipai/adapter-utils";
import { readAdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import {
ensureAdapterExecutionTargetCommandResolvable,
readAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
} from "@paperclipai/adapter-utils/execution-target";
import {
DEFAULT_ACP_ENGINE_MODE,
DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS,
@@ -179,10 +183,30 @@ async function findAncestorBin(startDir: string, binName: string): Promise<strin
}
}
async function commandIsResolvable(command: string): Promise<boolean> {
async function commandIsResolvable(
command: string,
input?: CodexEngineResolutionInput,
): Promise<boolean> {
const trimmed = command.trim();
if (!trimmed) return false;
if (looksLikeShellCommand(trimmed)) return true;
const target = readAdapterExecutionTarget({
executionTarget: input?.executionTarget,
legacyRemoteExecution: input?.executionTransport?.remoteExecution,
});
if (target?.kind === "remote") {
try {
await ensureAdapterExecutionTargetCommandResolvable(
trimmed,
target,
resolveAdapterExecutionTargetCwd(target, asString(input?.config.cwd, ""), process.cwd()),
process.env,
);
return true;
} catch {
return false;
}
}
if (path.isAbsolute(trimmed) || hasPathSeparator(trimmed)) return pathExists(trimmed);
return (await findCommandOnPath(trimmed)) !== null;
}
@@ -197,6 +221,22 @@ async function resolveCodexAcpCommand(config: Record<string, unknown>): Promise<
);
}
function sandboxTargetHasProcessSessionBridge(
target: ReturnType<typeof readAdapterExecutionTarget>,
): boolean {
return target?.kind === "remote" && target.transport === "sandbox" && Boolean(target.runner);
}
async function resolveCodexAcpCommandForTarget(
config: Record<string, unknown>,
target: ReturnType<typeof readAdapterExecutionTarget>,
): Promise<string> {
const configured = firstNonEmptyString(config.agentCommand, config.acpAgentCommand);
if (configured) return configured;
if (target?.kind === "remote") return "codex-acp";
return resolveCodexAcpCommand(config);
}
async function defaultCodexAcpFallbackReason(
input: CodexEngineResolutionInput,
): Promise<string | null> {
@@ -204,14 +244,17 @@ async function defaultCodexAcpFallbackReason(
executionTarget: input.executionTarget,
legacyRemoteExecution: input.executionTransport?.remoteExecution,
});
if (target?.kind === "remote") {
return "Codex ACP currently supports only the local Paperclip host, but this run targets a remote environment.";
if (target?.kind === "remote" && !sandboxTargetHasProcessSessionBridge(target)) {
if (target.transport === "sandbox") {
return "Codex ACP requires a bidirectional remote process target; this sandbox exposes only one-shot command execution.";
}
return "Codex ACP supports sandbox remote targets only; this run targets a non-sandbox remote environment.";
}
if (!nodeVersionMeetsCodexAcpMinimum()) {
return `Node ${process.version} does not satisfy Codex ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`;
}
const command = await resolveCodexAcpCommand(input.config);
if (!(await commandIsResolvable(command))) {
const command = await resolveCodexAcpCommandForTarget(input.config, target);
if (!(await commandIsResolvable(command, input))) {
return `Codex ACP server command is not available: ${command}.`;
}
return null;
@@ -257,10 +300,10 @@ export async function testCodexAcpEnvironment(
if (targetIsRemote) {
checks.push({
code: "codex_acp_remote_target_unsupported",
level: "error",
message: "Codex ACP currently runs on the local Paperclip host and cannot target a remote execution environment.",
hint: "Use engine=cli for remote or sandbox Codex runs.",
code: "codex_acp_remote_target",
level: "info",
message: "Codex ACP will run against the remote execution environment.",
hint: "Remote ACP requires a bidirectional process target such as SSH or Paperclip's sandbox process-session bridge.",
});
}
@@ -292,8 +335,11 @@ export async function testCodexAcpEnvironment(
: `Run Codex ACP with Node >=${MIN_ACP_NODE_VERSION} or switch engine=cli.`,
});
const command = await resolveCodexAcpCommand(config);
const commandResolvable = await commandIsResolvable(command);
const command = await resolveCodexAcpCommandForTarget(config, target);
const commandResolvable = await commandIsResolvable(command, {
config,
executionTarget: ctx.executionTarget,
});
checks.push({
code: commandResolvable ? "codex_acp_command_resolvable" : "codex_acp_command_missing",
level: commandResolvable ? "info" : "error",
@@ -98,7 +98,7 @@ describe("buildCodexExecArgs", () => {
expect(result.fastModeRequested).toBe(true);
expect(result.fastModeApplied).toBe(false);
expect(result.fastModeIgnoredReason).toContain(
"currently only supported on gpt-5.5, gpt-5.4 or manually configured model IDs",
"currently only supported on gpt-5.6, gpt-5.5, gpt-5.4 or manually configured model IDs",
);
expect(result.args).toEqual([
"exec",
@@ -264,10 +264,11 @@ describe("gemini_local ACP lane", () => {
).resolves.toEqual({ engine: "acp", explicit: true });
});
it("falls back to the CLI lane for remote auto runs", async () => {
it("falls back to the CLI lane for non-sandbox remote auto runs", async () => {
setNodeVersion("v20.0.0");
await expect(
resolveGeminiExecutionEngineForRun({
config: {},
config: { agentCommand: "gemini --acp" },
executionTarget: {
kind: "remote",
transport: "ssh",
@@ -287,7 +288,55 @@ describe("gemini_local ACP lane", () => {
).resolves.toMatchObject({
engine: "cli",
explicit: false,
fallbackReason: expect.stringContaining("remote environment"),
fallbackReason: expect.stringContaining("sandbox remote targets only"),
});
});
it("falls back to the CLI lane for one-shot sandbox auto runs", async () => {
setNodeVersion("v20.0.0");
await expect(
resolveGeminiExecutionEngineForRun({
config: {},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd: "/work",
},
}),
).resolves.toMatchObject({
engine: "cli",
explicit: false,
fallbackReason: expect.stringContaining("bidirectional remote process"),
});
});
it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => {
setNodeVersion("v20.0.0");
await expect(
resolveGeminiExecutionEngineForRun({
config: { agentCommand: "gemini --acp" },
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd: "/work",
runner: {
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
}),
},
},
}),
).resolves.toEqual({
engine: "acp",
explicit: false,
});
});
@@ -8,7 +8,11 @@ import type {
AdapterExecutionContext,
AdapterExecutionResult,
} from "@paperclipai/adapter-utils";
import { readAdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import {
ensureAdapterExecutionTargetCommandResolvable,
readAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
} from "@paperclipai/adapter-utils/execution-target";
import {
DEFAULT_ACP_ENGINE_MODE,
DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS,
@@ -183,9 +187,30 @@ function resolveConfigPath(config: Record<string, unknown>): string {
: process.env.PATH ?? "";
}
async function commandIsResolvable(command: string, pathValue = process.env.PATH ?? ""): Promise<boolean> {
async function commandIsResolvable(
command: string,
pathValue = process.env.PATH ?? "",
input?: GeminiEngineResolutionInput,
): Promise<boolean> {
const token = firstShellToken(command);
if (!token) return true;
const target = readAdapterExecutionTarget({
executionTarget: input?.executionTarget,
legacyRemoteExecution: input?.executionTransport?.remoteExecution,
});
if (target?.kind === "remote") {
try {
await ensureAdapterExecutionTargetCommandResolvable(
token,
target,
resolveAdapterExecutionTargetCwd(target, asString(input?.config.cwd, ""), process.cwd()),
process.env,
);
return true;
} catch {
return false;
}
}
if (path.isAbsolute(token) || hasPathSeparator(token)) return pathExists(token);
return (await findCommandOnPath(token, pathValue)) !== null;
}
@@ -197,6 +222,12 @@ function resolveGeminiAcpCommand(config: Record<string, unknown>): string {
return `${geminiCommand} --acp`;
}
function sandboxTargetHasProcessSessionBridge(
target: ReturnType<typeof readAdapterExecutionTarget>,
): boolean {
return target?.kind === "remote" && target.transport === "sandbox" && Boolean(target.runner);
}
async function defaultGeminiAcpFallbackReason(
input: GeminiEngineResolutionInput,
): Promise<string | null> {
@@ -204,14 +235,17 @@ async function defaultGeminiAcpFallbackReason(
executionTarget: input.executionTarget,
legacyRemoteExecution: input.executionTransport?.remoteExecution,
});
if (target?.kind === "remote") {
return "Gemini ACP currently supports only the local Paperclip host, but this run targets a remote environment.";
if (target?.kind === "remote" && !sandboxTargetHasProcessSessionBridge(target)) {
if (target.transport === "sandbox") {
return "Gemini ACP requires a bidirectional remote process target; this sandbox exposes only one-shot command execution.";
}
return "Gemini ACP supports sandbox remote targets only; this run targets a non-sandbox remote environment.";
}
if (!nodeVersionMeetsGeminiAcpMinimum()) {
return `Node ${process.version} does not satisfy Gemini ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`;
}
const command = resolveGeminiAcpCommand(input.config);
if (!(await commandIsResolvable(command, resolveConfigPath(input.config)))) {
if (!(await commandIsResolvable(command, resolveConfigPath(input.config), input))) {
return `Gemini ACP command is not available: ${command}.`;
}
return null;
@@ -244,10 +278,10 @@ export async function testGeminiAcpEnvironment(
if (targetIsRemote) {
checks.push({
code: "gemini_acp_remote_target_unsupported",
level: "error",
message: "Gemini ACP currently runs on the local Paperclip host and cannot target a remote execution environment.",
hint: "Use engine=cli for remote or sandbox Gemini runs.",
code: "gemini_acp_remote_target",
level: "info",
message: "Gemini ACP will run against the remote execution environment.",
hint: "Remote ACP requires a bidirectional process target such as SSH or Paperclip's sandbox process-session bridge.",
});
}
@@ -280,7 +314,10 @@ export async function testGeminiAcpEnvironment(
});
const command = resolveGeminiAcpCommand(config);
const commandResolvable = await commandIsResolvable(command, resolveConfigPath(config));
const commandResolvable = await commandIsResolvable(command, resolveConfigPath(config), {
config,
executionTarget: ctx.executionTarget,
});
checks.push({
code: commandResolvable ? "gemini_acp_command_resolvable" : "gemini_acp_command_missing",
level: commandResolvable ? "info" : "error",
@@ -272,6 +272,9 @@ describe("gemini remote execution", () => {
taskKey: null,
},
config: {
// Pin the CLI lane: sandbox targets with a runner now default to ACP,
// and this test covers the CLI lane's managed-HOME auth flow.
engine: "cli",
command: "gemini",
env: { GEMINI_API_KEY: "test-key" },
},
@@ -0,0 +1,15 @@
ALTER TABLE "inbox_dismissals" ADD COLUMN IF NOT EXISTS "kind" text;--> statement-breakpoint
UPDATE "inbox_dismissals" SET "kind" = 'dismiss' WHERE "kind" IS NULL;--> statement-breakpoint
ALTER TABLE "inbox_dismissals" ALTER COLUMN "kind" SET DEFAULT 'dismiss';--> statement-breakpoint
ALTER TABLE "inbox_dismissals" ALTER COLUMN "kind" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "inbox_dismissals" ADD COLUMN IF NOT EXISTS "snoozed_until" timestamp with time zone;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "inbox_dismissals" ADD CONSTRAINT "inbox_dismissals_kind_check" CHECK ("kind" IN ('dismiss', 'snooze'));
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "inbox_dismissals" ADD CONSTRAINT "inbox_dismissals_kind_snooze_until_check" CHECK (("kind" = 'dismiss' AND "snoozed_until" IS NULL) OR ("kind" = 'snooze' AND "snoozed_until" IS NOT NULL));
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
@@ -1002,6 +1002,13 @@
"when": 1783520000000,
"tag": "0144_case_document_annotations",
"breakpoints": true
},
{
"idx": 145,
"version": "7",
"when": 1783641600000,
"tag": "0145_inbox_dismissal_snooze_kind",
"breakpoints": true
}
]
}
@@ -8,7 +8,9 @@ export const inboxDismissals = pgTable(
companyId: uuid("company_id").notNull().references(() => companies.id),
userId: text("user_id").notNull(),
itemKey: text("item_key").notNull(),
kind: text("kind").$type<"dismiss" | "snooze">().notNull().default("dismiss"),
dismissedAt: timestamp("dismissed_at", { withTimezone: true }).notNull().defaultNow(),
snoozedUntil: timestamp("snoozed_until", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
@@ -1,6 +1,6 @@
---
name: index-refresh
description: Use when an operation issue is an index refresh — typically the hourly index-refresh routine. Rebuild `wiki/index.md` so each entry has a tight, scannable one-line summary and the catalog tracks the actual contents of `wiki/`. Resolve drift between the index and recent log activity, but do not edit page content.
description: Use when an LLM Wiki operation issue requests an index refresh. Rebuild `wiki/index.md` from the actual wiki tree, reconcile missing/deleted entries, and log counts without editing page bodies.
---
# Index Refresh
@@ -1,6 +1,6 @@
---
name: paperclip-distill
description: Use when an operation issue is a Paperclip cursor-window, distill, or backfill — `operationType: "distill"` or `"backfill"` and the body references a Paperclip source bundle for a project or root issue. Turn raw Paperclip activity into a wiki-insightful project page, decisions log, and history note. This skill exists specifically to replace the stiff, datestamp-heavy templated output that the deterministic distiller produces.
description: Use when an operation issue is a Paperclip cursor-window, distill, or backfill. Turn source-bundled Paperclip activity into wiki-insightful project standups, durable project pages, decisions, and history without asset dereferencing.
---
# Paperclip Distill
@@ -1,6 +1,6 @@
---
name: wiki-ingest
description: Use when an operation issue asks you to ingest a captured source from `raw/` into the LLM Wiki, or when the user explicitly says "ingest <slug>". The issue body will name a file under `raw/` (e.g. `raw/karpathy-llm-wiki.md`) and ask for durable wiki pages. Do not invoke this skill for Paperclip activity bundles — those use `paperclip-distill` instead.
description: Use when an operation issue asks to ingest a captured `raw/` source into the LLM Wiki, or the user says "ingest <slug>". Create durable source, entity, concept, synthesis, index, and log pages; use paperclip-distill for Paperclip bundles.
---
# Wiki Ingest
@@ -1,6 +1,6 @@
---
name: wiki-lint
description: Use when an operation issue is a lint or health-check (`operationType: "lint"`) — typically the nightly lint routine or a manual "Run lint" from the UI. Audit the wiki for contradictions, orphans, weak provenance, broken links, and missing concept pages, and return a triage list — do not auto-fix.
description: Use when an LLM Wiki operation issue is a lint or health check. Audit for contradictions, orphans, weak provenance, broken links, missing concept pages, and index/log drift; return triage findings without auto-fixing.
---
# Wiki Lint
@@ -1,6 +1,6 @@
---
name: wiki-query
description: Use when an operation issue asks you to answer a question from the LLM Wiki — `operationType: "query"` and a question in the issue body. Answer with citations to wiki pages and raw sources, and offer to file durable synthesis back into `wiki/synthesis/` so the work compounds instead of disappearing into a chat thread.
description: Use when an LLM Wiki operation issue asks a question. Answer from wiki pages and raw sources with citations, name gaps plainly, and offer to file durable synthesis when the answer should compound.
---
# Wiki Query
@@ -32,6 +32,7 @@ const manifest: PaperclipPluginManifestV1 = {
field: "snapshot",
unsetFields: ["image"],
},
templateIdentityPaths: ["apiUrl"],
supportsTemplateDelete: true,
configSchema: {
type: "object",
@@ -48,6 +48,8 @@ function createMockSandbox(overrides: {
recover: vi.fn().mockResolvedValue(undefined),
resize: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
archive: vi.fn().mockResolvedValue(undefined),
setAutoDeleteInterval: vi.fn().mockResolvedValue(undefined),
createSshAccess: vi.fn().mockResolvedValue({
token: "ssh-token-secret",
command: "ssh ssh-token-secret@ssh.app.daytona.io",
@@ -133,6 +135,7 @@ describe("Daytona sandbox provider plugin", () => {
autoArchiveInterval: 60,
autoDeleteInterval: -1,
reuseLease: true,
archiveOnRelease: false,
},
});
});
@@ -956,6 +959,54 @@ describe("Daytona sandbox provider plugin", () => {
expect(ephemeral.delete).toHaveBeenCalledWith(300);
});
it("archives instead of deleting when the lease was acquired with archiveOnRelease", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox({ id: "sandbox-test-probe", state: "started" });
mockGet.mockResolvedValue(sandbox);
await plugin.definition.onEnvironmentReleaseLease?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "sandbox-test-probe",
config: {
timeoutMs: 300000,
reuseLease: false,
archiveOnRelease: true,
},
});
expect(sandbox.stop).toHaveBeenCalledWith(300);
expect(sandbox.setAutoDeleteInterval).toHaveBeenCalledWith(60);
expect(sandbox.archive).toHaveBeenCalled();
expect(sandbox.delete).not.toHaveBeenCalled();
});
it("falls back to delete when archiving an archiveOnRelease lease fails", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox({ id: "sandbox-test-probe", state: "stopped" });
sandbox.archive.mockRejectedValueOnce(new Error("archive unsupported"));
mockGet.mockResolvedValue(sandbox);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
await plugin.definition.onEnvironmentReleaseLease?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "sandbox-test-probe",
config: {
timeoutMs: 300000,
reuseLease: false,
archiveOnRelease: true,
},
});
expect(sandbox.stop).not.toHaveBeenCalled();
expect(sandbox.archive).toHaveBeenCalled();
expect(sandbox.delete).toHaveBeenCalledWith(300);
expect(warnSpy).toHaveBeenCalled();
});
it("falls back to delete when stopping a reusable lease from an error state fails", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const errored = createMockSandbox({ id: "sandbox-error", state: "error" });
@@ -51,6 +51,7 @@ interface DaytonaDriverConfig {
autoArchiveInterval: number | null;
autoDeleteInterval: number | null;
reuseLease: boolean;
archiveOnRelease: boolean;
}
type WorkspaceSentinelResult = {
@@ -93,6 +94,12 @@ const DEFAULT_AUTO_STOP_INTERVAL_MINUTES = 15;
const DEFAULT_AUTO_ARCHIVE_INTERVAL_MINUTES = 60;
const DEFAULT_AUTO_DELETE_INTERVAL_MINUTES = 7 * 24 * 60; // 7 days
// Sandboxes released with `archiveOnRelease` (test/probe runs) are archived so
// operators can inspect them from the Daytona dashboard, then expired by
// Daytona itself after this interval (counted from the stop that precedes the
// archive) so debugging copies don't accumulate.
const ARCHIVE_ON_RELEASE_AUTO_DELETE_MINUTES = 60;
// Fail-fast cap for git network operations (push, fetch, pull, ls-remote, etc.)
// so a stalled remote or missing credential never consumes the full 900 s adapter
// RPC ceiling; callers always see an actionable error within this window.
@@ -145,6 +152,7 @@ function parseDriverConfig(raw: Record<string, unknown>): DaytonaDriverConfig {
autoArchiveInterval: parseOptionalInteger(raw.autoArchiveInterval) ?? DEFAULT_AUTO_ARCHIVE_INTERVAL_MINUTES,
autoDeleteInterval: parseOptionalInteger(raw.autoDeleteInterval) ?? DEFAULT_AUTO_DELETE_INTERVAL_MINUTES,
reuseLease: raw.reuseLease === true,
archiveOnRelease: raw.archiveOnRelease === true,
};
}
@@ -434,6 +442,9 @@ function leaseMetadata(input: {
target: input.sandbox.target,
timeoutMs: input.config.timeoutMs,
reuseLease: input.config.reuseLease,
// Persisted so the release path (which rebuilds config from lease
// metadata) still knows to archive instead of delete.
...(input.config.archiveOnRelease ? { archiveOnRelease: true } : {}),
remoteCwd: input.remoteCwd,
resumedLease: input.resumedLease,
// Record the resources Paperclip attempted to request so future diagnosis
@@ -890,7 +901,14 @@ const plugin = definePlugin({
});
return {
providerLeaseId: sandbox.id,
metadata: leaseMetadata({ config, sandbox, shellCommand, remoteCwd, resumedLease: false, workspaceSentinel }),
metadata: leaseMetadata({
config,
sandbox,
shellCommand,
remoteCwd,
resumedLease: false,
workspaceSentinel,
}),
};
} catch (error) {
await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch(() => undefined);
@@ -922,7 +940,14 @@ const plugin = definePlugin({
const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs));
return {
providerLeaseId: sandbox.id,
metadata: leaseMetadata({ config, sandbox, shellCommand, remoteCwd, resumedLease: true, workspaceSentinel }),
metadata: leaseMetadata({
config,
sandbox,
shellCommand,
remoteCwd,
resumedLease: true,
workspaceSentinel,
}),
};
} catch (error) {
await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch(() => undefined);
@@ -956,6 +981,21 @@ const plugin = definePlugin({
return;
}
if (config.archiveOnRelease) {
try {
if (sandbox.state !== "stopped") {
await sandbox.stop(toTimeoutSeconds(config.timeoutMs));
}
await sandbox.setAutoDeleteInterval(ARCHIVE_ON_RELEASE_AUTO_DELETE_MINUTES);
await sandbox.archive();
return;
} catch (error) {
console.warn(
`Failed to archive Daytona sandbox during lease release: ${formatErrorMessage(error)}. Falling back to delete.`,
);
}
}
await sandbox.delete(toTimeoutSeconds(config.timeoutMs));
},
+2
View File
@@ -248,10 +248,12 @@ export const ISSUE_THREAD_INTERACTION_KINDS = [
"ask_user_questions",
"request_confirmation",
"request_checkbox_confirmation",
"request_item_verdicts",
] as const;
export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number];
export const REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT = 200;
export const REQUEST_ITEM_VERDICTS_ITEM_LIMIT = REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT;
export const ISSUE_THREAD_INTERACTION_STATUSES = [
"pending",
+30
View File
@@ -49,6 +49,21 @@ export {
type ResponsibleUserDenialCopy,
type ResponsibleUserDenialTone,
} from "./responsible-user-denial.js";
export type {
AttentionDecisionVerb,
AttentionDetailImage,
AttentionFeed,
AttentionItem,
AttentionItemDetail,
AttentionItemDismissal,
AttentionProjectRef,
AttentionSeverity,
AttentionSourceKind,
AttentionSubject,
AttentionSubjectKind,
AttentionWorkspaceRef,
} from "./types/attention.js";
export type {
PipelineAutomationRetryBlocker,
PipelineAutomationRetryCleanupOptions,
@@ -296,6 +311,7 @@ export {
type IssueThreadInteractionStatus,
type IssueThreadInteractionContinuationPolicy,
REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT,
REQUEST_ITEM_VERDICTS_ITEM_LIMIT,
type BuiltInIssueOriginKind,
type PluginIssueOriginKind,
type IssueOriginKind,
@@ -770,6 +786,11 @@ export type {
RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
RequestItemVerdictValue,
RequestItemVerdictsItem,
RequestItemVerdictsPayload,
RequestItemVerdictsResultItem,
RequestItemVerdictsResult,
AcceptedPlanDecompositionStatus,
AcceptedPlanDecompositionChild,
AcceptedPlanDecomposition,
@@ -781,6 +802,7 @@ export type {
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
RequestCheckboxConfirmationInteraction,
RequestItemVerdictsInteraction,
IssueThreadInteraction,
IssueThreadInteractionPayload,
IssueThreadInteractionResult,
@@ -857,6 +879,7 @@ export type {
SidebarBadges,
SidebarOrderPreference,
InboxDismissal,
InboxDismissalKind,
AccessUserProfile,
CompanyMemberRecord,
CompanyMembersResponse,
@@ -1284,11 +1307,17 @@ export {
requestCheckboxConfirmationOptionSchema,
requestCheckboxConfirmationPayloadSchema,
requestCheckboxConfirmationResultSchema,
requestItemVerdictValueSchema,
requestItemVerdictsItemSchema,
requestItemVerdictsPayloadSchema,
requestItemVerdictsResultItemSchema,
requestItemVerdictsResultSchema,
createIssueThreadInteractionSchema,
acceptIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema,
cancelIssueThreadInteractionSchema,
respondIssueThreadInteractionSchema,
submitIssueThreadInteractionVerdictsSchema,
linkIssueApprovalSchema,
createIssueAttachmentMetadataSchema,
createIssueWorkProductSchema,
@@ -1350,6 +1379,7 @@ export {
type RejectIssueThreadInteraction,
type CancelIssueThreadInteraction,
type RespondIssueThreadInteraction,
type SubmitIssueThreadInteractionVerdicts,
type LinkIssueApproval,
type CreateIssueAttachmentMetadata,
type CreateIssueWorkProduct,
@@ -3,6 +3,7 @@ import {
acceptIssueThreadInteractionSchema,
askUserQuestionsResultSchema,
createIssueThreadInteractionSchema,
submitIssueThreadInteractionVerdictsSchema,
} from "./validators/issue.js";
describe("issue thread interaction schemas", () => {
@@ -306,4 +307,111 @@ describe("issue thread interaction schemas", () => {
selectedOptionIds: ["item-1", "item-1"],
})).toThrow("selectedOptionIds must be unique");
});
it("parses request_item_verdicts payloads with defaults", () => {
const parsed = createIssueThreadInteractionSchema.parse({
kind: "request_item_verdicts",
payload: {
version: 1,
prompt: "Review these generated items.",
items: [
{ id: "api", label: "API route", description: "Server submit endpoint" },
{ id: "docs", label: "Docs", previewMarkdown: "Document the route." },
],
},
});
expect(parsed).toMatchObject({
kind: "request_item_verdicts",
continuationPolicy: "wake_assignee",
payload: {
verdicts: ["approve", "reject"],
requireReasonOn: ["reject"],
allowBulkApprove: true,
},
});
});
it("accepts request_item_verdicts defer when enabled explicitly", () => {
const parsed = createIssueThreadInteractionSchema.parse({
kind: "request_item_verdicts",
payload: {
version: 1,
prompt: "Review these generated items.",
items: [{ id: "api", label: "API route" }],
verdicts: ["approve", "reject", "defer"],
requireReasonOn: ["reject", "defer"],
},
});
expect(parsed).toMatchObject({
kind: "request_item_verdicts",
payload: {
verdicts: ["approve", "reject", "defer"],
requireReasonOn: ["reject", "defer"],
},
});
});
it("rejects invalid request_item_verdicts item and reason references", () => {
const base = {
kind: "request_item_verdicts",
payload: {
version: 1,
prompt: "Review these generated items.",
items: [
{ id: "api", label: "API route" },
{ id: "docs", label: "Docs" },
],
},
} as const;
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
items: [],
},
})).toThrow();
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
items: [
{ id: "api", label: "API route" },
{ id: "api", label: "Duplicate" },
],
},
})).toThrow("Item ids must be unique within one item verdict request");
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
items: Array.from({ length: 201 }, (_value, index) => ({
id: `item-${index}`,
label: `Item ${index}`,
})),
},
})).toThrow();
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
verdicts: ["approve", "reject"],
requireReasonOn: ["defer"],
},
})).toThrow("requireReasonOn must reference enabled verdicts");
});
it("rejects duplicate request_item_verdicts submit ids", () => {
expect(() => submitIssueThreadInteractionVerdictsSchema.parse({
verdicts: [
{ id: "api", verdict: "approve" },
{ id: "api", verdict: "reject", reason: "Needs revision" },
],
})).toThrow("verdict item ids must be unique");
});
});
+177
View File
@@ -0,0 +1,177 @@
import type { InboxDismissalKind } from "./inbox-dismissal.js";
export type AttentionSourceKind =
| "approval"
| "issue_thread_interaction"
| "join_request"
| "recovery_action"
| "productivity_review"
| "blocker_attention"
| "review"
| "failed_run"
| "budget_alert"
| "agent_error_alert";
export type AttentionSubjectKind =
| "approval"
| "issue"
| "interaction"
| "join_request"
| "recovery_action"
| "run"
| "budget_incident"
| "agent";
export type AttentionSeverity = "critical" | "high" | "medium" | "low";
export interface AttentionSubject {
kind: AttentionSubjectKind;
id: string;
companyId: string;
title: string | null;
identifier: string | null;
status: string | null;
href: string | null;
metadata?: Record<string, unknown>;
}
export interface AttentionDecisionVerb {
id: string;
label: string;
description: string | null;
}
export interface AttentionProjectRef {
id: string;
name: string;
urlKey: string;
color: string | null;
icon: string | null;
}
export interface AttentionWorkspaceRef {
id: string;
name: string;
}
export interface AttentionDetailImage {
assetId: string;
alt?: string | null;
}
export interface AttentionItemDismissal {
kind: InboxDismissalKind;
dismissedAt: string;
snoozedUntil: string | null;
isActive: boolean;
}
export type AttentionItemDetail =
| {
kind: "approval";
approvalType: string;
summaryExcerpt: string | null;
images: AttentionDetailImage[];
}
| {
kind: "plan_approval";
issueTitle: string | null;
planTitle: string | null;
summaryExcerpt: string | null;
images: AttentionDetailImage[];
}
| {
kind: "confirmation";
promptExcerpt: string | null;
isPlanTarget: false;
images: AttentionDetailImage[];
}
| {
kind: "questions";
questionCount: number;
firstQuestionText: string | null;
images: AttentionDetailImage[];
}
| {
kind: "suggested_tasks";
taskCount: number;
firstTaskTitle: string | null;
images: AttentionDetailImage[];
}
| {
kind: "checkbox_confirmation";
optionCount: number;
promptExcerpt: string | null;
images: AttentionDetailImage[];
}
| {
kind: "item_verdicts";
itemCount: number;
promptExcerpt: string | null;
images: AttentionDetailImage[];
}
| {
kind: "failed_run";
agentName: string | null;
failureReasonExcerpt: string | null;
images: AttentionDetailImage[];
}
| {
kind: "blocker";
blockingIssue: {
id: string | null;
identifier: string | null;
title: string | null;
} | null;
images: AttentionDetailImage[];
}
| {
kind: "budget";
observedPercent: number;
amountObserved: number;
amountLimit: number;
images: AttentionDetailImage[];
}
| {
kind: "agent_error";
agentName: string | null;
failureReasonExcerpt: string | null;
images: AttentionDetailImage[];
}
| {
kind: "generic";
summaryExcerpt: string | null;
images: AttentionDetailImage[];
};
export interface AttentionItem {
id: string;
companyId: string;
sourceKind: AttentionSourceKind;
subject: AttentionSubject;
whyNow: string;
decisionVerbs: AttentionDecisionVerb[];
inlineResolvable: boolean;
entryRule: string;
exitRule: string;
dedupKey: string;
dismissalKey: string;
dismissal: AttentionItemDismissal | null;
severity: AttentionSeverity;
rank: number;
activityAt: string;
createdAt: string;
updatedAt: string;
relatedIssue: AttentionSubject | null;
project: AttentionProjectRef | null;
workspace: AttentionWorkspaceRef | null;
detail: AttentionItemDetail | null;
}
export interface AttentionFeed {
companyId: string;
generatedAt: string;
totalCount: number;
countsBySourceKind: Record<AttentionSourceKind, number>;
items: AttentionItem[];
}
+12
View File
@@ -30,6 +30,12 @@ export interface FakeSandboxEnvironmentConfig {
reuseLease: boolean;
/** Stream agent CLI stdout/stderr during sandbox runs (bridge log-tail loop). */
streamRunLogs?: boolean;
/**
* Archive the sandbox on lease release instead of deleting it, so operators
* can inspect it from the provider dashboard. Injected by test/probe paths;
* providers without archive support delete as usual.
*/
archiveOnRelease?: boolean;
}
export interface PluginSandboxEnvironmentConfig {
@@ -38,6 +44,12 @@ export interface PluginSandboxEnvironmentConfig {
timeoutMs?: number;
/** Stream agent CLI stdout/stderr during sandbox runs (bridge log-tail loop). */
streamRunLogs?: boolean;
/**
* Archive the sandbox on lease release instead of deleting it, so operators
* can inspect it from the provider dashboard. Injected by test/probe paths;
* providers without archive support delete as usual.
*/
archiveOnRelease?: boolean;
[key: string]: unknown;
}
@@ -1,9 +1,13 @@
export type InboxDismissalKind = "dismiss" | "snooze";
export interface InboxDismissal {
id: string;
companyId: string;
userId: string;
itemKey: string;
kind: InboxDismissalKind;
dismissedAt: Date;
snoozedUntil: Date | null;
createdAt: Date;
updatedAt: Date;
}
+21 -1
View File
@@ -1,4 +1,18 @@
export type { Company } from "./company.js";
export type {
AttentionDecisionVerb,
AttentionDetailImage,
AttentionFeed,
AttentionItem,
AttentionItemDetail,
AttentionItemDismissal,
AttentionProjectRef,
AttentionSeverity,
AttentionSourceKind,
AttentionSubject,
AttentionSubjectKind,
AttentionWorkspaceRef,
} from "./attention.js";
export type {
Environment,
EnvironmentDeleteBlastRadius,
@@ -400,6 +414,11 @@ export type {
RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
RequestItemVerdictValue,
RequestItemVerdictsItem,
RequestItemVerdictsPayload,
RequestItemVerdictsResultItem,
RequestItemVerdictsResult,
AcceptedPlanDecompositionStatus,
AcceptedPlanDecompositionChild,
AcceptedPlanDecomposition,
@@ -411,6 +430,7 @@ export type {
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
RequestCheckboxConfirmationInteraction,
RequestItemVerdictsInteraction,
IssueThreadInteraction,
IssueThreadInteractionPayload,
IssueThreadInteractionResult,
@@ -558,7 +578,7 @@ export type {
UpdateResourceMembership,
} from "./resource-memberships.js";
export { RESOURCE_MEMBERSHIP_STATES } from "./resource-memberships.js";
export type { InboxDismissal } from "./inbox-dismissal.js";
export type { InboxDismissal, InboxDismissalKind } from "./inbox-dismissal.js";
export type {
AccessUserProfile,
CompanyMemberRecord,
+11
View File
@@ -57,6 +57,7 @@ export interface InstanceExperimentalSettings {
enableCloudSync: boolean;
enableExternalObjects: boolean;
enableBuiltInAgents: boolean;
enableDecisions: boolean;
enableGoalsSidebarLink: boolean;
enableServerInfoDebugView: boolean;
autoRestartDevServerWhenIdle: boolean;
@@ -70,6 +71,16 @@ export interface InstanceExperimentalSettings {
* runs actually execute inside the preview. Ignored outside a worktree.
*/
enableWorktreeRunExecution: boolean;
/**
* Server-managed cutoff recorded when worktree run execution is enabled in
* this instance. Client PATCH payloads must not control this value.
*/
worktreeRunExecutionActivatedAt: string | null;
/**
* Server-managed instance id captured with the cutoff so copied settings rows
* from another instance fail closed.
*/
worktreeRunExecutionActivationInstanceId: string | null;
issueGraphLivenessAutoRecoveryLookbackHours: number;
}
+54 -3
View File
@@ -1082,6 +1082,30 @@ export interface RequestCheckboxConfirmationPayload {
target?: RequestConfirmationTarget | null;
}
export type RequestItemVerdictValue = "approve" | "reject" | "defer";
export interface RequestItemVerdictsItem {
id: string;
label: string;
description?: string | null;
previewMarkdown?: string | null;
href?: string | null;
attachmentId?: string | null;
}
export interface RequestItemVerdictsPayload {
version: 1;
prompt: string;
detailsMarkdown?: string | null;
items: RequestItemVerdictsItem[];
verdicts?: RequestItemVerdictValue[];
requireReasonOn?: RequestItemVerdictValue[];
reasonLabel?: string | null;
allowBulkApprove?: boolean;
supersedeOnUserComment?: boolean;
target?: RequestConfirmationTarget | null;
}
export interface RequestConfirmationResult {
version: 1;
outcome: "accepted" | "rejected" | "superseded_by_comment" | "stale_target";
@@ -1104,6 +1128,24 @@ export interface RequestCheckboxConfirmationResult extends RequestConfirmationRe
selectedOptionIds?: string[];
}
export interface RequestItemVerdictsResultItem {
id: string;
verdict: RequestItemVerdictValue;
reason?: string | null;
resolvedByUserId: string;
resolvedAt: Date | string;
commentId?: string | null;
}
export interface RequestItemVerdictsResult {
version: 1;
outcome: "resolved" | "superseded_by_comment" | "stale_target" | "cancelled";
complete: boolean;
items: RequestItemVerdictsResultItem[];
commentId?: string | null;
staleTarget?: RequestConfirmationTarget | null;
}
export interface IssueThreadInteractionBase extends IssueThreadInteractionActorFields {
id: string;
companyId: string;
@@ -1145,23 +1187,32 @@ export interface RequestCheckboxConfirmationInteraction extends IssueThreadInter
result?: RequestCheckboxConfirmationResult | null;
}
export interface RequestItemVerdictsInteraction extends IssueThreadInteractionBase {
kind: "request_item_verdicts";
payload: RequestItemVerdictsPayload;
result?: RequestItemVerdictsResult | null;
}
export type IssueThreadInteraction =
| SuggestTasksInteraction
| AskUserQuestionsInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction;
| RequestCheckboxConfirmationInteraction
| RequestItemVerdictsInteraction;
export type IssueThreadInteractionPayload =
| SuggestTasksPayload
| AskUserQuestionsPayload
| RequestConfirmationPayload
| RequestCheckboxConfirmationPayload;
| RequestCheckboxConfirmationPayload
| RequestItemVerdictsPayload;
export type IssueThreadInteractionResult =
| SuggestTasksResult
| AskUserQuestionsResult
| RequestConfirmationResult
| RequestCheckboxConfirmationResult;
| RequestCheckboxConfirmationResult
| RequestItemVerdictsResult;
export interface IssueAttachment {
id: string;
+7
View File
@@ -164,6 +164,13 @@ export interface PluginEnvironmentDriverDeclaration {
* runtime config. Omit to use the standard key for `templateRefKind`.
*/
templateConfigBinding?: PluginEnvironmentTemplateConfigBinding;
/**
* Config paths (dot notation) that scope where captured templates live for
* this provider, such as an API endpoint. When one of these changes on a
* saved environment, captured templates cannot be re-linked to the updated
* config and a fresh capture is required.
*/
templateIdentityPaths?: string[];
/** Provider supports best-effort deletion/cleanup of captured templates. */
supportsTemplateDelete?: boolean;
/** JSON Schema describing the driver's provider-specific configuration. */
+7
View File
@@ -364,11 +364,17 @@ export {
requestCheckboxConfirmationOptionSchema,
requestCheckboxConfirmationPayloadSchema,
requestCheckboxConfirmationResultSchema,
requestItemVerdictValueSchema,
requestItemVerdictsItemSchema,
requestItemVerdictsPayloadSchema,
requestItemVerdictsResultItemSchema,
requestItemVerdictsResultSchema,
createIssueThreadInteractionSchema,
acceptIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema,
cancelIssueThreadInteractionSchema,
respondIssueThreadInteractionSchema,
submitIssueThreadInteractionVerdictsSchema,
linkIssueApprovalSchema,
createIssueAttachmentMetadataSchema,
issueDocumentFormatSchema,
@@ -391,6 +397,7 @@ export {
type RejectIssueThreadInteraction,
type CancelIssueThreadInteraction,
type RespondIssueThreadInteraction,
type SubmitIssueThreadInteractionVerdicts,
type LinkIssueApproval,
type CreateIssueAttachmentMetadata,
type IssueDocumentFormat,
@@ -28,6 +28,20 @@ describe("instance experimental settings validators", () => {
const settings = instanceExperimentalSettingsSchema.parse({});
expect(settings.enableWorktreeRunExecution).toBe(false);
expect(settings.worktreeRunExecutionActivatedAt).toBeNull();
expect(settings.worktreeRunExecutionActivationInstanceId).toBeNull();
});
it("strips server-managed worktree run execution fields from patches", () => {
expect(
patchInstanceExperimentalSettingsSchema.parse({
enableWorktreeRunExecution: true,
worktreeRunExecutionActivatedAt: "2026-07-10T12:00:00.000Z",
worktreeRunExecutionActivationInstanceId: "copied-instance",
}),
).toEqual({
enableWorktreeRunExecution: true,
});
});
it("defaults built-in agents off", () => {
@@ -46,6 +60,22 @@ describe("instance experimental settings validators", () => {
});
});
it("defaults the decisions sidebar link off", () => {
const settings = instanceExperimentalSettingsSchema.parse({});
expect(settings.enableDecisions).toBe(false);
});
it("accepts decisions patches", () => {
expect(
patchInstanceExperimentalSettingsSchema.parse({
enableDecisions: true,
}),
).toEqual({
enableDecisions: true,
});
});
it("accepts server info debug view patches", () => {
expect(
patchInstanceExperimentalSettingsSchema.parse({
+10 -1
View File
@@ -51,6 +51,7 @@ export const instanceExperimentalSettingsSchema = z.object({
enableCloudSync: z.boolean().default(false),
enableExternalObjects: z.boolean().default(false),
enableBuiltInAgents: z.boolean().default(false),
enableDecisions: z.boolean().default(false),
enableGoalsSidebarLink: z.boolean().default(false),
enableServerInfoDebugView: z.boolean().default(false),
autoRestartDevServerWhenIdle: z.boolean().default(false),
@@ -58,6 +59,8 @@ export const instanceExperimentalSettingsSchema = z.object({
enableWorkspaceBranchReconcileForward: z.boolean().default(true),
enableWorkspaceDirtyQuarantineRepair: z.boolean().default(true),
enableWorktreeRunExecution: z.boolean().default(false),
worktreeRunExecutionActivatedAt: z.string().datetime().nullable().default(null),
worktreeRunExecutionActivationInstanceId: z.string().min(1).nullable().default(null),
issueGraphLivenessAutoRecoveryLookbackHours: z
.number()
.int()
@@ -66,7 +69,13 @@ export const instanceExperimentalSettingsSchema = z.object({
.default(DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS),
}).strict();
export const patchInstanceExperimentalSettingsSchema = instanceExperimentalSettingsSchema.partial();
export const patchInstanceExperimentalSettingsSchema = instanceExperimentalSettingsSchema
.omit({
worktreeRunExecutionActivatedAt: true,
worktreeRunExecutionActivationInstanceId: true,
})
.partial()
.strip();
export const patchInstanceSettingsSchema = z.object({
defaultEnvironmentId: z.string().uuid().nullable().optional(),
+149
View File
@@ -28,6 +28,7 @@ import {
ISSUE_WATCHDOG_DISCOVERY_KINDS,
MODEL_PROFILE_KEYS,
REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT,
REQUEST_ITEM_VERDICTS_ITEM_LIMIT,
} from "../constants.js";
import { multilineTextSchema } from "./text.js";
import { lowTrustReviewPresetPolicySchema, trustAuthorizationPolicySchema } from "./trust-policy.js";
@@ -894,6 +895,121 @@ export const requestCheckboxConfirmationResultSchema = requestConfirmationResult
}
});
export const requestItemVerdictValueSchema = z.enum(["approve", "reject", "defer"]);
export const requestItemVerdictsItemSchema = z.object({
id: z.string().trim().min(1).max(120),
label: z.string().trim().min(1).max(120),
description: z.string().trim().max(500).nullable().optional(),
previewMarkdown: z.string().max(20000).nullable().optional(),
href: requestConfirmationHrefSchema.nullable().optional(),
attachmentId: z.string().uuid().nullable().optional(),
});
export const requestItemVerdictsPayloadSchema = z.object({
version: z.literal(1),
prompt: z.string().trim().min(1).max(1000),
detailsMarkdown: z.string().max(20000).nullable().optional(),
items: z.array(requestItemVerdictsItemSchema)
.min(1)
.max(REQUEST_ITEM_VERDICTS_ITEM_LIMIT),
verdicts: z.array(requestItemVerdictValueSchema)
.min(2)
.max(3)
.optional()
.default(["approve", "reject"]),
requireReasonOn: z.array(requestItemVerdictValueSchema)
.max(3)
.optional()
.default(["reject"]),
reasonLabel: z.string().trim().min(1).max(160).nullable().optional(),
allowBulkApprove: z.boolean().optional().default(true),
supersedeOnUserComment: z.boolean().optional(),
target: requestConfirmationTargetSchema.nullable().optional(),
}).superRefine((value, ctx) => {
const itemIds = new Set<string>();
for (const [index, item] of value.items.entries()) {
if (itemIds.has(item.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Item ids must be unique within one item verdict request",
path: ["items", index, "id"],
});
}
itemIds.add(item.id);
}
const verdicts = new Set<string>();
for (const [index, verdict] of value.verdicts.entries()) {
if (verdicts.has(verdict)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "verdicts must be unique",
path: ["verdicts", index],
});
}
verdicts.add(verdict);
}
if (!verdicts.has("approve") || !verdicts.has("reject")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "verdicts must include approve and reject; defer is optional",
path: ["verdicts"],
});
}
const reasonVerdicts = new Set<string>();
for (const [index, verdict] of value.requireReasonOn.entries()) {
if (reasonVerdicts.has(verdict)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "requireReasonOn must be unique",
path: ["requireReasonOn", index],
});
continue;
}
reasonVerdicts.add(verdict);
if (!verdicts.has(verdict)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "requireReasonOn must reference enabled verdicts",
path: ["requireReasonOn", index],
});
}
}
});
export const requestItemVerdictsResultItemSchema = z.object({
id: z.string().trim().min(1).max(120),
verdict: requestItemVerdictValueSchema,
reason: z.string().trim().max(4000).nullable().optional(),
resolvedByUserId: z.string().trim().min(1).max(255),
resolvedAt: z.union([z.string().datetime(), z.date()]),
commentId: z.string().uuid().nullable().optional(),
});
export const requestItemVerdictsResultSchema = z.object({
version: z.literal(1),
outcome: z.enum(["resolved", "superseded_by_comment", "stale_target", "cancelled"]),
complete: z.boolean(),
items: z.array(requestItemVerdictsResultItemSchema)
.max(REQUEST_ITEM_VERDICTS_ITEM_LIMIT),
commentId: z.string().uuid().nullable().optional(),
staleTarget: requestConfirmationTargetSchema.nullable().optional(),
}).superRefine((value, ctx) => {
const itemIds = new Set<string>();
for (const [index, item] of value.items.entries()) {
if (itemIds.has(item.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "result item ids must be unique",
path: ["items", index, "id"],
});
}
itemIds.add(item.id);
}
});
export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("suggest_tasks"),
@@ -935,6 +1051,16 @@ export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
payload: requestCheckboxConfirmationPayloadSchema,
}),
z.object({
kind: z.literal("request_item_verdicts"),
idempotencyKey: z.string().trim().max(255).nullable().optional(),
sourceCommentId: z.string().uuid().nullable().optional(),
sourceRunId: z.string().uuid().nullable().optional(),
title: z.string().trim().max(240).nullable().optional(),
summary: z.string().trim().max(1000).nullable().optional(),
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
payload: requestItemVerdictsPayloadSchema,
}),
]);
export type CreateIssueThreadInteraction = z.infer<typeof createIssueThreadInteractionSchema>;
@@ -989,6 +1115,29 @@ export const respondIssueThreadInteractionSchema = z.object({
});
export type RespondIssueThreadInteraction = z.infer<typeof respondIssueThreadInteractionSchema>;
export const submitIssueThreadInteractionVerdictsSchema = z.object({
verdicts: z.array(z.object({
id: z.string().trim().min(1).max(120),
verdict: requestItemVerdictValueSchema,
reason: z.string().trim().max(4000).nullable().optional(),
}))
.min(1)
.max(REQUEST_ITEM_VERDICTS_ITEM_LIMIT),
}).superRefine((value, ctx) => {
const itemIds = new Set<string>();
for (const [index, verdict] of value.verdicts.entries()) {
if (itemIds.has(verdict.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "verdict item ids must be unique",
path: ["verdicts", index, "id"],
});
}
itemIds.add(verdict.id);
}
});
export type SubmitIssueThreadInteractionVerdicts = z.infer<typeof submitIssueThreadInteractionVerdictsSchema>;
export const linkIssueApprovalSchema = z.object({
approvalId: z.string().uuid(),
});
+1
View File
@@ -167,6 +167,7 @@ export const pluginEnvironmentDriverDeclarationSchema = z.object({
supportsTemplateCapture: z.boolean().optional(),
templateRefKind: z.string().min(1).max(100).optional(),
templateConfigBinding: pluginEnvironmentTemplateConfigBindingSchema.optional(),
templateIdentityPaths: z.array(z.string().min(1).max(200)).max(20).optional(),
supportsTemplateDelete: z.boolean().optional(),
configSchema: jsonSchemaSchema,
});
@@ -1,6 +1,6 @@
---
name: reflection-coach
description: Reflect on another agent's recent execution record, name evidence-backed patterns, and propose the smallest durable change to their AGENTS.md, a reusable skill, or a tool description — as a reviewable, interaction-gated proposal, never a same-run hot-swap.
description: Reflect on another agent's recent execution record and propose the smallest durable instruction, skill, or tool-description change. Use for evidence-backed coaching proposals, never hot-swaps.
key: paperclipai/bundled/paperclip-operations/reflection-coach
recommendedForRoles:
- manager
@@ -1,6 +1,6 @@
---
name: paperclip-capsules
description: Generate, implement, or review Paperclip capsule visuals. Use when the user asks for Paperclip capsule art, capsule banks, agent capsule visuals, heartbeat status capsules, capsule identicons, capsule graphic-generator output, or validation of Paperclip capsule brand usage.
description: Generate, implement, or review Paperclip capsule visuals. Use for capsule art, agent capsules, heartbeat status capsules, identicons, capsule banks, or brand-usage validation.
key: paperclipai/bundled/product/paperclip-capsules
recommendedForRoles:
- designer
@@ -1,6 +1,6 @@
---
name: wireframe
description: Produce low-fidelity black-and-white UI wireframes as standalone SVG files, optionally bundled into a single-page HTML viewer and published via the here-now skill. Use when the user asks to "wireframe X", "sketch a screen for", "draft a layout", "low-fi mockup", "rough mock", "make a page to view the wireframes", "build a viewer for these screens", or to "deploy / publish / host the wireframes". Do NOT use when the user wants production UI code, branded designs, hi-fi mockups, or animated/interactive prototypes — use frontend-design or similar instead.
description: Produce low-fidelity black-and-white UI wireframes as SVGs or viewer pages. Use when asked to wireframe, sketch a screen, draft a layout, make a low-fi mockup, or publish wireframes.
key: paperclipai/bundled/product/wireframe
recommendedForRoles:
- designer
+13 -13
View File
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"packageName": "@paperclipai/skills-catalog",
"packageVersion": "0.3.1",
"generatedAt": "2026-07-10T00:27:11.902Z",
"generatedAt": "2026-07-10T13:15:25.878Z",
"skills": [
{
"id": "paperclipai:bundled:docs:doc-maintenance",
@@ -80,7 +80,7 @@
"category": "paperclip-operations",
"slug": "reflection-coach",
"name": "reflection-coach",
"description": "Reflect on another agent's recent execution record, name evidence-backed patterns, and propose the smallest durable change to their AGENTS.md, a reusable skill, or a tool description — as a reviewable, interaction-gated proposal, never a same-run hot-swap.",
"description": "Reflect on another agent's recent execution record and propose the smallest durable instruction, skill, or tool-description change. Use for evidence-backed coaching proposals, never hot-swaps.",
"path": "catalog/bundled/paperclip-operations/reflection-coach",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
@@ -102,11 +102,11 @@
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 11903,
"sha256": "ca167eac8d1e89cadc8009b61a204368507e7edb7f2da0688fea6dba8223e189"
"sizeBytes": 11837,
"sha256": "86d6e382c32c626213b46c010cc7fc1ec460821a8c777cfa5b8e86fa9d6a86b2"
}
],
"contentHash": "sha256:20381a898f05ceb668e305708dd33a03ce36aef0e00b923c01ae20ab785f04d2"
"contentHash": "sha256:1c7a82cd9638a1d845b238032da3ff4ad80c5b6a87dca46082f501fa4583db55"
},
{
"id": "paperclipai:bundled:paperclip-operations:task-planning",
@@ -150,7 +150,7 @@
"category": "product",
"slug": "paperclip-capsules",
"name": "paperclip-capsules",
"description": "Generate, implement, or review Paperclip capsule visuals. Use when the user asks for Paperclip capsule art, capsule banks, agent capsule visuals, heartbeat status capsules, capsule identicons, capsule graphic-generator output, or validation of Paperclip capsule brand usage.",
"description": "Generate, implement, or review Paperclip capsule visuals. Use for capsule art, agent capsules, heartbeat status capsules, identicons, capsule banks, or brand-usage validation.",
"path": "catalog/bundled/product/paperclip-capsules",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
@@ -175,8 +175,8 @@
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 5930,
"sha256": "eda40e8f29cae07c2ce8ad1cef3406eae243dbbf52069a85cfb56fe7999a25ff"
"sizeBytes": 5831,
"sha256": "dd659ccb46e105336eb00fbe689b1d5531347fc69493c3156390d40ee413c93e"
},
{
"path": "references/generator-workflows.md",
@@ -203,7 +203,7 @@
"sha256": "be1f2eccf96b4d2deefe45b3bc1dd0a3597278472c2ffedadc43c57161e9103d"
}
],
"contentHash": "sha256:e8f502f9556103bd575bb664a9d21b1004869990802e639a188a720012116909"
"contentHash": "sha256:a2b26cf6895affeeb70c85567a061094450a59bd3c4fdcf28ee65858599ac12c"
},
{
"id": "paperclipai:bundled:product:wireframe",
@@ -212,7 +212,7 @@
"category": "product",
"slug": "wireframe",
"name": "wireframe",
"description": "Produce low-fidelity black-and-white UI wireframes as standalone SVG files, optionally bundled into a single-page HTML viewer and published via the here-now skill. Use when the user asks to \"wireframe X\", \"sketch a screen for\", \"draft a layout\", \"low-fi mockup\", \"rough mock\", \"make a page to view the wireframes\", \"build a viewer for these screens\", or to \"deploy / publish / host the wireframes\". Do NOT use when the user wants production UI code, branded designs, hi-fi mockups, or animated/interactive prototypes — use frontend-design or similar instead.",
"description": "Produce low-fidelity black-and-white UI wireframes as SVGs or viewer pages. Use when asked to wireframe, sketch a screen, draft a layout, make a low-fi mockup, or publish wireframes.",
"path": "catalog/bundled/product/wireframe",
"entrypoint": "SKILL.md",
"trustLevel": "assets",
@@ -235,8 +235,8 @@
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 11887,
"sha256": "a910d038d8cdd13615b82f44a2195b781ebccf01c49f9c58babd43b0701fe644"
"sizeBytes": 11509,
"sha256": "6992047cc97b67211cc8c1183718ab9aa26b8afc34c70d54d20558c215daaf48"
},
{
"path": "assets/site-template.html",
@@ -275,7 +275,7 @@
"sha256": "017260d2216859d57e5fa869e0efc6b88c7ed4d8a8a4f5af04fe89a3ef2445bd"
}
],
"contentHash": "sha256:0bd9a9fdc656d529e3f97c00cd504dcf72d3a4fecb8b0504ca2fe3e00d63287f"
"contentHash": "sha256:7106d84fc9db2053090f692706e41cfc586e7dd85a44f568e3319c4eefbdde46"
},
{
"id": "paperclipai:bundled:quality:qa-acceptance",
@@ -1,4 +1,6 @@
import { readFileSync } from "node:fs";
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { catalogManifest, catalogSkills, resolveCatalogSkillRef } from "./index.js";
@@ -21,7 +23,68 @@ const EXPECTED_OPTIONAL_KEYS = [
"paperclipai/optional/research/last30days",
];
const MAX_FRONTMATTER_DESCRIPTION_LENGTH = 300;
const REPO_ROOT = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const SKILL_FRONTMATTER_ROOTS = [
path.join(REPO_ROOT, ".agents"),
path.join(REPO_ROOT, "skills"),
path.join(REPO_ROOT, "packages/adapters"),
path.join(REPO_ROOT, "packages/plugins"),
path.join(REPO_ROOT, "packages/skills-catalog/catalog"),
path.join(REPO_ROOT, "packages/teams-catalog/catalog"),
];
function listSkillFiles(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) return listSkillFiles(entryPath);
if (entry.isFile() && entry.name === "SKILL.md") return [entryPath];
return [];
});
}
function readFrontmatterDescription(markdown: string): string | null {
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) return null;
const lines = match[1]!.split(/\r?\n/);
const descriptionIndex = lines.findIndex((line) => line.startsWith("description:"));
if (descriptionIndex === -1) return null;
const inlineValue = lines[descriptionIndex]!.slice("description:".length).trim();
if (/^[>|][+-]?$/.test(inlineValue)) {
const descriptionLines: string[] = [];
for (let index = descriptionIndex + 1; index < lines.length; index += 1) {
const line = lines[index]!;
if (/^[A-Za-z0-9_-]+:/.test(line)) break;
descriptionLines.push(line.trim());
}
return descriptionLines.join(" ").replace(/\s+/g, " ").trim();
}
return inlineValue.replace(/^['"]|['"]$/g, "");
}
describe("shipped skills catalog", () => {
it("keeps repo and catalog skill descriptions within the prompt budget cap", () => {
const violations: string[] = [];
for (const skillFile of SKILL_FRONTMATTER_ROOTS.flatMap(listSkillFiles)) {
const description = readFrontmatterDescription(readFileSync(skillFile, "utf8"));
if (!description) {
violations.push(`${path.relative(REPO_ROOT, skillFile)} is missing a frontmatter description`);
} else if (description.length > MAX_FRONTMATTER_DESCRIPTION_LENGTH) {
violations.push(`${path.relative(REPO_ROOT, skillFile)} description is ${description.length} chars`);
}
}
for (const skill of catalogSkills) {
if (skill.description.length > MAX_FRONTMATTER_DESCRIPTION_LENGTH) {
violations.push(`${skill.key} generated description is ${skill.description.length} chars`);
}
}
expect(violations).toEqual([]);
});
it("ships the expected bundled and optional skill set", () => {
const bundledKeys = catalogSkills
.filter((skill) => skill.kind === "bundled")
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
// Screenshot the C3 per-item verdict card stories (PAP-13249) at both
// viewports and both themes against a served storybook-static (see PAP-13249).
import fs from "node:fs/promises";
import path from "node:path";
import { chromium } from "@playwright/test";
const BASE = "http://localhost:6102/iframe.html";
const PREFIX = "chat-comments-issue-thread-interactions";
const OUT_DIR = process.argv[2] || "screenshots/pap-13249";
const stories = [
{ id: "item-verdicts-pending", label: "s1-s2-pending" },
{ id: "item-verdicts-partial", label: "s4-partial" },
{ id: "item-verdicts-complete", label: "s5-complete" },
{ id: "item-verdicts-superseded", label: "s6-superseded" },
{ id: "item-verdicts-many-items", label: "s7-many" },
];
const viewports = [
{ name: "desktop", width: 1440, height: 900 },
{ name: "mobile", width: 390, height: 844 },
];
const themes = ["light", "dark"];
await fs.mkdir(path.resolve(OUT_DIR), { recursive: true });
const executablePath = process.env.CHROME_BIN || undefined;
const browser = await chromium.launch({
headless: true,
executablePath,
args: ["--no-sandbox", "--headless=new"],
});
try {
for (const vp of viewports) {
for (const theme of themes) {
const ctx = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
deviceScaleFactor: 2,
});
const page = await ctx.newPage();
for (const story of stories) {
const url = `${BASE}?id=${PREFIX}--${story.id}&viewMode=story&globals=theme:${theme}`;
await page.goto(url, { waitUntil: "networkidle" });
await page.waitForTimeout(800);
const out = path.join(OUT_DIR, `${story.label}_${vp.name}_${theme}.png`);
await page.screenshot({ path: out, fullPage: true });
console.log(`Wrote ${out}`);
}
await ctx.close();
}
}
} finally {
await browser.close();
}
+7 -3
View File
@@ -48,7 +48,10 @@ describe("adapter model listing", () => {
const models = await listAdapterModels("codex_local");
expect(models).toEqual(codexFallbackModels);
expect(models.some((model) => model.id === "gpt-5.5")).toBe(true);
expect(models.some((model) => model.id === "gpt-5.6")).toBe(true);
expect(models.some((model) => model.id === "gpt-5.6-sol")).toBe(true);
expect(models.some((model) => model.id === "gpt-5.6-terra")).toBe(true);
expect(models.some((model) => model.id === "gpt-5.6-luna")).toBe(true);
expect(fetchSpy).not.toHaveBeenCalled();
});
@@ -155,7 +158,7 @@ describe("adapter model listing", () => {
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ id: "gpt-5.5" }],
data: [{ id: "gpt-5.6-terra" }],
}),
} as Response);
@@ -164,7 +167,8 @@ describe("adapter model listing", () => {
expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(initial.some((model) => model.id === "gpt-5")).toBe(true);
expect(refreshed.some((model) => model.id === "gpt-5.5")).toBe(true);
expect(refreshed.some((model) => model.id === "gpt-5.6-terra")).toBe(true);
expect(refreshed.some((model) => model.id === "gpt-5.6-luna")).toBe(true);
});
it("falls back to static codex models when OpenAI model discovery fails", async () => {
@@ -137,7 +137,15 @@ describe("agent test-environment route", () => {
mockEnvironmentRuntime.acquireRunLease.mockResolvedValue({
lease: {
id: "lease-1",
metadata: { remoteCwd: "/home/user/paperclip-workspace" },
provider: "daytona",
providerLeaseId: "provider-lease-1",
metadata: {
remoteCwd: "/home/user/paperclip-workspace",
sandboxId: "sandbox-1",
sandboxName: "paperclip-probe",
templateKind: "snapshot",
templateRef: "snapshot-1",
},
},
leaseContext: {
executionWorkspaceId: null,
@@ -263,6 +271,19 @@ describe("agent test-environment route", () => {
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(testEnvironmentSpy).toHaveBeenCalledTimes(1);
// Test leases boot fresh and stay debuggable: never resume a retained
// agent lease, archive (not delete) the sandbox on release.
expect(mockEnvironmentRuntime.acquireRunLease).toHaveBeenCalledWith(
expect.objectContaining({
applyCustomImageTemplate: true,
environment: expect.objectContaining({
config: expect.objectContaining({
reuseLease: false,
archiveOnRelease: true,
}),
}),
}),
);
expect(testEnvironmentSpy.mock.calls[0]?.[0]).toMatchObject({
executionTarget: expect.objectContaining({
kind: "remote",
@@ -271,6 +292,24 @@ describe("agent test-environment route", () => {
environmentName: "Sandbox QA",
});
expect(res.body).toMatchObject({ adapterType: "external_test", status: "pass" });
expect(res.body.checks).toEqual([
expect.objectContaining({
code: "sandbox_test_identity",
level: "info",
message: 'Sandbox test identity for "Sandbox QA".',
detail: expect.stringContaining("paperclipLeaseId=lease-1"),
}),
expect.objectContaining({
code: "external_test_hello_probe_passed",
level: "info",
message: "OK",
}),
]);
expect(res.body.checks[0].detail).toContain("providerLeaseId=provider-lease-1");
expect(res.body.checks[0].detail).toContain("provider=daytona");
expect(res.body.checks[0].detail).toContain("sandboxId=sandbox-1");
expect(res.body.checks[0].detail).toContain("sandboxName=paperclip-probe");
expect(res.body.checks[0].detail).toContain("snapshotRef=snapshot-1");
expect(mockReleaseRunLease).toHaveBeenCalledWith({
environment: expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }),
lease: expect.objectContaining({ id: "lease-1" }),
@@ -105,6 +105,27 @@ function buildApp() {
res.write(chunk.slice(0, chunk.length / 2));
res.end(chunk.slice(chunk.length / 2));
});
// Mirrors better-call's setResponse (Better Auth sign-in/sign-up): headers
// are committed with writeHead() first, then the web-stream body arrives as
// Uint8Array chunks.
app.get("/api/auth-bridge", (req, res) => {
const body = JSON.stringify({
token: "t".repeat(Number(req.query.pad ?? 0)),
user: { name: "Dotta", email: "dotta@example.test" },
});
res.setHeader("content-type", "application/json");
res.setHeader("set-cookie", "workspace.session_token=abc; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax");
res.writeHead(200);
const bytes = new TextEncoder().encode(body);
res.write(bytes.subarray(0, 16));
res.write(bytes.subarray(16));
res.end();
});
app.get("/api/uint8-json", (req, res) => {
const body = JSON.stringify(issueListFixture(Number(req.query.count ?? 1)));
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(new TextEncoder().encode(body));
});
return app;
}
@@ -184,4 +205,37 @@ describe("API compression middleware", () => {
expect(res.headers["content-encoding"]).toBeUndefined();
expect(JSON.parse(res.body.toString("utf8"))).toHaveLength(500);
});
it("delivers small writeHead+Uint8Array auth responses byte-for-byte", async () => {
const res = await requestRaw(buildApp(), "/api/auth-bridge", {
"accept-encoding": "gzip, deflate",
});
expect(res.statusCode).toBe(200);
expect(res.headers["set-cookie"]).toBeDefined();
expect(res.headers["content-encoding"]).toBeUndefined();
expect(JSON.parse(res.body.toString("utf8")).user.email).toBe("dotta@example.test");
});
it("does not drop the connection for large writeHead+Uint8Array auth responses", async () => {
const res = await requestRaw(buildApp(), "/api/auth-bridge?pad=2000", {
"accept-encoding": "gzip, deflate",
});
expect(res.statusCode).toBe(200);
expect(res.headers["content-encoding"]).toBeUndefined();
const parsed = JSON.parse(res.body.toString("utf8"));
expect(parsed.token).toHaveLength(2000);
expect(parsed.user.email).toBe("dotta@example.test");
});
it("compresses large Uint8Array bodies without corrupting them", async () => {
const res = await requestRaw(buildApp(), "/api/uint8-json?count=500", {
"accept-encoding": "gzip",
});
expect(res.statusCode).toBe(200);
expect(res.headers["content-encoding"]).toBe("gzip");
expect(JSON.parse(gunzipSync(res.body).toString("utf8"))).toHaveLength(500);
});
});
@@ -0,0 +1,961 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
approvals,
assets,
budgetIncidents,
budgetPolicies,
companies,
createDb,
documents,
heartbeatRunEvents,
heartbeatRuns,
inboxDismissals,
invites,
issueApprovals,
issueAttachments,
issueDocuments,
issueRecoveryActions,
issueRelations,
issueThreadInteractions,
issues,
joinRequests,
projects,
projectWorkspaces,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { attentionRoutes } from "../routes/attention.js";
import { attentionService } from "../services/attention.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres attention service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("attention service", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-attention-service-");
db = createDb(tempDb.connectionString);
}, 30_000);
afterEach(async () => {
await db.delete(inboxDismissals);
await db.delete(issueThreadInteractions);
await db.delete(issueApprovals);
await db.delete(issueAttachments);
await db.delete(issueDocuments);
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
await db.delete(budgetIncidents);
await db.delete(budgetPolicies);
await db.delete(joinRequests);
await db.delete(invites);
await db.delete(issueRecoveryActions);
await db.delete(issueRelations);
await db.delete(activityLog);
await db.delete(approvals);
await db.delete(issues);
await db.delete(assets);
await db.delete(documents);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompany(prefix = "ATN") {
const companyId = randomUUID();
const workerId = randomUUID();
const reviewerId = randomUUID();
const errorAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: `${prefix} Co`,
issuePrefix: prefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values([
{
id: workerId,
companyId,
name: "Worker",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: reviewerId,
companyId,
name: "Reviewer",
role: "qa",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: errorAgentId,
companyId,
name: "Broken Agent",
role: "engineer",
status: "error",
errorReason: "adapter config missing",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
return { companyId, workerId, reviewerId, errorAgentId, prefix };
}
async function insertIssue(input: {
companyId: string;
id?: string;
identifier: string;
title: string;
status: string;
priority?: string;
parentId?: string | null;
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
originKind?: string;
originId?: string | null;
originFingerprint?: string;
projectId?: string | null;
projectWorkspaceId?: string | null;
executionState?: Record<string, unknown> | null;
updatedAt?: Date;
createdAt?: Date;
}) {
const id = input.id ?? randomUUID();
await db.insert(issues).values({
id,
companyId: input.companyId,
identifier: input.identifier,
title: input.title,
status: input.status,
priority: input.priority ?? "medium",
parentId: input.parentId ?? null,
projectId: input.projectId ?? null,
projectWorkspaceId: input.projectWorkspaceId ?? null,
assigneeAgentId: input.assigneeAgentId ?? null,
assigneeUserId: input.assigneeUserId ?? null,
originKind: input.originKind ?? "manual",
originId: input.originId ?? null,
originFingerprint: input.originFingerprint ?? "default",
executionState: input.executionState ?? null,
createdAt: input.createdAt,
updatedAt: input.updatedAt,
});
return id;
}
function pendingUserExecutionState(userId = "board-user") {
return {
status: "pending",
currentStageId: null,
currentStageIndex: null,
currentStageType: "review",
currentParticipant: { type: "user", userId },
returnAssignee: null,
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
monitor: null,
};
}
function pendingAgentExecutionState(agentId: string) {
return {
...pendingUserExecutionState(),
currentParticipant: { type: "agent", agentId },
};
}
it("returns ranked decision-only items for every active source and excludes non-human or transient rows", async () => {
const { companyId, workerId, reviewerId } = await seedCompany("ATN");
const baseTime = new Date("2026-07-09T12:00:00.000Z");
const interactionIssueId = await insertIssue({
companyId,
identifier: "ATN-1",
title: "Needs interaction",
status: "in_progress",
assigneeAgentId: workerId,
updatedAt: baseTime,
});
const recoverySourceIssueId = await insertIssue({
companyId,
identifier: "ATN-2",
title: "Needs recovery",
status: "in_progress",
assigneeAgentId: workerId,
updatedAt: baseTime,
});
const agentRecoverySourceIssueId = await insertIssue({
companyId,
identifier: "ATN-21",
title: "Agent-owned recovery source",
status: "in_progress",
assigneeAgentId: workerId,
updatedAt: baseTime,
});
const productivitySourceIssueId = await insertIssue({
companyId,
identifier: "ATN-3",
title: "Needs productivity review source",
status: "in_progress",
assigneeAgentId: workerId,
updatedAt: baseTime,
});
const agentProductivitySourceIssueId = await insertIssue({
companyId,
identifier: "ATN-31",
title: "Agent productivity review source",
status: "in_progress",
assigneeAgentId: workerId,
updatedAt: baseTime,
});
const blockerParentId = await insertIssue({
companyId,
identifier: "ATN-4",
title: "Blocked parent",
status: "blocked",
updatedAt: new Date("2026-07-09T12:04:00.000Z"),
});
const blockerLeafId = await insertIssue({
companyId,
identifier: "ATN-5",
title: "Stalled review blocker",
status: "in_review",
assigneeAgentId: reviewerId,
updatedAt: new Date("2026-07-09T12:05:00.000Z"),
});
await db.insert(issueRelations).values({
companyId,
issueId: blockerLeafId,
relatedIssueId: blockerParentId,
type: "blocks",
});
const reviewUserIssueId = await insertIssue({
companyId,
identifier: "ATN-6",
title: "Human review",
status: "in_review",
executionState: pendingUserExecutionState(),
updatedAt: new Date("2026-07-09T12:06:00.000Z"),
});
await insertIssue({
companyId,
identifier: "ATN-7",
title: "Agent review excluded",
status: "in_review",
executionState: pendingAgentExecutionState(reviewerId),
updatedAt: new Date("2026-07-09T12:07:00.000Z"),
});
const pendingApprovalId = randomUUID();
await db.insert(approvals).values([
{
id: pendingApprovalId,
companyId,
type: "hire_agent",
status: "pending",
payload: { title: "Hire Designer" },
createdAt: new Date("2026-07-09T12:01:00.000Z"),
updatedAt: new Date("2026-07-09T12:01:00.000Z"),
},
{
id: randomUUID(),
companyId,
type: "hire_agent",
status: "revision_requested",
payload: { title: "Revision requested" },
createdAt: new Date("2026-07-09T12:02:00.000Z"),
updatedAt: new Date("2026-07-09T12:02:00.000Z"),
},
]);
await db.insert(issueThreadInteractions).values([
{
id: randomUUID(),
companyId,
issueId: interactionIssueId,
kind: "ask_user_questions",
status: "pending",
continuationPolicy: "wake_assignee",
title: "Pick a launch date",
payload: { version: 1, questions: [] },
createdAt: new Date("2026-07-09T12:03:00.000Z"),
updatedAt: new Date("2026-07-09T12:03:00.000Z"),
},
{
id: randomUUID(),
companyId,
issueId: interactionIssueId,
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee",
title: "Already accepted",
payload: { version: 1, prompt: "Already done" },
createdAt: new Date("2026-07-09T12:03:30.000Z"),
updatedAt: new Date("2026-07-09T12:03:30.000Z"),
},
]);
const inviteId = randomUUID();
await db.insert(invites).values({
id: inviteId,
companyId,
tokenHash: `hash-${inviteId}`,
allowedJoinTypes: "both",
expiresAt: new Date("2026-07-10T00:00:00.000Z"),
});
await db.insert(joinRequests).values({
id: randomUUID(),
inviteId,
companyId,
requestType: "human",
status: "pending_approval",
requestIp: "127.0.0.1",
requestEmailSnapshot: "new@paperclip.test",
createdAt: new Date("2026-07-09T12:04:00.000Z"),
updatedAt: new Date("2026-07-09T12:04:00.000Z"),
});
await db.insert(issueRecoveryActions).values([
{
id: randomUUID(),
companyId,
sourceIssueId: recoverySourceIssueId,
kind: "missing_disposition",
status: "escalated",
ownerType: "board",
ownerAgentId: null,
ownerUserId: null,
cause: "missing_disposition",
fingerprint: "human-recovery",
evidence: {},
nextAction: "Choose the final disposition.",
createdAt: new Date("2026-07-09T12:05:00.000Z"),
updatedAt: new Date("2026-07-09T12:05:00.000Z"),
},
{
id: randomUUID(),
companyId,
sourceIssueId: agentRecoverySourceIssueId,
kind: "stranded_assigned_issue",
status: "active",
ownerType: "agent",
ownerAgentId: workerId,
ownerUserId: null,
cause: "stranded",
fingerprint: "agent-recovery",
evidence: {},
nextAction: "Agent should self-heal.",
createdAt: new Date("2026-07-09T12:05:30.000Z"),
updatedAt: new Date("2026-07-09T12:05:30.000Z"),
},
]);
await insertIssue({
companyId,
identifier: "ATN-8",
title: "Human productivity review",
status: "todo",
priority: "high",
parentId: productivitySourceIssueId,
assigneeUserId: "board-user",
originKind: "issue_productivity_review",
originId: productivitySourceIssueId,
originFingerprint: `productivity-review:${productivitySourceIssueId}`,
updatedAt: new Date("2026-07-09T12:08:00.000Z"),
});
await insertIssue({
companyId,
identifier: "ATN-9",
title: "Agent productivity review excluded",
status: "todo",
priority: "high",
parentId: agentProductivitySourceIssueId,
assigneeAgentId: workerId,
originKind: "issue_productivity_review",
originId: agentProductivitySourceIssueId,
originFingerprint: `productivity-review-agent:${agentProductivitySourceIssueId}`,
updatedAt: new Date("2026-07-09T12:08:30.000Z"),
});
const exhaustedRunId = randomUUID();
const transientRunId = randomUUID();
await db.insert(heartbeatRuns).values([
{
id: exhaustedRunId,
companyId,
agentId: workerId,
invocationSource: "automation",
status: "failed",
error: "adapter failed",
errorCode: "adapter_failed",
contextSnapshot: { issueId: reviewUserIssueId },
scheduledRetryAttempt: 4,
scheduledRetryReason: "transient_failure",
createdAt: new Date("2026-07-09T12:09:00.000Z"),
updatedAt: new Date("2026-07-09T12:09:00.000Z"),
finishedAt: new Date("2026-07-09T12:09:00.000Z"),
},
{
id: transientRunId,
companyId,
agentId: reviewerId,
invocationSource: "automation",
status: "failed",
error: "will retry",
errorCode: "provider_quota",
contextSnapshot: { issueId: interactionIssueId },
createdAt: new Date("2026-07-09T12:09:30.000Z"),
updatedAt: new Date("2026-07-09T12:09:30.000Z"),
finishedAt: new Date("2026-07-09T12:09:30.000Z"),
},
]);
await db.insert(heartbeatRunEvents).values({
companyId,
runId: exhaustedRunId,
agentId: workerId,
seq: 1,
eventType: "lifecycle",
message: "Bounded retry exhausted after 4 scheduled attempts; no further automatic retry will be queued",
payload: { retryReason: "transient_failure", maxAttempts: 4 },
createdAt: new Date("2026-07-09T12:09:01.000Z"),
});
const softPolicy85Id = randomUUID();
const softPolicy84Id = randomUUID();
const hardPolicyId = randomUUID();
await db.insert(budgetPolicies).values([
{
id: softPolicy85Id,
companyId,
scopeType: "company",
scopeId: companyId,
metric: "billed_cents",
windowKind: "calendar_month_utc",
amount: 100,
},
{
id: softPolicy84Id,
companyId,
scopeType: "company",
scopeId: companyId,
metric: "billed_cents",
windowKind: "lifetime",
amount: 100,
},
{
id: hardPolicyId,
companyId,
scopeType: "agent",
scopeId: workerId,
metric: "billed_cents",
windowKind: "calendar_month_utc",
amount: 100,
},
]);
await db.insert(budgetIncidents).values([
{
companyId,
policyId: softPolicy85Id,
scopeType: "company",
scopeId: companyId,
metric: "billed_cents",
windowKind: "calendar_month_utc",
windowStart: new Date("2026-07-01T00:00:00.000Z"),
windowEnd: new Date("2026-08-01T00:00:00.000Z"),
thresholdType: "soft",
amountLimit: 100,
amountObserved: 85,
status: "open",
createdAt: new Date("2026-07-09T12:10:00.000Z"),
updatedAt: new Date("2026-07-09T12:10:00.000Z"),
},
{
companyId,
policyId: softPolicy84Id,
scopeType: "company",
scopeId: companyId,
metric: "billed_cents",
windowKind: "lifetime",
windowStart: new Date("1970-01-01T00:00:00.000Z"),
windowEnd: new Date("9999-01-01T00:00:00.000Z"),
thresholdType: "soft",
amountLimit: 100,
amountObserved: 84,
status: "open",
createdAt: new Date("2026-07-09T12:10:30.000Z"),
updatedAt: new Date("2026-07-09T12:10:30.000Z"),
},
{
companyId,
policyId: hardPolicyId,
scopeType: "agent",
scopeId: workerId,
metric: "billed_cents",
windowKind: "calendar_month_utc",
windowStart: new Date("2026-07-01T00:00:00.000Z"),
windowEnd: new Date("2026-08-01T00:00:00.000Z"),
thresholdType: "hard",
amountLimit: 100,
amountObserved: 100,
status: "open",
createdAt: new Date("2026-07-09T12:11:00.000Z"),
updatedAt: new Date("2026-07-09T12:11:00.000Z"),
},
]);
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
expect(feed.totalCount).toBe(11);
expect(feed.countsBySourceKind).toMatchObject({
approval: 1,
issue_thread_interaction: 1,
join_request: 1,
recovery_action: 1,
productivity_review: 1,
blocker_attention: 1,
review: 1,
failed_run: 1,
budget_alert: 2,
agent_error_alert: 1,
});
expect(feed.items.map((item) => item.sourceKind)).toEqual(expect.arrayContaining([
"approval",
"issue_thread_interaction",
"join_request",
"recovery_action",
"productivity_review",
"blocker_attention",
"review",
"failed_run",
"budget_alert",
"agent_error_alert",
]));
for (const item of feed.items) {
expect(item.dedupKey).toBeTruthy();
expect(item.dismissalKey).toBe(`attention:${item.dedupKey}`);
expect(item.whyNow).toBeTruthy();
expect(item.entryRule).toBeTruthy();
expect(item.exitRule).toBeTruthy();
expect(item.decisionVerbs.length).toBeGreaterThan(0);
expect(item.rank).toBeGreaterThan(0);
}
expect(feed.items.some((item) => item.subject.title === "Revision requested")).toBe(false);
expect(feed.items.some((item) => item.subject.title === "Agent productivity review excluded")).toBe(false);
expect(feed.items.some((item) => item.subject.title === "Agent review excluded")).toBe(false);
expect(feed.items.some((item) =>
item.sourceKind === "failed_run" && item.subject.metadata?.errorCode === "provider_quota"
)).toBe(false);
expect(feed.items.find((item) => item.sourceKind === "approval")?.detail).toMatchObject({
kind: "approval",
approvalType: "hire_agent",
summaryExcerpt: "Hire Designer",
});
expect(feed.items.find((item) => item.sourceKind === "issue_thread_interaction")?.detail).toMatchObject({
kind: "questions",
questionCount: 0,
});
expect(feed.items.find((item) => item.sourceKind === "blocker_attention")?.detail).toMatchObject({
kind: "blocker",
blockingIssue: { identifier: "ATN-5", title: "Stalled review blocker" },
});
expect(feed.items.find((item) => item.sourceKind === "failed_run")?.detail).toMatchObject({
kind: "failed_run",
agentName: "Worker",
failureReasonExcerpt: "adapter failed",
});
expect(feed.items.find((item) =>
item.sourceKind === "budget_alert" && item.detail?.kind === "budget" && item.detail.observedPercent === 100
)).toBeTruthy();
expect(feed.items.find((item) => item.sourceKind === "agent_error_alert")?.detail).toMatchObject({
kind: "agent_error",
agentName: "Broken Agent",
failureReasonExcerpt: "adapter config missing",
});
});
it("suppresses failed-run attention after a newer run for the same issue", async () => {
const { companyId, workerId } = await seedCompany("ATN");
const issueId = await insertIssue({
companyId,
identifier: "ATN-1",
title: "Recoverable task",
status: "in_progress",
});
const failedRunId = randomUUID();
const failedAt = new Date("2026-07-09T12:00:00.000Z");
await db.insert(heartbeatRuns).values([
{
id: failedRunId,
companyId,
agentId: workerId,
invocationSource: "automation",
status: "failed",
error: "adapter failed",
contextSnapshot: { issueId },
createdAt: failedAt,
updatedAt: failedAt,
finishedAt: failedAt,
},
{
id: randomUUID(),
companyId,
agentId: workerId,
invocationSource: "automation",
status: "succeeded",
contextSnapshot: { issueId },
createdAt: new Date("2026-07-09T12:01:00.000Z"),
updatedAt: new Date("2026-07-09T12:01:00.000Z"),
finishedAt: new Date("2026-07-09T12:01:00.000Z"),
},
]);
await db.insert(heartbeatRunEvents).values({
companyId,
runId: failedRunId,
agentId: workerId,
seq: 1,
eventType: "lifecycle",
message: "Bounded retry exhausted after 4 scheduled attempts; no further automatic retry will be queued",
createdAt: new Date("2026-07-09T12:00:01.000Z"),
});
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
expect(feed.items.filter((item) => item.sourceKind === "failed_run")).toEqual([]);
});
it("enriches interaction details with project, workspace, plan metadata, and images", async () => {
const { companyId, workerId } = await seedCompany("ATE");
const projectId = randomUUID();
const workspaceId = randomUUID();
const issueId = randomUUID();
const planDocumentId = randomUUID();
const planRevisionId = randomUUID();
const imageAssetIds = [randomUUID(), randomUUID(), randomUUID(), randomUUID()];
await db.insert(projects).values({
id: projectId,
companyId,
name: "Attention Project",
status: "in_progress",
color: "#0f766e",
icon: "rocket",
});
await db.insert(projectWorkspaces).values({
id: workspaceId,
companyId,
projectId,
name: "Preview workspace",
sourceType: "local_path",
isPrimary: true,
});
await insertIssue({
id: issueId,
companyId,
identifier: "ATE-1",
title: "Approve launch plan",
status: "in_progress",
assigneeAgentId: workerId,
projectId,
projectWorkspaceId: workspaceId,
updatedAt: new Date("2026-07-09T12:00:00.000Z"),
});
await db.insert(documents).values({
id: planDocumentId,
companyId,
title: "Launch Plan",
format: "markdown",
latestBody: "# Summary\n\nThis plan explains the launch checklist, rollout owner, QA gates, and risk controls for the homepage release.",
latestRevisionId: planRevisionId,
latestRevisionNumber: 2,
});
await db.insert(issueDocuments).values({
companyId,
issueId,
documentId: planDocumentId,
key: "plan",
});
await db.insert(assets).values([
{ id: imageAssetIds[0], companyId, provider: "local_disk", objectKey: "img-1", contentType: "image/png", byteSize: 10, sha256: "a".repeat(64), originalFilename: "one.png" },
{ id: imageAssetIds[1], companyId, provider: "local_disk", objectKey: "img-2", contentType: "image/jpeg", byteSize: 10, sha256: "b".repeat(64), originalFilename: "two.jpg" },
{ id: imageAssetIds[2], companyId, provider: "local_disk", objectKey: "img-3", contentType: "image/gif", byteSize: 10, sha256: "c".repeat(64), originalFilename: "three.gif" },
{ id: imageAssetIds[3], companyId, provider: "local_disk", objectKey: "img-4", contentType: "image/png", byteSize: 10, sha256: "d".repeat(64), originalFilename: "four.png" },
]);
await db.insert(issueAttachments).values(imageAssetIds.map((assetId, index) => ({
companyId,
issueId,
assetId,
createdAt: new Date(`2026-07-09T12:0${index}:30.000Z`),
updatedAt: new Date(`2026-07-09T12:0${index}:30.000Z`),
})));
const planInteractionId = randomUUID();
const questionsInteractionId = randomUUID();
const tasksInteractionId = randomUUID();
const checkboxInteractionId = randomUUID();
const verdictInteractionId = randomUUID();
await db.insert(issueThreadInteractions).values([
{
id: planInteractionId,
companyId,
issueId,
kind: "request_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
title: "Approve the plan",
payload: {
version: 1,
prompt: "Approve plan?",
acceptLabel: "Approve plan",
rejectLabel: "Request changes",
target: { type: "issue_document", issueId, key: "plan", revisionId: planRevisionId },
},
createdAt: new Date("2026-07-09T12:01:00.000Z"),
updatedAt: new Date("2026-07-09T12:01:00.000Z"),
},
{
id: questionsInteractionId,
companyId,
issueId,
kind: "ask_user_questions",
status: "pending",
continuationPolicy: "wake_assignee",
title: "Questions",
payload: {
version: 1,
questions: [
{ id: "q1", prompt: "Which auth provider should we use?", selectionMode: "single", options: [] },
{ id: "q2", prompt: "Should we add a fallback?", selectionMode: "single", options: [] },
],
},
createdAt: new Date("2026-07-09T12:02:00.000Z"),
updatedAt: new Date("2026-07-09T12:02:00.000Z"),
},
{
id: tasksInteractionId,
companyId,
issueId,
kind: "suggest_tasks",
status: "pending",
continuationPolicy: "wake_assignee",
title: "Tasks",
payload: { version: 1, tasks: [{ clientKey: "t1", title: "Build API" }, { clientKey: "t2", title: "Wire UI" }] },
createdAt: new Date("2026-07-09T12:03:00.000Z"),
updatedAt: new Date("2026-07-09T12:03:00.000Z"),
},
{
id: checkboxInteractionId,
companyId,
issueId,
kind: "request_checkbox_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
title: "Checkbox",
payload: { version: 1, prompt: "Select rollout regions", options: [{ id: "us", label: "US" }, { id: "eu", label: "EU" }] },
createdAt: new Date("2026-07-09T12:04:00.000Z"),
updatedAt: new Date("2026-07-09T12:04:00.000Z"),
},
{
id: verdictInteractionId,
companyId,
issueId,
kind: "request_item_verdicts",
status: "pending",
continuationPolicy: "wake_assignee",
title: "Verdicts",
payload: { version: 1, prompt: "Approve these screenshots", items: [{ id: "one", label: "One" }, { id: "two", label: "Two" }] },
createdAt: new Date("2026-07-09T12:05:00.000Z"),
updatedAt: new Date("2026-07-09T12:05:00.000Z"),
},
]);
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
const interactionItems = feed.items.filter((item) => item.sourceKind === "issue_thread_interaction");
const detailsByKind = new Map(interactionItems.map((item) => [item.detail?.kind, item]));
const planItem = detailsByKind.get("plan_approval");
expect(planItem?.subject.title).toBe("Plan approval - Approve launch plan");
expect(planItem?.subject.metadata).toMatchObject({ isPlanTarget: true, targetDocumentKey: "plan" });
expect(planItem?.decisionVerbs).toEqual([
expect.objectContaining({ id: "accept", label: "Approve plan" }),
expect.objectContaining({ id: "reject", label: "Request changes" }),
]);
expect(planItem?.project).toMatchObject({
id: projectId,
name: "Attention Project",
color: "#0f766e",
icon: "rocket",
});
expect(planItem?.project?.urlKey).toEqual(expect.any(String));
expect(planItem?.workspace).toEqual({ id: workspaceId, name: "Preview workspace" });
expect(planItem?.detail).toMatchObject({
kind: "plan_approval",
issueTitle: "Approve launch plan",
planTitle: "Launch Plan",
summaryExcerpt: expect.stringContaining("launch checklist"),
images: imageAssetIds.slice(0, 3).map((assetId) => ({ assetId, alt: expect.any(String) })),
});
expect(detailsByKind.get("questions")?.detail).toMatchObject({
kind: "questions",
questionCount: 2,
firstQuestionText: "Which auth provider should we use?",
});
expect(detailsByKind.get("suggested_tasks")?.detail).toMatchObject({
kind: "suggested_tasks",
taskCount: 2,
firstTaskTitle: "Build API",
});
expect(detailsByKind.get("checkbox_confirmation")?.detail).toMatchObject({
kind: "checkbox_confirmation",
optionCount: 2,
promptExcerpt: "Select rollout regions",
});
expect(detailsByKind.get("item_verdicts")?.detail).toMatchObject({
kind: "item_verdicts",
itemCount: 2,
promptExcerpt: "Approve these screenshots",
});
});
it("uses inbox_dismissals with attention-prefixed dedup keys and resurfaces newer activity", async () => {
const { companyId } = await seedCompany("ATD");
const approvalId = randomUUID();
await db.insert(approvals).values({
id: approvalId,
companyId,
type: "hire_agent",
status: "pending",
payload: { title: "Hire Writer" },
createdAt: new Date("2026-07-09T12:00:00.000Z"),
updatedAt: new Date("2026-07-09T12:00:00.000Z"),
});
await db.insert(inboxDismissals).values({
companyId,
userId: "board-user",
itemKey: `attention:approval:${approvalId}`,
dismissedAt: new Date("2026-07-09T13:00:00.000Z"),
});
await expect(attentionService(db).list(companyId, { userId: "board-user" }))
.resolves.toMatchObject({ totalCount: 1 }); // agent_error_alert from seed
const includeDismissedFeed = await attentionService(db).list(companyId, { userId: "board-user", includeDismissed: true });
expect(includeDismissedFeed.totalCount).toBe(2);
expect(includeDismissedFeed.items.find((item) => item.dedupKey === `approval:${approvalId}`)?.dismissal)
.toMatchObject({ kind: "dismiss", isActive: true, snoozedUntil: null });
await db
.update(approvals)
.set({ updatedAt: new Date("2026-07-09T14:00:00.000Z") })
.where(eq(approvals.id, approvalId));
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
expect(feed.items.some((item) => item.dedupKey === `approval:${approvalId}`)).toBe(true);
});
it("hides snoozed attention rows until snoozedUntil passes, then returns them unconditionally", async () => {
const { companyId } = await seedCompany("ATS");
const approvalId = randomUUID();
await db.insert(approvals).values({
id: approvalId,
companyId,
type: "hire_agent",
status: "pending",
payload: { title: "Hire Researcher" },
createdAt: new Date("2026-07-09T12:00:00.000Z"),
updatedAt: new Date("2026-07-09T12:00:00.000Z"),
});
await db.insert(inboxDismissals).values({
companyId,
userId: "board-user",
itemKey: `attention:approval:${approvalId}`,
kind: "snooze",
dismissedAt: new Date("2099-01-01T00:00:00.000Z"),
snoozedUntil: new Date("2099-01-02T00:00:00.000Z"),
});
await expect(attentionService(db).list(companyId, { userId: "board-user" }))
.resolves.toMatchObject({ totalCount: 1 }); // agent_error_alert from seed
const hiddenFeed = await attentionService(db).list(companyId, { userId: "board-user", includeDismissed: true });
expect(hiddenFeed.items.find((item) => item.dedupKey === `approval:${approvalId}`)?.dismissal)
.toMatchObject({ kind: "snooze", isActive: true, snoozedUntil: "2099-01-02T00:00:00.000Z" });
await db
.update(inboxDismissals)
.set({ snoozedUntil: new Date("2020-01-01T00:00:00.000Z") })
.where(eq(inboxDismissals.itemKey, `attention:approval:${approvalId}`));
const visibleFeed = await attentionService(db).list(companyId, { userId: "board-user" });
const visibleApproval = visibleFeed.items.find((item) => item.dedupKey === `approval:${approvalId}`);
expect(visibleApproval?.dismissal).toMatchObject({ kind: "snooze", isActive: false });
expect(visibleApproval).toBeTruthy();
});
it("serves the route for board users and rejects agent callers", async () => {
const { companyId } = await seedCompany("ATR");
function app(actor: Record<string, unknown>) {
const testApp = express();
testApp.use(express.json());
testApp.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
testApp.use("/api", attentionRoutes(db));
testApp.use(errorHandler);
return testApp;
}
const board = {
type: "board",
source: "local_implicit",
userId: "board-user",
companyIds: [companyId],
isInstanceAdmin: false,
};
const agent = {
type: "agent",
source: "agent_key",
companyId,
agentId: randomUUID(),
runId: null,
};
await request(app(board)).get(`/api/companies/${companyId}/attention`).expect(200);
await request(app(agent)).get(`/api/companies/${companyId}/attention`).expect(403);
});
});
+54 -1
View File
@@ -1,5 +1,8 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import {
activityLog,
@@ -32,6 +35,7 @@ import {
builtInAgentService,
deriveBuiltInAgentStatus,
listBuiltInAgentDefinitions,
readBuiltInTextWithFallback,
reconcileBuiltInAgentsOnStartup,
validateBuiltInAgentDefinitions,
} from "../services/built-in-agents.ts";
@@ -51,6 +55,55 @@ if (!embeddedPostgresSupport.supported) {
);
}
describe("built-in agent asset loading", () => {
it("uses the first readable candidate path", () => {
const dir = mkdtempSync(path.join(tmpdir(), "paperclip-built-in-agent-"));
try {
const first = path.join(dir, "missing.md");
const second = path.join(dir, "asset.md");
writeFileSync(second, "asset text", "utf8");
expect(readBuiltInTextWithFallback(`asset:${randomUUID()}`, [first, second], "fallback text")).toBe("asset text");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("falls back instead of throwing when built-in agent files are missing", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const label = `missing:${randomUUID()}`;
try {
expect(readBuiltInTextWithFallback(label, [path.join(tmpdir(), label, "AGENTS.md")], "fallback text")).toBe(
"fallback text",
);
expect(warn).toHaveBeenCalledWith(expect.stringContaining(`Built-in agent asset ${label} was not readable`));
} finally {
warn.mockRestore();
}
});
it("warns about non-missing read errors before falling back", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const dir = mkdtempSync(path.join(tmpdir(), "paperclip-built-in-agent-"));
const label = "unreadable:" + randomUUID();
try {
const directoryPath = path.join(dir, "asset.md");
mkdirSync(directoryPath);
expect(readBuiltInTextWithFallback(label, [directoryPath], "fallback text")).toBe("fallback text");
expect(warn).toHaveBeenCalledWith(expect.stringContaining("read error on " + directoryPath + ":"));
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("Built-in agent asset " + label + " was not readable"),
);
} finally {
warn.mockRestore();
rmSync(dir, { recursive: true, force: true });
}
});
});
describeEmbeddedPostgres("built-in agents", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
@@ -14,6 +14,8 @@ import {
environmentCustomImageService,
} from "../services/environment-custom-images.js";
import {
ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
environmentCustomImageTemplateMatchesBaseConfig,
fingerprintEnvironmentSandboxProviderConfig,
} from "../services/environment-custom-image-runtime.js";
import {
@@ -55,6 +57,7 @@ function pluginManifest() {
field: "customTemplate",
unsetFields: ["image"],
},
templateIdentityPaths: ["apiUrl"],
supportsTemplateDelete: true,
configSchema: { type: "object" },
},
@@ -436,7 +439,9 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
provider: "fake-plugin",
templateKind: "snapshot",
templateRef: "snapshot-active",
sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(environment.config as any),
sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(environment.config as any, {
excludePaths: ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
}),
status: "active",
});
const resolved = await resolveEnvironmentDriverConfigForRuntime(db, companyId, {
@@ -448,20 +453,54 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
expect(resolved.config).toMatchObject({ snapshot: "snapshot-active" });
expect(resolved.config).not.toHaveProperty("image");
// A stored fingerprint that no longer matches the current base config (e.g. the
// config dialog re-saved the environment, or the user tweaked resource/lease
// knobs) must NOT silently discard the captured template.
await db.update(environmentCustomImageTemplates)
.set({ sourceEnvironmentConfigFingerprint: "stale" })
.where(eq(environmentCustomImageTemplates.templateRef, "snapshot-active"));
const afterConfigChange = await resolveEnvironmentDriverConfigForRuntime(db, companyId, {
id: environment.id,
// Runtime-only resource/lease edits must NOT silently discard the captured
// template.
await db.update(environments)
.set({
config: {
...(environment.config as Record<string, unknown>),
cpu: 4,
timeoutMs: 600000,
reuseLease: true,
},
})
.where(eq(environments.id, environment.id));
const afterResourceChangeEnvironment = await db.select().from(environments).where(eq(environments.id, environmentId)).then((rows) => rows[0]!);
const afterResourceChange = await resolveEnvironmentDriverConfigForRuntime(db, companyId, {
id: afterResourceChangeEnvironment.id,
driver: "sandbox",
config: environment.config,
config: afterResourceChangeEnvironment.config,
}, { heartbeatRunId: randomUUID() });
expect(afterConfigChange.driver).toBe("sandbox");
expect(afterConfigChange.config).toMatchObject({ snapshot: "snapshot-active" });
expect(afterConfigChange.config).not.toHaveProperty("image");
expect(afterResourceChange.driver).toBe("sandbox");
expect(afterResourceChange.config).toMatchObject({ snapshot: "snapshot-active" });
expect(afterResourceChange.config).not.toHaveProperty("image");
// Changing the base image is a meaningful source-template change. In that
// case, the old capture must not mask the newly saved image.
await db.update(environmentCustomImageTemplates)
.set({
sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(environment.config as any, {
excludePaths: ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
}),
})
.where(eq(environmentCustomImageTemplates.templateRef, "snapshot-active"));
await db.update(environments)
.set({
config: {
...(environment.config as Record<string, unknown>),
image: "fake:new-base",
},
})
.where(eq(environments.id, environment.id));
const afterImageChangeEnvironment = await db.select().from(environments).where(eq(environments.id, environmentId)).then((rows) => rows[0]!);
const afterImageChange = await resolveEnvironmentDriverConfigForRuntime(db, companyId, {
id: afterImageChangeEnvironment.id,
driver: "sandbox",
config: afterImageChangeEnvironment.config,
}, { heartbeatRunId: randomUUID() });
expect(afterImageChange.driver).toBe("sandbox");
expect(afterImageChange.config).toMatchObject({ image: "fake:new-base" });
expect(afterImageChange.config).not.toHaveProperty("snapshot");
});
it("applies the active template for ad-hoc Test probes only when applyCustomImageTemplate is set", async () => {
@@ -562,3 +601,268 @@ describe("fingerprintEnvironmentSandboxProviderConfig", () => {
);
});
});
describe("environmentCustomImageTemplateMatchesBaseConfig", () => {
it("keeps captures across runtime-only edits but not base image changes", () => {
const baseConfig = {
provider: "daytona",
image: "daytonaio/sandbox:0.8.0",
timeoutMs: 300000,
reuseLease: false,
} as any;
const template = {
id: "template-1",
environmentId: "env-1",
provider: "daytona",
templateKind: "snapshot",
templateRef: "snapshot-active",
sourceTemplateRef: "daytonaio/sandbox:0.8.0",
sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(baseConfig, {
excludePaths: ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
}),
status: "active",
createdByUserId: null,
createdByAgentId: null,
capturedAt: null,
lastUsedAt: null,
supersededByTemplateId: null,
metadata: null,
createdAt: new Date("2026-07-09T00:00:00.000Z"),
updatedAt: new Date("2026-07-09T00:00:00.000Z"),
} as const;
expect(environmentCustomImageTemplateMatchesBaseConfig({
template,
baseConfig: {
...baseConfig,
timeoutMs: 600000,
reuseLease: true,
cpu: 4,
},
})).toBe(true);
expect(environmentCustomImageTemplateMatchesBaseConfig({
template,
baseConfig: {
...baseConfig,
image: "daytonaio/sandbox:0.9.0",
},
})).toBe(false);
});
it("matches configs carrying a secret-ref credential when the capture excluded that path", () => {
// Capture-time fingerprints exclude the provider's secret-ref paths (e.g.
// daytona apiKey). The runtime re-check must exclude the same paths or a
// config with any credential never matches and the template is dropped.
const baseConfig = {
provider: "daytona",
image: "daytonaio/sandbox:0.8.0",
apiKey: "raw-api-key-value",
} as any;
const template = {
id: "template-1",
environmentId: "env-1",
provider: "daytona",
templateKind: "snapshot",
templateRef: "snapshot-active",
sourceTemplateRef: "daytonaio/sandbox:0.8.0",
sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(baseConfig, {
excludePaths: [
...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
"apiKey",
],
}),
status: "active",
createdByUserId: null,
createdByAgentId: null,
capturedAt: null,
lastUsedAt: null,
supersededByTemplateId: null,
metadata: null,
createdAt: new Date("2026-07-09T00:00:00.000Z"),
updatedAt: new Date("2026-07-09T00:00:00.000Z"),
} as const;
expect(environmentCustomImageTemplateMatchesBaseConfig({
template,
baseConfig,
secretRefExcludePaths: ["apiKey"],
})).toBe(true);
// A rotated credential still matches — credentials are not part of the
// captured image identity.
expect(environmentCustomImageTemplateMatchesBaseConfig({
template,
baseConfig: { ...baseConfig, apiKey: "rotated-key" },
secretRefExcludePaths: ["apiKey"],
})).toBe(true);
// Without the exclusion the same config fails to match (the pre-fix bug).
expect(environmentCustomImageTemplateMatchesBaseConfig({
template,
baseConfig,
})).toBe(false);
});
});
describeEmbeddedPostgres("environmentCustomImageService reconciliation", () => {
let stopDb: (() => Promise<void>) | null = null;
let db!: ReturnType<typeof createDb>;
beforeAll(async () => {
const started = await startEmbeddedPostgresTestDatabase("environment-custom-images-reconcile");
stopDb = started.stop;
db = createDb(started.connectionString);
});
afterEach(async () => {
await db.delete(environmentCustomImageSetupSessions);
await db.delete(environmentCustomImageTemplates);
await db.delete(plugins);
await db.delete(environments);
await db.delete(companies);
});
afterAll(async () => {
await stopDb?.();
});
async function seed() {
const companyId = randomUUID();
const environmentId = randomUUID();
await db.insert(companies).values(
{ id: companyId, name: "Acme", issuePrefix: `A${companyId.slice(0, 4)}` },
);
await db.insert(environments).values({
id: environmentId,
name: `Fake ${environmentId.slice(0, 8)}`,
driver: "sandbox",
status: "active",
config: {
provider: "fake-plugin",
image: "fake:base",
reuseLease: false,
},
envVars: {},
});
await db.insert(plugins).values({
pluginKey: "paperclip.fake-sandbox-provider",
packageName: "paperclip-plugin-fake-sandbox",
version: "0.1.0",
apiVersion: 1,
categories: ["automation"],
manifestJson: pluginManifest(),
status: "ready",
});
return { companyId, environmentId };
}
it("re-links the active template on save when only non-identity fields change", async () => {
const { companyId, environmentId } = await seed();
const workerManager = createWorkerManager();
const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager });
const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } });
const promoted = await service.finishSetupSession({ sessionId: started.session.id });
const baseConfig = { provider: "fake-plugin", image: "fake:base", reuseLease: false };
const nextConfig = { ...baseConfig, region: "eu-west" };
const relinked = await service.reconcileActiveTemplateForConfigChange({
environmentId,
previous: { driver: "sandbox", config: baseConfig },
next: { driver: "sandbox", config: nextConfig },
});
expect(relinked.action).toBe("relinked");
if (relinked.action !== "relinked") throw new Error("expected relink");
expect(relinked.template.sourceEnvironmentConfigFingerprint)
.not.toBe(promoted.template.sourceEnvironmentConfigFingerprint);
// Re-running with the same configs is a no-op: the template already
// matches the new config.
const repeat = await service.reconcileActiveTemplateForConfigChange({
environmentId,
previous: { driver: "sandbox", config: baseConfig },
next: { driver: "sandbox", config: nextConfig },
});
expect(repeat.action).toBe("none");
// The captured template keeps applying at runtime under the new config.
await db.update(environments)
.set({ config: nextConfig })
.where(eq(environments.id, environmentId));
const resolved = await resolveEnvironmentDriverConfigForRuntime(db, companyId, {
id: environmentId,
driver: "sandbox",
config: nextConfig,
}, { heartbeatRunId: randomUUID() });
expect(resolved.driver).toBe("sandbox");
expect(resolved.config).toMatchObject({ customTemplate: promoted.template.templateRef });
expect(resolved.config).not.toHaveProperty("image");
});
it("reports detached on save when a boot-source or provider identity field changes", async () => {
const { environmentId } = await seed();
const workerManager = createWorkerManager();
const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager });
const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } });
const promoted = await service.finishSetupSession({ sessionId: started.session.id });
const baseConfig = { provider: "fake-plugin", image: "fake:base", reuseLease: false };
// Base image change: the user asked for a different base, so the capture
// cannot be re-linked.
const imageChange = await service.reconcileActiveTemplateForConfigChange({
environmentId,
previous: { driver: "sandbox", config: baseConfig },
next: { driver: "sandbox", config: { ...baseConfig, image: "fake:other" } },
});
expect(imageChange.action).toBe("detached");
// Provider-declared identity path change (apiUrl): captured templates do
// not exist on a different provider endpoint.
const endpointChange = await service.reconcileActiveTemplateForConfigChange({
environmentId,
previous: { driver: "sandbox", config: baseConfig },
next: { driver: "sandbox", config: { ...baseConfig, apiUrl: "https://other.example" } },
});
expect(endpointChange.action).toBe("detached");
// Binding-field change counts as a boot-source change too.
const bindingChange = await service.reconcileActiveTemplateForConfigChange({
environmentId,
previous: { driver: "sandbox", config: baseConfig },
next: { driver: "sandbox", config: { ...baseConfig, customTemplate: "someone-elses-snapshot" } },
});
expect(bindingChange.action).toBe("detached");
// Detach never mutates the stored fingerprint; rollback/disable stay
// available and the old config still matches.
const template = await service.getActiveTemplate({ environmentId, provider: "fake-plugin" });
expect(template?.sourceEnvironmentConfigFingerprint)
.toBe(promoted.template.sourceEnvironmentConfigFingerprint);
// A template that was already detached before the save is left alone.
const alreadyDetached = await service.reconcileActiveTemplateForConfigChange({
environmentId,
previous: { driver: "sandbox", config: { ...baseConfig, image: "fake:unrelated" } },
next: { driver: "sandbox", config: { ...baseConfig, image: "fake:unrelated", region: "eu" } },
});
expect(alreadyDetached.action).toBe("none");
});
it("reports whether the active template matches the saved config in the overview", async () => {
const { environmentId } = await seed();
const workerManager = createWorkerManager();
const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager });
const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } });
await service.finishSetupSession({ sessionId: started.session.id });
const inSync = await service.getOverview({ environmentId });
expect(inSync.activeTemplateMatchesConfig).toBe(true);
await db.update(environments)
.set({ config: { provider: "fake-plugin", image: "fake:other", reuseLease: false } })
.where(eq(environments.id, environmentId));
const outOfSync = await service.getOverview({ environmentId });
expect(outOfSync.activeTemplate).not.toBeNull();
expect(outOfSync.activeTemplateMatchesConfig).toBe(false);
});
});
@@ -133,6 +133,49 @@ describe("resolveEnvironmentExecutionTarget", () => {
});
});
it("keeps sandbox targets on callback bridge execution even when lease metadata advertises SSH access", async () => {
mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
driver: "sandbox",
config: {
provider: "fake-plugin",
reuseLease: false,
timeoutMs: 30_000,
},
});
const target = await resolveEnvironmentExecutionTarget({
db: {} as never,
companyId: "company-1",
adapterType: "claude_local",
environment: {
id: "env-1",
driver: "sandbox",
config: {
provider: "fake-plugin",
},
},
leaseId: "lease-1",
leaseMetadata: {
remoteCwd: "/home/sandbox/paperclip-workspace",
sshAccess: {
type: "ssh",
host: "ssh.example.test",
port: 22,
username: "paperclip",
},
},
lease: null,
environmentRuntime: null,
});
expect(target).toMatchObject({
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd: "/home/sandbox/paperclip-workspace",
});
});
it("resolves SSH execution targets in bridge mode", async () => {
mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
driver: "ssh",
@@ -4,6 +4,14 @@ const mockEnsureSshWorkspaceReady = vi.hoisted(() => vi.fn());
const mockProbePluginEnvironmentDriver = vi.hoisted(() => vi.fn());
const mockProbePluginSandboxProviderDriver = vi.hoisted(() => vi.fn());
const mockResolvePluginSandboxProviderDriverByKey = vi.hoisted(() => vi.fn());
const mockRuntimeAcquireRunLease = vi.hoisted(() => vi.fn());
const mockRuntimeReleaseRunLease = vi.hoisted(() => vi.fn());
const mockEnvironmentRuntimeService = vi.hoisted(() => vi.fn(() => ({
acquireRunLease: mockRuntimeAcquireRunLease,
getDriver: vi.fn(() => ({
releaseRunLease: mockRuntimeReleaseRunLease,
})),
})));
vi.mock("@paperclipai/adapter-utils/ssh", () => ({
ensureSshWorkspaceReady: mockEnsureSshWorkspaceReady,
@@ -15,6 +23,10 @@ vi.mock("../services/plugin-environment-driver.js", () => ({
resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey,
}));
vi.mock("../services/environment-runtime.js", () => ({
environmentRuntimeService: mockEnvironmentRuntimeService,
}));
import { probeEnvironment } from "../services/environment-probe.ts";
describe("probeEnvironment", () => {
@@ -24,6 +36,9 @@ describe("probeEnvironment", () => {
mockProbePluginSandboxProviderDriver.mockReset();
mockResolvePluginSandboxProviderDriverByKey.mockReset();
mockResolvePluginSandboxProviderDriverByKey.mockResolvedValue(null);
mockRuntimeAcquireRunLease.mockReset();
mockRuntimeReleaseRunLease.mockReset();
mockEnvironmentRuntimeService.mockClear();
});
it("reports local environments as immediately available", async () => {
@@ -163,6 +178,91 @@ describe("probeEnvironment", () => {
});
});
it("boots a fresh runtime lease for saved sandbox probes when requested", async () => {
mockRuntimeAcquireRunLease.mockResolvedValue({
environment: {},
leaseContext: {
executionWorkspaceId: null,
executionWorkspaceMode: null,
},
lease: {
id: "lease-1",
companyId: "company-1",
environmentId: "env-sandbox-plugin",
executionWorkspaceId: null,
issueId: null,
heartbeatRunId: null,
status: "active",
leasePolicy: "ephemeral",
provider: "daytona",
providerLeaseId: "sandbox-runtime-1",
acquiredAt: new Date(),
lastUsedAt: new Date(),
expiresAt: null,
releasedAt: null,
failureReason: null,
cleanupStatus: "pending",
metadata: {
provider: "daytona",
sandboxName: "paperclip-probe",
reuseLease: false,
},
createdAt: new Date(),
updatedAt: new Date(),
},
});
const environment = {
id: "env-sandbox-plugin",
companyId: "company-1",
name: "Daytona",
description: null,
driver: "sandbox" as const,
status: "active" as const,
config: {
provider: "daytona",
image: "daytonaio/sandbox:0.8.0",
reuseLease: true,
},
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await probeEnvironment({} as any, environment, {
companyId: "company-1",
pluginWorkerManager: {} as any,
applyCustomImageTemplate: true,
acquireSandboxRuntimeLease: true,
});
expect(result).toMatchObject({
ok: true,
driver: "sandbox",
summary: "Connected to daytona sandbox paperclip-probe.",
});
expect(mockProbePluginSandboxProviderDriver).not.toHaveBeenCalled();
expect(mockRuntimeAcquireRunLease).toHaveBeenCalledWith(expect.objectContaining({
companyId: "company-1",
issueId: null,
agentId: null,
heartbeatRunId: null,
persistedExecutionWorkspace: null,
adapterType: null,
applyCustomImageTemplate: true,
}));
expect(mockRuntimeAcquireRunLease.mock.calls[0]?.[0].environment.config).toMatchObject({
provider: "daytona",
image: "daytonaio/sandbox:0.8.0",
reuseLease: false,
archiveOnRelease: true,
});
expect(mockRuntimeReleaseRunLease).toHaveBeenCalledWith(expect.objectContaining({
lease: expect.objectContaining({ id: "lease-1" }),
status: "released",
}));
});
it("routes plugin environment probes through the plugin worker host", async () => {
mockProbePluginEnvironmentDriver.mockResolvedValue({
ok: true,
@@ -1597,6 +1597,7 @@ describe("environment routes", () => {
companyId: null,
pluginWorkerManager: undefined,
applyCustomImageTemplate: false,
acquireSandboxRuntimeLease: false,
});
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
@@ -1693,9 +1694,10 @@ describe("environment routes", () => {
expect(res.status).toBe(200);
expect(res.body.driver).toBe("sandbox");
expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, {
companyId: null,
companyId: "company-1",
pluginWorkerManager: undefined,
applyCustomImageTemplate: true,
acquireSandboxRuntimeLease: true,
});
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
@@ -1748,9 +1750,10 @@ describe("environment routes", () => {
expect(res.status).toBe(200);
expect(res.body.driver).toBe("sandbox");
expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, {
companyId: null,
companyId: "company-1",
pluginWorkerManager: undefined,
applyCustomImageTemplate: true,
acquireSandboxRuntimeLease: true,
});
});
@@ -1249,4 +1249,14 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
.where(eq(issueRelations.relatedIssueId, blockedIssueId));
expect(blockers.some((row) => row.blockerIssueId === escalations[0]!.id)).toBe(false);
});
it("handles an armed cutoff when no liveness findings exist", async () => {
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileIssueGraphLiveness({
issueCreatedAtGte: new Date(),
});
expect(result.findings).toBe(0);
});
});
@@ -106,6 +106,10 @@ import {
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
} from "../services/recovery/index.ts";
import {
UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON,
UNMANAGED_BACKGROUND_TASK_STOP_REASON,
} from "@paperclipai/adapter-utils/server-utils";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -655,6 +659,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
livenessState?: "completed" | "advanced" | "plan_only" | "empty_response" | "blocked" | "failed" | "needs_followup" | null;
runErrorCode?: string | null;
runError?: string | null;
resultJson?: Record<string, unknown> | null;
monitorNextCheckAt?: Date | null;
}) {
const companyId = randomUUID();
const agentId = randomUUID();
@@ -729,6 +735,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
? null
: ("runError" in input ? input.runError : "run failed before issue advanced"),
livenessState: input.livenessState ?? null,
resultJson: input.resultJson ?? null,
});
await db.insert(issues).values([
@@ -755,6 +762,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
assigneeUserId: input.assignToUser ? "user-1" : null,
checkoutRunId: input.status === "in_progress" ? runId : null,
executionRunId: null,
monitorNextCheckAt: input.monitorNextCheckAt ?? null,
responsibleUserId: "responsible-user",
issueNumber: input.activePauseHold ? 2 : 1,
identifier: `${issuePrefix}-${input.activePauseHold ? 2 : 1}`,
@@ -1554,6 +1562,17 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(failedRun?.status).toBe("failed");
expect(failedRun?.errorCode).toBe("process_lost");
expect(failedRun?.error).toContain("descendant process group");
expect(failedRun?.resultJson).toMatchObject({
stopReason: UNMANAGED_BACKGROUND_TASK_STOP_REASON,
unmanagedBackgroundTask: {
kind: "orphaned_process_group_cleanup",
stopped: true,
stopReason: UNMANAGED_BACKGROUND_TASK_STOP_REASON,
reason: UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON,
processPid: orphan.processPid,
processGroupId: orphan.processGroupId,
},
});
const retryRun = runs.find((row) => row.id !== runId);
expect(["queued", "running"]).toContain(retryRun?.status);
@@ -5287,6 +5306,155 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(wakeups).toHaveLength(2);
});
it("does not accept unmanaged local-background wait evidence as a live continuation path", async () => {
const localWaitEvidence = {
summary: "Started a local polling watcher and will check the log later.",
externalWait: {
kind: "local_background",
pid: 12345,
logPath: "run/watch.log",
durable: false,
},
};
const { agentId, issueId, runId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "succeeded",
livenessState: "advanced",
resultJson: localWaitEvidence,
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.continuationRequeued).toBe(1);
expect(result.escalated).toBe(0);
expect(result.issueIds).toEqual([issueId]);
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId));
const retryRun = runs.find((row) => row.id !== runId);
expect(retryRun?.contextSnapshot as Record<string, unknown> | undefined).toMatchObject({
issueId,
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.productive_terminal_continuation_recovery",
});
expect(retryRun?.contextSnapshot as Record<string, unknown>).not.toHaveProperty("modelProfile");
});
it("escalates repeated unmanaged local-background waits instead of retrying forever", async () => {
const localWaitEvidence = {
summary: "Still waiting on the local background watcher.",
externalWait: {
kind: "local_background",
pid: 12345,
logPath: "run/watch.log",
durable: false,
},
};
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "succeeded",
retryReason: "issue_continuation_needed",
runSource: "issue.productive_terminal_continuation_recovery",
livenessState: "advanced",
resultJson: localWaitEvidence,
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.continuationRequeued).toBe(0);
expect(result.escalated).toBe(1);
expect(result.issueIds).toEqual([issueId]);
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(issue?.status).toBe("blocked");
await expectSourceScopedStrandedRecoveryAction({
companyId,
agentId,
issueId,
runId,
previousStatus: "in_progress",
retryReason: "issue_continuation_needed",
});
const followupRuns = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId));
expect(followupRuns).toHaveLength(2);
});
it("preserves a persisted issue monitor as the durable external-wait path", async () => {
const { companyId, agentId, issueId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "succeeded",
livenessState: "advanced",
monitorNextCheckAt: new Date("2026-03-19T01:00:00.000Z"),
resultJson: {
summary: "Waiting for the deploy to settle; monitor is scheduled.",
externalWait: { kind: "issue_monitor", durable: true },
},
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.continuationRequeued).toBe(0);
expect(result.escalated).toBe(0);
expect(result.skipped).toBe(1);
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(issue?.status).toBe("in_progress");
expect(issue?.monitorNextCheckAt?.toISOString()).toBe("2026-03-19T01:00:00.000Z");
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(1);
const recoveryIssues = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stranded_issue_recovery")));
expect(recoveryIssues).toHaveLength(0);
});
it("preserves a delegated blocker edge as the durable external-wait path", async () => {
const { companyId, agentId, issueId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "succeeded",
livenessState: "advanced",
resultJson: {
summary: "Delegated the external account check to a child task.",
externalWait: { kind: "delegated_child", durable: true },
},
});
const blockerIssueId = randomUUID();
await db.insert(issues).values({
id: blockerIssueId,
companyId,
parentId: issueId,
title: "Check external account approval",
status: "todo",
priority: "medium",
assigneeUserId: "external-owner",
responsibleUserId: "responsible-user",
issueNumber: 2,
identifier: "PAP-2",
});
await db.insert(issueRelations).values({
companyId,
issueId: blockerIssueId,
relatedIssueId: issueId,
type: "blocks",
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.continuationRequeued).toBe(0);
expect(result.escalated).toBe(0);
expect(result.skipped).toBe(1);
const source = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(source?.status).toBe("in_progress");
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(1);
});
it("blocks stranded in-progress work after a productive continuation retry was already used", async () => {
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
status: "in_progress",
@@ -133,6 +133,16 @@ describeEmbeddedPostgres("heartbeat worktree suppression", () => {
return { companyId, agentId, issueId };
}
async function armWorktreeRunExecution(cutoff: Date) {
await instanceSettingsService(db, {
runtimeEnv: {
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_INSTANCE_ID: "test-worktree",
},
now: () => cutoff,
}).updateExperimental({ enableWorktreeRunExecution: true });
}
async function waitForCompletedRun(runId: string, agentId: string) {
let latestStatus: string | null = null;
let latestLastRunId: string | null = null;
@@ -243,6 +253,48 @@ describeEmbeddedPostgres("heartbeat worktree suppression", () => {
expect(runningCount).toBe(0);
});
it("skips pre-cutoff system wakes but allows user wakes in an armed worktree", async () => {
const { agentId, issueId } = await insertAgentAndIssue();
await armWorktreeRunExecution(new Date(Date.now() + 1_000));
const heartbeat = heartbeatService(db, {
runtimeEnv: {
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_INSTANCE_ID: "test-worktree",
},
});
const systemRun = await heartbeat.wakeup(agentId, {
source: "assignment",
triggerDetail: "system",
payload: { issueId },
contextSnapshot: { issueId },
requestedByActorType: "system",
});
expect(systemRun).toBeNull();
const skippedWake = await db
.select({ reason: agentWakeupRequests.reason, payload: agentWakeupRequests.payload })
.from(agentWakeupRequests)
.orderBy(sql`${agentWakeupRequests.createdAt} desc`)
.limit(1)
.then((rows) => rows[0] ?? null);
expect(skippedWake).toMatchObject({ reason: "heartbeat.worktree_execution_cutoff" });
expect(skippedWake?.payload).toMatchObject({
heartbeatSkip: { reason: "worktree_execution_cutoff", issueId },
});
const userRun = await heartbeat.wakeup(agentId, {
source: "on_demand",
triggerDetail: "user",
payload: { issueId },
contextSnapshot: { issueId, skipIssueComment: true },
requestedByActorType: "user",
requestedByActorId: "operator",
});
expect(userRun).not.toBeNull();
await heartbeat.waitForRunExecutionDrain(userRun!.id);
}, 10_000);
it("still creates live-plane assignment runs when suppression is not active", async () => {
const { agentId, issueId } = await insertAgentAndIssue();
await db
@@ -1,6 +1,9 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
approvals,
companies,
@@ -14,6 +17,8 @@ import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { inboxDismissalRoutes } from "../routes/inbox-dismissals.js";
import { inboxDismissalService } from "../services/inbox-dismissals.ts";
import { sidebarBadgeService } from "../services/sidebar-badges.ts";
@@ -43,6 +48,7 @@ describeEmbeddedPostgres("inbox dismissals", () => {
await db.delete(inboxDismissals);
await db.delete(joinRequests);
await db.delete(invites);
await db.delete(activityLog);
await db.delete(heartbeatRuns);
await db.delete(approvals);
await db.delete(agents);
@@ -73,9 +79,63 @@ describeEmbeddedPostgres("inbox dismissals", () => {
expect(dismissals).toHaveLength(1);
expect(dismissals[0]?.itemKey).toBe("approval:approval-1");
expect(dismissals[0]?.kind).toBe("dismiss");
expect(dismissals[0]?.snoozedUntil).toBeNull();
expect(new Date(dismissals[0]?.dismissedAt ?? 0).toISOString()).toBe(secondDismissedAt.toISOString());
});
it("snoozes and restores dismissal records through the route", async () => {
const companyId = randomUUID();
const userId = "board-user";
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
source: "local_implicit",
userId,
companyIds: [companyId],
isInstanceAdmin: false,
};
next();
});
app.use("/api", inboxDismissalRoutes(db));
app.use(errorHandler);
await request(app)
.post(`/api/companies/${companyId}/inbox-dismissals`)
.send({ itemKey: "attention:approval:old", kind: "snooze", snoozedUntil: "2020-01-01T00:00:00.000Z" })
.expect(400);
const snoozedUntil = "2099-01-01T00:00:00.000Z";
const createRes = await request(app)
.post(`/api/companies/${companyId}/inbox-dismissals`)
.send({ itemKey: "attention:approval:approval-1", kind: "snooze", snoozedUntil })
.expect(201);
expect(createRes.body).toMatchObject({
companyId,
userId,
itemKey: "attention:approval:approval-1",
kind: "snooze",
snoozedUntil,
});
await request(app)
.delete(`/api/companies/${companyId}/inbox-dismissals/${encodeURIComponent("attention:approval:approval-1")}`)
.expect(204);
await expect(dismissalsSvc.list(companyId, userId)).resolves.toEqual([]);
});
it("honors dismissal timestamps and resurfaces approvals with newer activity", async () => {
const companyId = randomUUID();
const userId = "board-user";
@@ -89,6 +89,9 @@ describe("instance settings routes", () => {
enableIssueGraphLivenessAutoRecovery: true,
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
},
createdAt: "2026-06-20T00:00:00.000Z",
@@ -114,6 +117,9 @@ describe("instance settings routes", () => {
enableIssueGraphLivenessAutoRecovery: true,
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
});
mockInstanceSettingsService.update.mockResolvedValue({
@@ -138,6 +144,9 @@ describe("instance settings routes", () => {
enableIssueGraphLivenessAutoRecovery: true,
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
},
createdAt: "2026-06-20T00:00:00.000Z",
@@ -168,6 +177,9 @@ describe("instance settings routes", () => {
enableIssueGraphLivenessAutoRecovery: true,
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
},
});
@@ -226,6 +238,9 @@ describe("instance settings routes", () => {
enableIssueGraphLivenessAutoRecovery: true,
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
});
@@ -240,6 +255,28 @@ describe("instance settings routes", () => {
expect(mockLogActivity).toHaveBeenCalledTimes(2);
}, 10_000);
it("strips server-managed worktree run execution fields before updating experimental settings", async () => {
const app = await createApp({
type: "board",
userId: "local-board",
source: "local_implicit",
isInstanceAdmin: true,
});
await request(app)
.patch("/api/instance/settings/experimental")
.send({
enableWorktreeRunExecution: true,
worktreeRunExecutionActivatedAt: "2026-07-10T12:00:00.000Z",
worktreeRunExecutionActivationInstanceId: "copied-instance",
})
.expect(200);
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
enableWorktreeRunExecution: true,
});
});
it("allows local board users to read and update the instance default environment", async () => {
const app = await createApp({
type: "board",
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { normalizeExperimentalSettings } from "../services/instance-settings.js";
import { describe, expect, it, vi } from "vitest";
import type { InstanceExperimentalSettings } from "@paperclipai/shared";
import {
applyExperimentalSettingsPatch,
normalizeExperimentalSettings,
resolveWorktreeRunExecutionActivationState,
} from "../services/instance-settings.js";
describe("instance settings service", () => {
it("ignores retired experimental flags without resetting current settings", () => {
@@ -32,6 +37,7 @@ describe("instance settings service", () => {
enableTaskWatchdogs: true,
enableCloudSync: true,
enableBuiltInAgents: true,
enableDecisions: false,
enableGoalsSidebarLink: true,
enableServerInfoDebugView: true,
autoRestartDevServerWhenIdle: true,
@@ -39,6 +45,8 @@ describe("instance settings service", () => {
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: false,
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,
issueGraphLivenessAutoRecoveryLookbackHours: 48,
});
});
@@ -76,6 +84,14 @@ describe("instance settings service", () => {
).toBe(false);
});
it("defaults enableDecisions to false for empty and legacy stored settings", () => {
expect(normalizeExperimentalSettings(undefined).enableDecisions).toBe(false);
expect(normalizeExperimentalSettings({}).enableDecisions).toBe(false);
expect(
normalizeExperimentalSettings({ enableStreamlinedLeftNavigation: true }).enableDecisions,
).toBe(false);
});
it("defaults workspace branch repair settings to true for empty and legacy stored settings", () => {
expect(normalizeExperimentalSettings(undefined).enableWorkspaceBranchReconcileForward).toBe(true);
expect(normalizeExperimentalSettings({}).enableWorkspaceBranchReconcileForward).toBe(true);
@@ -115,4 +131,208 @@ describe("instance settings service", () => {
expect(normalizeExperimentalSettings({}).enableBuiltInAgents).toBe(false);
expect(normalizeExperimentalSettings({ enableExternalObjects: true }).enableBuiltInAgents).toBe(false);
});
it("sets worktree run execution activation fields on a false to true transition", () => {
const activatedAt = new Date("2026-07-10T12:00:00.000Z");
const next = applyExperimentalSettingsPatch(
{ enableWorktreeRunExecution: false },
{ enableWorktreeRunExecution: true },
{
now: () => activatedAt,
runtimeEnv: {
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_INSTANCE_ID: "worktree-instance",
},
},
);
expect(next.enableWorktreeRunExecution).toBe(true);
expect(next.worktreeRunExecutionActivatedAt).toBe("2026-07-10T12:00:00.000Z");
expect(next.worktreeRunExecutionActivationInstanceId).toBe("worktree-instance");
});
it("clears worktree run execution activation fields on a true to false transition", () => {
const next = applyExperimentalSettingsPatch(
{
enableWorktreeRunExecution: true,
worktreeRunExecutionActivatedAt: "2026-07-10T12:00:00.000Z",
worktreeRunExecutionActivationInstanceId: "worktree-instance",
},
{ enableWorktreeRunExecution: false },
{
runtimeEnv: {
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_INSTANCE_ID: "worktree-instance",
},
},
);
expect(next.enableWorktreeRunExecution).toBe(false);
expect(next.worktreeRunExecutionActivatedAt).toBeNull();
expect(next.worktreeRunExecutionActivationInstanceId).toBeNull();
});
it("refreshes the activation cutoff when worktree run execution is re-toggled", () => {
const firstActivation = applyExperimentalSettingsPatch(
{ enableWorktreeRunExecution: false },
{ enableWorktreeRunExecution: true },
{
now: () => new Date("2026-07-10T12:00:00.000Z"),
runtimeEnv: {
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_INSTANCE_ID: "worktree-instance",
},
},
);
const disabled = applyExperimentalSettingsPatch(
firstActivation,
{ enableWorktreeRunExecution: false },
{
runtimeEnv: {
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_INSTANCE_ID: "worktree-instance",
},
},
);
const secondActivation = applyExperimentalSettingsPatch(
disabled,
{ enableWorktreeRunExecution: true },
{
now: () => new Date("2026-07-10T12:05:00.000Z"),
runtimeEnv: {
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_INSTANCE_ID: "worktree-instance",
},
},
);
expect(secondActivation.worktreeRunExecutionActivatedAt).toBe("2026-07-10T12:05:00.000Z");
expect(secondActivation.worktreeRunExecutionActivatedAt).not.toBe(
firstActivation.worktreeRunExecutionActivatedAt,
);
});
it("strips client-supplied activation fields before applying experimental patches", () => {
const next = applyExperimentalSettingsPatch(
{ enableWorktreeRunExecution: false },
{
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: "2026-07-10T12:00:00.000Z",
worktreeRunExecutionActivationInstanceId: "copied-instance",
},
{
runtimeEnv: {
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_INSTANCE_ID: "worktree-instance",
},
},
);
expect(next.worktreeRunExecutionActivatedAt).toBeNull();
expect(next.worktreeRunExecutionActivationInstanceId).toBeNull();
});
it("resolves worktree run execution as armed only when the cutoff matches the current instance", async () => {
const experimental = normalizeExperimentalSettings({
enableWorktreeRunExecution: true,
worktreeRunExecutionActivatedAt: "2026-07-10T12:00:00.000Z",
worktreeRunExecutionActivationInstanceId: "worktree-instance",
});
await expect(
resolveWorktreeRunExecutionActivationState({
getExperimental: async () => experimental,
runtimeEnv: {
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_INSTANCE_ID: "worktree-instance",
},
}),
).resolves.toEqual({
armed: true,
cutoff: "2026-07-10T12:00:00.000Z",
activationInstanceId: "worktree-instance",
reason: null,
});
});
it("fails closed when worktree run execution is missing a cutoff", async () => {
const experimental = normalizeExperimentalSettings({
enableWorktreeRunExecution: true,
worktreeRunExecutionActivationInstanceId: "worktree-instance",
});
await expect(
resolveWorktreeRunExecutionActivationState({
getExperimental: async () => experimental,
runtimeEnv: {
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_INSTANCE_ID: "worktree-instance",
},
}),
).resolves.toMatchObject({
armed: false,
cutoff: null,
reason: "missing_cutoff",
});
});
it("fails closed when worktree run execution was activated by another instance", async () => {
const experimental = normalizeExperimentalSettings({
enableWorktreeRunExecution: true,
worktreeRunExecutionActivatedAt: "2026-07-10T12:00:00.000Z",
worktreeRunExecutionActivationInstanceId: "source-instance",
});
await expect(
resolveWorktreeRunExecutionActivationState({
getExperimental: async () => experimental,
runtimeEnv: {
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_INSTANCE_ID: "target-instance",
},
}),
).resolves.toMatchObject({
armed: false,
cutoff: null,
activationInstanceId: "source-instance",
reason: "instance_id_mismatch",
});
});
it("fails closed on settings read errors and avoids reads outside worktree runtimes", async () => {
await expect(
resolveWorktreeRunExecutionActivationState({
getExperimental: async () => {
throw new Error("settings unavailable");
},
runtimeEnv: {
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_INSTANCE_ID: "worktree-instance",
},
}),
).resolves.toMatchObject({
armed: false,
cutoff: null,
reason: "settings_read_error",
});
const getExperimental = vi.fn<() => Promise<InstanceExperimentalSettings>>();
await expect(
resolveWorktreeRunExecutionActivationState({
getExperimental,
runtimeEnv: {
PAPERCLIP_IN_WORKTREE: "false",
PAPERCLIP_INSTANCE_ID: "worktree-instance",
},
}),
).resolves.toMatchObject({
armed: false,
cutoff: null,
reason: "not_worktree_runtime",
});
expect(getExperimental).not.toHaveBeenCalled();
});
});
@@ -18,6 +18,7 @@ const mockInteractionService = vi.hoisted(() => ({
rejectSuggestedTasks: vi.fn(),
expireRequestConfirmationsSupersededByHistoricalComments: vi.fn(),
answerQuestions: vi.fn(),
submitItemVerdicts: vi.fn(),
cancelQuestions: vi.fn(),
}));
@@ -284,6 +285,48 @@ describe.sequential("issue thread interaction routes", () => {
updatedAt: "2026-04-20T12:06:00.000Z",
resolvedAt: "2026-04-20T12:06:00.000Z",
});
mockInteractionService.submitItemVerdicts.mockResolvedValue({
interaction: {
id: "interaction-verdicts",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_item_verdicts",
status: "pending",
continuationPolicy: "wake_assignee",
idempotencyKey: null,
sourceCommentId: "comment-verdicts",
sourceRunId: "run-verdicts",
payload: {
version: 1,
prompt: "Review generated artifacts.",
items: [
{ id: "api", label: "API route" },
{ id: "docs", label: "Docs" },
],
verdicts: ["approve", "reject"],
requireReasonOn: ["reject"],
allowBulkApprove: true,
},
result: {
version: 1,
outcome: "resolved",
complete: false,
items: [
{
id: "docs",
verdict: "reject",
reason: "Missing examples",
resolvedByUserId: "local-board",
resolvedAt: "2026-04-20T12:06:00.000Z",
},
],
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:06:00.000Z",
resolvedAt: null,
},
newlyResolvedItemIds: ["docs"],
});
mockInteractionService.cancelQuestions.mockResolvedValue({
id: "interaction-2",
companyId: "company-1",
@@ -469,6 +512,68 @@ describe.sequential("issue thread interaction routes", () => {
);
});
it("submits item verdicts and emits one continuation wake with resolved item ids", async () => {
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-verdicts/verdicts")
.send({
verdicts: [{ id: "docs", verdict: "reject", reason: "Missing examples" }],
});
expect(res.status).toBe(200);
expect(mockInteractionService.submitItemVerdicts).toHaveBeenCalledWith(
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
"interaction-verdicts",
{ verdicts: [{ id: "docs", verdict: "reject", reason: "Missing examples" }] },
expect.objectContaining({ userId: "local-board" }),
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
reason: "issue_commented",
idempotencyKey: expect.stringMatching(
/^request_item_verdicts:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa:interaction-verdicts:/,
),
payload: expect.objectContaining({
interactionId: "interaction-verdicts",
interactionKind: "request_item_verdicts",
interactionStatus: "pending",
sourceCommentId: "comment-verdicts",
sourceRunId: "run-verdicts",
newlyResolvedItemIds: ["docs"],
itemVerdicts: {
newlyResolvedItemIds: ["docs"],
coalesceWindowMs: 2000,
},
}),
contextSnapshot: expect.objectContaining({
interactionId: "interaction-verdicts",
interactionKind: "request_item_verdicts",
interactionStatus: "pending",
newlyResolvedItemIds: ["docs"],
itemVerdicts: {
newlyResolvedItemIds: ["docs"],
coalesceWindowMs: 2000,
},
}),
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.thread_interaction_item_verdicts_submitted",
details: expect.objectContaining({
interactionKind: "request_item_verdicts",
newlyResolvedItemCount: 1,
newlyResolvedItemIds: ["docs"],
complete: false,
}),
}),
);
});
it("cancels question interactions and emits a continuation wake", async () => {
const app = await createApp();
@@ -1140,6 +1140,243 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
});
});
it("submits request_item_verdicts partially and completes when all items are resolved", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Item verdict partial submit");
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_item_verdicts",
payload: {
version: 1,
prompt: "Review generated artifacts.",
items: [
{ id: "api", label: "API route" },
{ id: "docs", label: "Docs" },
{ id: "tests", label: "Tests" },
],
},
}, {
userId: "local-board",
});
expect(created).toMatchObject({
kind: "request_item_verdicts",
status: "pending",
continuationPolicy: "wake_assignee",
payload: {
verdicts: ["approve", "reject"],
requireReasonOn: ["reject"],
allowBulkApprove: true,
supersedeOnUserComment: true,
},
});
const first = await interactionsSvc.submitItemVerdicts({
id: issueId,
companyId,
}, created.id, {
verdicts: [{ id: "docs", verdict: "reject", reason: "Missing examples" }],
}, {
userId: "local-board",
});
expect(first.newlyResolvedItemIds).toEqual(["docs"]);
expect(first.interaction).toMatchObject({
kind: "request_item_verdicts",
status: "pending",
result: {
version: 1,
outcome: "resolved",
complete: false,
items: [
{
id: "docs",
verdict: "reject",
reason: "Missing examples",
resolvedByUserId: "local-board",
},
],
},
resolvedAt: null,
});
const duplicate = await interactionsSvc.submitItemVerdicts({
id: issueId,
companyId,
}, created.id, {
verdicts: [{ id: "docs", verdict: "reject" }],
}, {
userId: "local-board",
});
expect(duplicate.newlyResolvedItemIds).toEqual([]);
expect(duplicate.interaction).toMatchObject({
status: "pending",
result: {
complete: false,
items: [
{
id: "docs",
verdict: "reject",
reason: "Missing examples",
},
],
},
});
const completed = await interactionsSvc.submitItemVerdicts({
id: issueId,
companyId,
}, created.id, {
verdicts: [
{ id: "api", verdict: "approve" },
{ id: "tests", verdict: "reject", reason: "No route coverage" },
],
}, {
userId: "local-board",
});
expect(completed.newlyResolvedItemIds).toEqual(["api", "tests"]);
expect(completed.interaction).toMatchObject({
kind: "request_item_verdicts",
status: "answered",
result: {
version: 1,
outcome: "resolved",
complete: true,
items: [
{ id: "api", verdict: "approve" },
{ id: "docs", verdict: "reject", reason: "Missing examples" },
{ id: "tests", verdict: "reject", reason: "No route coverage" },
],
},
resolvedByUserId: "local-board",
});
const duplicateAfterComplete = await interactionsSvc.submitItemVerdicts({
id: issueId,
companyId,
}, created.id, {
verdicts: [{ id: "api", verdict: "approve" }],
}, {
userId: "local-board",
});
expect(duplicateAfterComplete.newlyResolvedItemIds).toEqual([]);
expect(duplicateAfterComplete.interaction.status).toBe("answered");
});
it("enforces request_item_verdicts ids, enabled verdicts, and required reasons", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Item verdict validation");
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_item_verdicts",
payload: {
version: 1,
prompt: "Review generated artifacts.",
items: [
{ id: "api", label: "API route" },
{ id: "docs", label: "Docs" },
],
},
}, {
userId: "local-board",
});
await expect(interactionsSvc.submitItemVerdicts({
id: issueId,
companyId,
}, created.id, {
verdicts: [{ id: "missing", verdict: "approve" }],
}, {
userId: "local-board",
})).rejects.toThrow("Unknown item verdict id: missing");
await expect(interactionsSvc.submitItemVerdicts({
id: issueId,
companyId,
}, created.id, {
verdicts: [{ id: "api", verdict: "defer" }],
}, {
userId: "local-board",
})).rejects.toThrow("Verdict defer is not enabled");
await expect(interactionsSvc.submitItemVerdicts({
id: issueId,
companyId,
}, created.id, {
verdicts: [{ id: "docs", verdict: "reject" }],
}, {
userId: "local-board",
})).rejects.toThrow("A reason is required when verdict is reject");
});
it("preserves resolved request_item_verdicts items when a later user comment supersedes the pending remainder", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Item verdict supersede");
const commentId = randomUUID();
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_item_verdicts",
payload: {
version: 1,
prompt: "Review generated artifacts.",
items: [
{ id: "api", label: "API route" },
{ id: "docs", label: "Docs" },
],
},
}, {
userId: "local-board",
});
await interactionsSvc.submitItemVerdicts({
id: issueId,
companyId,
}, created.id, {
verdicts: [{ id: "api", verdict: "approve" }],
}, {
userId: "local-board",
});
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
}, {
id: commentId,
createdAt: new Date(new Date(created.createdAt).getTime() + 1_000),
authorUserId: "local-board",
}, {
userId: "local-board",
});
expect(expired).toHaveLength(1);
expect(expired[0]).toMatchObject({
id: created.id,
kind: "request_item_verdicts",
status: "expired",
result: {
version: 1,
outcome: "superseded_by_comment",
complete: false,
commentId,
items: [
{
id: "api",
verdict: "approve",
resolvedByUserId: "local-board",
},
],
},
});
});
it("returns agent-authored request confirmations to the creating agent when a board user accepts", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
@@ -1580,6 +1817,152 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
});
});
it("preserves resolved request_item_verdicts items when the watched issue document revision changes", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
const documentId = randomUUID();
const revisionId = randomUUID();
const nextRevisionId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Document target verdicts",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
await db.insert(documents).values({
id: documentId,
companyId,
title: "Plan",
format: "markdown",
latestBody: "v1",
latestRevisionId: revisionId,
latestRevisionNumber: 1,
});
await db.insert(issueDocuments).values({
companyId,
issueId,
documentId,
key: "plan",
});
await db.insert(documentRevisions).values({
id: revisionId,
companyId,
documentId,
revisionNumber: 1,
title: "Plan",
format: "markdown",
body: "v1",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_item_verdicts",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Review generated artifacts.",
items: [
{ id: "api", label: "API route" },
{ id: "docs", label: "Docs" },
],
target: {
type: "issue_document",
issueId,
documentId,
key: "plan",
revisionId,
revisionNumber: 1,
},
},
}, {
userId: "local-board",
});
await interactionsSvc.submitItemVerdicts({
id: issueId,
companyId,
}, created.id, {
verdicts: [{ id: "api", verdict: "approve" }],
}, {
userId: "local-board",
});
await db.insert(documentRevisions).values({
id: nextRevisionId,
companyId,
documentId,
revisionNumber: 2,
title: "Plan",
format: "markdown",
body: "v2",
});
await db.update(documents).set({
latestBody: "v2",
latestRevisionId: nextRevisionId,
latestRevisionNumber: 2,
});
const stale = await interactionsSvc.submitItemVerdicts({
id: issueId,
companyId,
}, created.id, {
verdicts: [{ id: "docs", verdict: "approve" }],
}, {
userId: "local-board",
});
expect(stale.newlyResolvedItemIds).toEqual([]);
expect(stale.interaction).toMatchObject({
id: created.id,
status: "expired",
payload: {
target: {
type: "issue_document",
key: "plan",
revisionId: nextRevisionId,
revisionNumber: 2,
},
},
result: {
version: 1,
outcome: "stale_target",
complete: false,
staleTarget: {
type: "issue_document",
key: "plan",
revisionId,
},
items: [
{
id: "api",
verdict: "approve",
resolvedByUserId: "local-board",
},
],
},
});
});
describe("workspace_finalize accept gate", () => {
type AcceptGateInteractionKind = "request_confirmation" | "request_checkbox_confirmation";
@@ -15,6 +15,7 @@ const apiPrefixes: Record<string, string> = {
"activity.ts": "/api",
"adapters.ts": "/api",
"agents.ts": "/api",
"attention.ts": "/api",
"approvals.ts": "/api",
"assets.ts": "/api",
"auth.ts": "/api/auth",
@@ -68,10 +68,13 @@ describe("paperclip skill utils", () => {
const skillPath = path.resolve(".agents/skills/create-issue-interaction-ui/SKILL.md");
const skillBody = await fs.readFile(skillPath, "utf8");
const normalizedSkillBody = skillBody.replace(/\s+/g, " ");
const normalizedLowerSkillBody = normalizedSkillBody.toLowerCase();
expect(skillBody).toContain("name: create-issue-interaction-ui");
expect(skillBody).toContain("Developer/maintainer skill");
expect(normalizedSkillBody).toContain("Do NOT install this on production Paperclip agents");
expect(normalizedLowerSkillBody).toContain("developer/maintainer skill");
expect(normalizedLowerSkillBody).toContain(
"not the operational agents that run inside a deployed paperclip company",
);
expect(skillBody).toContain("packages/shared/src/constants.ts");
expect(skillBody).toContain("server/src/services/issue-thread-interactions.ts");
expect(skillBody).toContain("ui/src/components/IssueThreadInteractionCard.tsx");
@@ -88,6 +88,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
});
async function seedFixture(opts?: {
runtimeEnv?: Record<string, string | undefined>;
wakeup?: (
agentId: string,
wakeupOpts: {
@@ -147,6 +148,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
});
const svc = routineService(db, {
runtimeEnv: opts?.runtimeEnv,
heartbeat: {
wakeup: async (wakeupAgentId, wakeupOpts) => {
wakeups.push({ agentId: wakeupAgentId, opts: wakeupOpts });
@@ -204,6 +206,18 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
return { companyId, agentId, issueSvc, projectId, routine, svc, wakeups };
}
async function armWorktreeExecution(cutoff: Date, instanceId = "worktree-routines-test") {
await db.insert(instanceSettings).values({
singletonKey: "default",
general: {},
experimental: {
enableWorktreeRunExecution: true,
worktreeRunExecutionActivatedAt: cutoff.toISOString(),
worktreeRunExecutionActivationInstanceId: instanceId,
},
});
}
it("filters listed routines by project", async () => {
const { companyId, agentId, projectId, routine, svc } = await seedFixture();
const otherProjectId = randomUUID();
@@ -1746,6 +1760,80 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
expect(run.status).toBe("issue_created");
});
it("records suppressed automatic runs when worktree execution is disabled while allowing manual runs", async () => {
const runtimeEnv = { PAPERCLIP_IN_WORKTREE: "yes", PAPERCLIP_INSTANCE_ID: "worktree-routines-test" };
const { companyId, routine, svc } = await seedFixture({ runtimeEnv });
const { trigger: scheduleTrigger } = await svc.createTrigger(
routine.id,
{ kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" },
{},
);
const { trigger: webhookTrigger } = await svc.createTrigger(
routine.id,
{ kind: "webhook", signingMode: "none" },
{},
);
const pastDue = new Date("2020-01-01T00:00:00.000Z");
await db.update(routineTriggers).set({ nextRunAt: pastDue }).where(eq(routineTriggers.id, scheduleTrigger.id));
expect(await svc.tickScheduledTriggers(new Date())).toEqual({ triggered: 0 });
const webhookRun = await svc.firePublicTrigger(webhookTrigger.publicId!, { payload: { event: "created" } });
expect(webhookRun).toMatchObject({ source: "webhook", status: "skipped", failureReason: "worktree_execution_cutoff" });
const manualRun = await svc.runRoutine(routine.id, { source: "manual" });
expect(manualRun.status).toBe("issue_created");
const automatedRuns = await db.select().from(routineRuns).where(eq(routineRuns.routineId, routine.id));
expect(automatedRuns.filter((run) => run.failureReason === "worktree_execution_cutoff")).toHaveLength(2);
expect(automatedRuns.filter((run) => run.linkedIssueId)).toHaveLength(1);
const scheduleAfter = await db.select().from(routineTriggers).where(eq(routineTriggers.id, scheduleTrigger.id)).then((rows) => rows[0]);
expect(scheduleAfter!.nextRunAt!.getTime()).toBeGreaterThan(pastDue.getTime());
expect((await db.select().from(issues).where(eq(issues.companyId, companyId))).filter((issue) => issue.originKind === "routine_execution")).toHaveLength(1);
});
it("dispatches only post-cutoff scheduled routines in an armed worktree", async () => {
const runtimeEnv = { PAPERCLIP_IN_WORKTREE: "true", PAPERCLIP_INSTANCE_ID: "worktree-routines-test" };
const { companyId, agentId, projectId, routine: oldRoutine, svc } = await seedFixture({ runtimeEnv });
const cutoff = new Date("2025-01-01T00:00:00.000Z");
await armWorktreeExecution(cutoff);
const newRoutine = await svc.create(companyId, {
projectId,
goalId: null,
parentIssueId: null,
title: "new routine",
description: null,
assigneeAgentId: agentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
}, {});
await db.update(routines).set({ createdAt: new Date("2024-12-31T23:59:59.000Z") }).where(eq(routines.id, oldRoutine.id));
await db.update(routines).set({ createdAt: new Date("2025-01-01T00:00:01.000Z") }).where(eq(routines.id, newRoutine.id));
const { trigger: oldTrigger } = await svc.createTrigger(oldRoutine.id, { kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" }, {});
const { trigger: newTrigger } = await svc.createTrigger(newRoutine.id, { kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" }, {});
await db.update(routineTriggers).set({ nextRunAt: new Date("2020-01-01T00:00:00.000Z") }).where(eq(routineTriggers.id, oldTrigger.id));
await db.update(routineTriggers).set({ nextRunAt: new Date("2020-01-01T00:00:00.000Z") }).where(eq(routineTriggers.id, newTrigger.id));
expect(await svc.tickScheduledTriggers(new Date())).toEqual({ triggered: 1 });
const oldRuns = await db.select().from(routineRuns).where(eq(routineRuns.routineId, oldRoutine.id));
expect(oldRuns).toMatchObject([{ status: "skipped", failureReason: "worktree_execution_cutoff", linkedIssueId: null }]);
const newRuns = await db.select().from(routineRuns).where(eq(routineRuns.routineId, newRoutine.id));
expect(newRuns).toMatchObject([{ status: "issue_created" }]);
});
it("applies the armed cutoff to webhook dispatch but not manual API runs", async () => {
const runtimeEnv = { PAPERCLIP_IN_WORKTREE: "true", PAPERCLIP_INSTANCE_ID: "worktree-routines-test" };
const { routine, svc } = await seedFixture({ runtimeEnv });
await armWorktreeExecution(new Date("2025-01-01T00:00:00.000Z"));
await db.update(routines).set({ createdAt: new Date("2024-12-31T23:59:59.000Z") }).where(eq(routines.id, routine.id));
const { trigger } = await svc.createTrigger(routine.id, { kind: "webhook", signingMode: "none" }, {});
const webhookRun = await svc.firePublicTrigger(trigger.publicId!, { payload: { event: "created" } });
expect(webhookRun).toMatchObject({ status: "skipped", failureReason: "worktree_execution_cutoff", linkedIssueId: null });
expect((await svc.runRoutine(routine.id, { source: "api" })).status).toBe("issue_created");
});
it("suppresses scheduled ticks while the routine project is paused, then resumes when unpaused", async () => {
const { companyId, projectId, routine, svc } = await seedFixture();
const { trigger } = await svc.createTrigger(
+21
View File
@@ -195,6 +195,27 @@ describe("run liveness classifier", () => {
expect(classification.nextAction).toBe("deploy to production and verify live traffic.");
});
it("uses killed background-task evidence instead of a generic failed-run reason", () => {
const classification = classifyRunLiveness({
...baseInput,
runStatus: "failed",
errorCode: "process_lost",
resultJson: {
stopReason: "unmanaged_background_task_stopped",
unmanagedBackgroundTask: {
kind: "orphaned_process_group_cleanup",
stopped: true,
stopReason: "unmanaged_background_task_stopped",
reason: "unmanaged background task stopped; no durable live path",
},
},
});
expect(classification.livenessState).toBe("failed");
expect(classification.livenessReason).toBe("unmanaged background task stopped; no durable live path");
});
it("marks unclear useful output as unknown actionability", () => {
const classification = classifyRunLiveness({
...baseInput,
@@ -663,4 +663,16 @@ describeEmbeddedPostgres("task watchdog scheduler", () => {
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "task_watchdog")));
expect(watchdogIssues).toHaveLength(1);
});
it("handles an armed cutoff when no watchdogs are active", async () => {
const companyId = await seedCompany();
const { service } = createService();
const result = await service.reconcileTaskWatchdogs({
companyId,
issueCreatedAtGte: new Date(),
});
expect(result).toMatchObject({ checked: 0, triggered: 0 });
});
});
+24
View File
@@ -65,6 +65,30 @@ describe("ui branding", () => {
expect(meta).toContain('name="paperclip-worktree-color"');
});
it("surfaces the runtime instance id so the UI can fail closed on copied rows", () => {
const branding = getWorktreeUiBranding({
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
PAPERCLIP_INSTANCE_ID: "inst-abc123",
});
expect(branding.instanceId).toBe("inst-abc123");
const meta = renderRuntimeBrandingMeta(branding);
expect(meta).toContain('name="paperclip-instance-id"');
expect(meta).toContain('content="inst-abc123"');
});
it("omits the instance-id meta when the runtime id is unset", () => {
const branding = getWorktreeUiBranding({
PAPERCLIP_IN_WORKTREE: "true",
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
});
expect(branding.instanceId).toBeNull();
expect(renderRuntimeBrandingMeta(branding)).not.toContain('name="paperclip-instance-id"');
});
it("rewrites the favicon and runtime branding blocks for worktree instances only", () => {
const branded = applyUiBranding(TEMPLATE, {
PAPERCLIP_IN_WORKTREE: "true",
+81 -2
View File
@@ -42,19 +42,98 @@ describe("resolveServerVersion", () => {
it("falls back to package version without throwing when git is unavailable", () => {
const debugLog = vi.fn();
const cause = new Error("spawn git ENOENT");
const err = Object.assign(new Error("fatal: not a git repository"), {
code: 128,
stderr: Buffer.from("fatal: not a git repository\n"),
stdout: "",
cause,
});
expect(
resolveServerVersion({
packageVersion: "2026.706.0",
gitDescribeCommand: () => {
throw new Error("fatal: not a git repository");
throw err;
},
debugLog,
}),
).toBe("2026.706.0");
expect(debugLog).toHaveBeenCalledWith(
expect.objectContaining({ reason: "git_describe_unavailable" }),
expect.objectContaining({
err: expect.objectContaining({
cause: expect.objectContaining({ message: "spawn git ENOENT" }),
code: 128,
message: "fatal: not a git repository",
stderr: "fatal: not a git repository\n",
stdout: "",
stack: expect.any(String),
}),
reason: "git_describe_unavailable",
}),
"falling back to package version for server version",
);
});
it("skips git metadata probing for packaged installs under node_modules", () => {
const debugLog = vi.fn();
expect(
resolveServerVersion({
packageVersion: "2026.707.0-canary.12",
debugLog,
packageRoot: "/tmp/npm/_npx/example/node_modules/@paperclipai/server",
}),
).toBe("2026.707.0-canary.12");
expect(debugLog).toHaveBeenCalledWith(
{ reason: "packaged_install" },
"falling back to package version for server version",
);
});
it("uses git metadata for source checkouts whose path contains node_modules", () => {
const debugLog = vi.fn();
const gitDescribeCommand = vi.fn(() => "v2026.626.0-58-g518fc71ce\n");
expect(
resolveServerVersion({
packageVersion: "2026.707.0-canary.12",
debugLog,
gitDescribeCommand,
packageRoot: "/tmp/node_modules/source/paperclip/server",
pathExists: (path) => path === "/tmp/node_modules/source/paperclip/.git",
realpath: (path) => path,
}),
).toBe("2026.626.0+58.git.518fc71ce");
expect(gitDescribeCommand).toHaveBeenCalledOnce();
expect(debugLog).not.toHaveBeenCalled();
});
it("keeps fallback diagnostics quiet by default", () => {
const previousDebugFlag = process.env.PAPERCLIP_DEBUG_VERSION_RESOLUTION;
delete process.env.PAPERCLIP_DEBUG_VERSION_RESOLUTION;
const consoleDebug = vi.spyOn(console, "debug").mockImplementation(() => {});
try {
expect(
resolveServerVersion({
packageVersion: "2026.706.0",
gitDescribeCommand: () => {
throw new Error("fatal: not a git repository");
},
}),
).toBe("2026.706.0");
expect(consoleDebug).not.toHaveBeenCalled();
} finally {
consoleDebug.mockRestore();
if (previousDebugFlag === undefined) {
delete process.env.PAPERCLIP_DEBUG_VERSION_RESOLUTION;
} else {
process.env.PAPERCLIP_DEBUG_VERSION_RESOLUTION = previousDebugFlag;
}
}
});
});
+2
View File
@@ -33,6 +33,7 @@ import { secretRoutes } from "./routes/secrets.js";
import { costRoutes } from "./routes/costs.js";
import { activityRoutes } from "./routes/activity.js";
import { dashboardRoutes } from "./routes/dashboard.js";
import { attentionRoutes } from "./routes/attention.js";
import { userProfileRoutes } from "./routes/user-profiles.js";
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
@@ -253,6 +254,7 @@ export async function createApp(
api.use(costRoutes(db, { pluginWorkerManager: workerManager }));
api.use(activityRoutes(db));
api.use(dashboardRoutes(db));
api.use(attentionRoutes(db));
api.use(userProfileRoutes(db));
api.use(sidebarBadgeRoutes(db));
api.use(sidebarPreferenceRoutes(db));
+11
View File
@@ -48,6 +48,7 @@ import {
reconcilePersistedRuntimeServicesOnStartup,
routineService,
} from "./services/index.js";
import { resolveWorktreeRunExecutionActivationState } from "./services/instance-settings.js";
import {
parseAdapterRegistryEnv,
reconcileAdapterAvailability,
@@ -838,6 +839,16 @@ export async function startServer(): Promise<StartedServer> {
drainHeartbeatRunsForShutdown = heartbeat.drainRunningRunsForShutdown;
const environmentCustomImages = environmentCustomImageService(db as any, { pluginWorkerManager });
const routines = routineService(db as any, { pluginWorkerManager });
const worktreeRunExecutionActivation = await resolveWorktreeRunExecutionActivationState({
getExperimental: () => instanceSettingsService(db).getExperimental(),
});
logger.info(
{
state: worktreeRunExecutionActivation.armed ? "armed" : "disarmed",
cutoff: worktreeRunExecutionActivation.cutoff,
},
"worktree run-execution cutoff state",
);
const heartbeatSchedulingSuppression = await heartbeat.resolveSchedulingSuppression();
// Reap orphaned runs before timer ticks start so wakeups cannot coalesce
+24 -3
View File
@@ -75,6 +75,10 @@ function shouldPassthroughWrite(res: Parameters<RequestHandler>[1]): boolean {
const contentType = res.getHeader("Content-Type");
const alreadyEncoded = res.hasHeader("Content-Encoding") && String(res.getHeader("Content-Encoding")).toLowerCase() !== "identity";
return (
// writeHead() may already have committed the response head (better-call
// does this before streaming the body); headers can no longer change, so
// buffering for compression would only risk corrupting the stream.
res.headersSent ||
alreadyEncoded ||
!statusAllowsBody(res.statusCode) ||
shouldSkipForCacheControl(res.getHeader("Cache-Control")) ||
@@ -97,6 +101,15 @@ function weakenStrongEtag(res: Parameters<RequestHandler>[1]): void {
res.setHeader("ETag", weaken(String(etag)));
}
function toBodyBuffer(chunk: unknown, encoding: BufferEncoding | undefined): Buffer {
if (Buffer.isBuffer(chunk)) return chunk;
// Handlers bridged from web Response streams (e.g. Better Auth via
// better-call) write Uint8Array chunks; String(chunk) would serialize them
// as comma-separated byte values and corrupt the body.
if (chunk instanceof Uint8Array) return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
return Buffer.from(String(chunk), encoding);
}
function normalizeEndArgs(args: unknown[]): {
chunk: unknown;
encoding: BufferEncoding | undefined;
@@ -160,7 +173,7 @@ export function apiCompression(options: ApiCompressionOptions = {}): RequestHand
return originalWrite(chunk as never, encodingOrCallback as never, callback as never);
}
if (chunk !== undefined) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), typeof encodingOrCallback === "string" ? encodingOrCallback : undefined));
chunks.push(toBodyBuffer(chunk, typeof encodingOrCallback === "string" ? encodingOrCallback : undefined));
}
const writeCallback = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
if (writeCallback) writeCallbacks.push(() => writeCallback(null));
@@ -171,13 +184,14 @@ export function apiCompression(options: ApiCompressionOptions = {}): RequestHand
restore();
const { chunk, encoding, callback } = normalizeEndArgs(args);
if (chunk !== undefined) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), encoding));
chunks.push(toBodyBuffer(chunk, encoding));
}
const body = Buffer.concat(chunks);
const alreadyEncoded = res.hasHeader("Content-Encoding") && String(res.getHeader("Content-Encoding")).toLowerCase() !== "identity";
const shouldCompress =
!passthrough &&
!res.headersSent &&
!alreadyEncoded &&
statusAllowsBody(res.statusCode) &&
body.length >= thresholdBytes &&
@@ -204,7 +218,14 @@ export function apiCompression(options: ApiCompressionOptions = {}): RequestHand
originalEnd(compressed, callback);
for (const writeCallback of writeCallbacks) writeCallback();
} catch (error) {
res.destroy(error instanceof Error ? error : new Error(String(error)));
// Compression is best-effort: never turn a healthy response into a
// dropped connection. Send the original body if the head allows it.
try {
originalEnd(body, callback);
for (const writeCallback of writeCallbacks) writeCallback();
} catch {
res.destroy(error instanceof Error ? error : new Error(String(error)));
}
}
})();
return res;
+108 -11
View File
@@ -83,7 +83,11 @@ import {
import { redactEventPayload } from "../redaction.js";
import { redactCurrentUserValue } from "../log-redaction.js";
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import {
instanceSettingsService,
isTruthyRuntimeEnvValue,
resolveWorktreeRunExecutionActivationState,
} from "../services/instance-settings.js";
import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server";
import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
@@ -259,6 +263,7 @@ export function agentRoutes(
executionTarget: AdapterExecutionTarget | null;
environmentName: string | null;
fallbackChecks: AdapterEnvironmentCheck[];
sandboxIdentityCheck?: AdapterEnvironmentCheck | null;
release: (status?: "released" | "failed") => Promise<void>;
}> {
const noopRelease = async () => {};
@@ -358,11 +363,26 @@ export function agentRoutes(
// run id to heartbeat_runs.id, and we don't want to manufacture a fake
// run row. Cleanup goes through the driver's `releaseRunLease` directly
// (by lease record), since the batch helper queries by heartbeatRunId.
//
// Sandbox tests boot a fresh throwaway sandbox (never resume a retained
// agent lease) and archive it on release instead of deleting it, so the
// operator can inspect the exact sandbox from the provider dashboard while
// provider-side expiry reaps it later.
const testEnvironment = environment.driver === "sandbox"
? {
...environment,
config: {
...(environment.config ?? {}),
reuseLease: false,
archiveOnRelease: true,
},
}
: environment;
let leaseRecord: Awaited<ReturnType<typeof environmentRuntime.acquireRunLease>>;
try {
leaseRecord = await environmentRuntime.acquireRunLease({
companyId: input.companyId,
environment,
environment: testEnvironment,
issueId: null,
heartbeatRunId: null,
persistedExecutionWorkspace: null,
@@ -394,7 +414,7 @@ export function agentRoutes(
try {
if (driver) {
await driver.releaseRunLease({
environment,
environment: testEnvironment,
lease: leaseRecord.lease,
status,
});
@@ -413,7 +433,7 @@ export function agentRoutes(
let realizedCwd: string | null = null;
try {
const realized = await environmentRuntime.realizeWorkspace({
environment,
environment: testEnvironment,
lease: leaseRecord.lease,
// No host workspace to copy for a Test invocation; sandbox/plugin
// realize implementations use the lease metadata's remoteCwd to
@@ -454,9 +474,9 @@ export function agentRoutes(
companyId: input.companyId,
adapterType: input.adapterType,
environment: {
id: environment.id,
driver: environment.driver,
config: environment.config ?? null,
id: testEnvironment.id,
driver: testEnvironment.driver,
config: testEnvironment.config ?? null,
},
leaseId: leaseRecord.lease.id,
leaseMetadata: leaseMetadataForTarget,
@@ -501,10 +521,75 @@ export function agentRoutes(
executionTarget: target,
environmentName: environment.name,
fallbackChecks: [],
sandboxIdentityCheck: buildSandboxIdentityCheck({
environmentName: environment.name,
lease: leaseRecord.lease,
}),
release: releaseLease,
};
}
function readMetadataString(metadata: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = metadata[key];
if (typeof value === "string" && value.trim().length > 0) return value.trim();
}
return null;
}
function buildSandboxIdentityCheck(input: {
environmentName: string;
lease: {
id: string;
provider?: string | null;
providerLeaseId?: string | null;
metadata?: Record<string, unknown> | null;
};
}): AdapterEnvironmentCheck {
const metadata = input.lease.metadata ?? {};
const provider = input.lease.provider ?? readMetadataString(metadata, ["provider"]);
const sandboxId = readMetadataString(metadata, ["sandboxId", "sandboxID", "sandbox_id", "id"]);
const sandboxName = readMetadataString(metadata, ["sandboxName", "sandbox_name", "name"]);
const snapshotRef = readMetadataString(metadata, [
"snapshot",
"snapshotId",
"snapshotID",
"snapshotRef",
"snapshot_ref",
"templateRef",
"template_ref",
"templateId",
"templateID",
"image",
"imageId",
"imageID",
"imageRef",
"image_ref",
]);
const templateKind = readMetadataString(metadata, [
"templateKind",
"template_kind",
"templateRefKind",
"template_ref_kind",
]);
const detailParts = [
`paperclipLeaseId=${input.lease.id}`,
input.lease.providerLeaseId ? `providerLeaseId=${input.lease.providerLeaseId}` : null,
provider ? `provider=${provider}` : null,
sandboxId ? `sandboxId=${sandboxId}` : null,
sandboxName ? `sandboxName=${sandboxName}` : null,
snapshotRef ? `${templateKind ? `${templateKind}Ref` : "snapshotOrTemplateRef"}=${snapshotRef}` : null,
].filter((part): part is string => Boolean(part));
return {
code: "sandbox_test_identity",
level: "info",
message: `Sandbox test identity for "${input.environmentName}".`,
detail: detailParts.join("; "),
hint: "Use these provider-neutral IDs when comparing model-test output with provider logs or refreshed sandbox snapshots.",
};
}
async function getCurrentUserRedactionOptions() {
return {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
@@ -1686,7 +1771,7 @@ export function agentRoutes(
{ adapterType: type },
);
const { executionTarget, environmentName, fallbackChecks, release } =
const { executionTarget, environmentName, fallbackChecks, sandboxIdentityCheck, release } =
await resolveAdapterTestExecutionContext({
companyId,
adapterType: type,
@@ -1724,7 +1809,10 @@ export function agentRoutes(
});
if (result.status === "fail") releaseStatus = "failed";
res.json(result);
res.json({
...result,
checks: sandboxIdentityCheck ? [sandboxIdentityCheck, ...result.checks] : result.checks,
});
} catch (err) {
releaseStatus = "failed";
throw err;
@@ -2042,14 +2130,23 @@ export function agentRoutes(
includeRoutineExecutions: true,
limit: ISSUE_LIST_DEFAULT_LIMIT,
});
const issueIds = rows.map((issue) => issue.id);
const worktreeActivation = await resolveWorktreeRunExecutionActivationState({
getExperimental: () => instanceSettingsService(db).getExperimental(),
});
const isWorktreeRuntime = isTruthyRuntimeEnvValue(process.env.PAPERCLIP_IN_WORKTREE);
const eligibleRows = !isWorktreeRuntime
? rows
: worktreeActivation.armed
? rows.filter((issue) => new Date(issue.createdAt) >= new Date(worktreeActivation.cutoff))
: [];
const issueIds = eligibleRows.map((issue) => issue.id);
const [dependencyReadiness, recoveryActionByIssue] = await Promise.all([
issuesSvc.listDependencyReadiness(req.actor.companyId, issueIds),
recoveryActionsSvc.listActiveForIssues(req.actor.companyId, issueIds),
]);
res.json(
rows.map((issue) => ({
eligibleRows.map((issue) => ({
id: issue.id,
identifier: issue.identifier,
title: issue.title,
+28
View File
@@ -0,0 +1,28 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { attentionService } from "../services/attention.js";
import { assertBoard, assertCompanyAccess } from "./authz.js";
export function attentionRoutes(db: Db) {
const router = Router();
const svc = attentionService(db);
router.get("/companies/:companyId/attention", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
if (!req.actor.userId) {
res.status(403).json({ error: "Board user context required" });
return;
}
const includeDismissed = req.query.includeDismissed === "true";
const feed = await svc.list(companyId, {
userId: req.actor.userId,
includeDismissed,
});
res.json(feed);
});
return router;
}
+19 -2
View File
@@ -777,12 +777,24 @@ export function environmentRoutes(
res.status(404).json({ error: "Environment not found" });
return;
}
let customImageReconciliation: Awaited<
ReturnType<typeof customImages.reconcileActiveTemplateForConfigChange>
> = { action: "none" };
if (patch.config !== undefined || patch.driver !== undefined) {
await secrets.syncSecretRefsForTarget(
companyIdForSecrets!,
{ targetType: "environment", targetId: environment.id },
await collectEnvironmentSecretRefs({ db, environment }),
);
try {
customImageReconciliation = await customImages.reconcileActiveTemplateForConfigChange({
environmentId: environment.id,
previous: existing,
next: environment,
});
} catch {
// Reconciliation is best-effort; a failure must not fail the save.
}
}
if (patch.envVars !== undefined) {
await secrets.syncEnvBindingsForTarget(
@@ -797,7 +809,9 @@ export function environmentRoutes(
entityId: environment.id,
details: summarizeEnvironmentUpdate(patch as Record<string, unknown>, environment),
});
res.json(environment);
res.json(customImageReconciliation.action === "none"
? environment
: { ...environment, customImageReconciliation });
});
router.delete("/environments/:id", async (req, res) => {
@@ -871,6 +885,8 @@ export function environmentRoutes(
assertCanAccessInstanceEnvironments(req);
const actor = getActorInfo(req);
const companyIdForSecrets = await resolveEnvironmentSecretContextCompanyId(req, environment.id, { required: false });
const companyIdForProbe = companyIdForSecrets
?? (environment.driver === "sandbox" ? await resolveCustomImageCompanyId(req) : null);
if (!companyIdForSecrets) {
const secretRefs = await collectEnvironmentSecretRefs({ db, environment });
if (secretRefs.length > 0) {
@@ -880,9 +896,10 @@ export function environmentRoutes(
}
}
const probe = await probeEnvironment(db, environment, {
companyId: companyIdForSecrets,
companyId: companyIdForProbe,
pluginWorkerManager: options.pluginWorkerManager,
applyCustomImageTemplate: environment.driver === "sandbox",
acquireSandboxRuntimeLease: environment.driver === "sandbox",
});
await logInstanceEnvironmentActivity({
actor,
+88 -22
View File
@@ -1,14 +1,50 @@
import { Router } from "express";
import { Router, type Request, type Response } from "express";
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { validate } from "../middleware/validate.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { inboxDismissalService, logActivity } from "../services/index.js";
const ITEM_KEY_RE = /^(approval|join|run|attention):.+$/;
const inboxDismissalSchema = z.object({
itemKey: z.string().trim().min(1).regex(/^(approval|join|run):.+$/, "Unsupported inbox item key"),
itemKey: z.string().trim().min(1).regex(ITEM_KEY_RE, "Unsupported inbox item key"),
kind: z.enum(["dismiss", "snooze"]).default("dismiss"),
snoozedUntil: z.string().trim().optional(),
}).superRefine((value, ctx) => {
if (value.kind === "dismiss") {
if (value.snoozedUntil != null) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "Dismissals must not include snoozedUntil" });
}
return;
}
if (!value.snoozedUntil) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "Snooze requires snoozedUntil" });
return;
}
const timestamp = new Date(value.snoozedUntil).getTime();
if (!Number.isFinite(timestamp)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "snoozedUntil must be an ISO timestamp" });
return;
}
if (timestamp <= Date.now()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "snoozedUntil must be in the future" });
}
});
function requireBoardUser(req: Request, res: Response) {
if (req.actor.type !== "board") {
res.status(403).json({ error: "Board authentication required" });
return null;
}
if (!req.actor.userId) {
res.status(403).json({ error: "Board user context required" });
return null;
}
return req.actor.userId;
}
export function inboxDismissalRoutes(db: Db) {
const router = Router();
const svc = inboxDismissalService(db);
@@ -16,15 +52,10 @@ export function inboxDismissalRoutes(db: Db) {
router.get("/companies/:companyId/inbox-dismissals", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Board authentication required" });
return;
}
if (!req.actor.userId) {
res.status(403).json({ error: "Board user context required" });
return;
}
const dismissals = await svc.list(companyId, req.actor.userId);
const userId = requireBoardUser(req, res);
if (!userId) return;
const dismissals = await svc.list(companyId, userId);
res.json(dismissals);
});
@@ -34,16 +65,12 @@ export function inboxDismissalRoutes(db: Db) {
async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Board authentication required" });
return;
}
if (!req.actor.userId) {
res.status(403).json({ error: "Board user context required" });
return;
}
const userId = requireBoardUser(req, res);
if (!userId) return;
const dismissal = await svc.dismiss(companyId, req.actor.userId, req.body.itemKey, new Date());
const dismissal = req.body.kind === "snooze"
? await svc.snooze(companyId, userId, req.body.itemKey, new Date(req.body.snoozedUntil))
: await svc.dismiss(companyId, userId, req.body.itemKey, new Date());
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
@@ -51,13 +78,15 @@ export function inboxDismissalRoutes(db: Db) {
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "inbox.dismissed",
action: dismissal.kind === "snooze" ? "inbox.snoozed" : "inbox.dismissed",
entityType: "company",
entityId: companyId,
details: {
userId: req.actor.userId,
userId,
itemKey: dismissal.itemKey,
kind: dismissal.kind,
dismissedAt: dismissal.dismissedAt,
snoozedUntil: dismissal.snoozedUntil,
},
});
@@ -65,5 +94,42 @@ export function inboxDismissalRoutes(db: Db) {
},
);
router.delete("/companies/:companyId/inbox-dismissals/:itemKey", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const userId = requireBoardUser(req, res);
if (!userId) return;
const itemKey = req.params.itemKey as string;
if (!ITEM_KEY_RE.test(itemKey)) {
res.status(400).json({ error: "Unsupported inbox item key" });
return;
}
const restored = await svc.restore(companyId, userId, itemKey);
if (restored) {
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "inbox.restored",
entityType: "company",
entityId: companyId,
details: {
userId,
itemKey: restored.itemKey,
kind: restored.kind,
dismissedAt: restored.dismissedAt,
snoozedUntil: restored.snoozedUntil,
},
});
}
res.status(204).send();
});
return router;
}
+1
View File
@@ -15,6 +15,7 @@ export { secretRoutes } from "./secrets.js";
export { costRoutes } from "./costs.js";
export { activityRoutes } from "./activity.js";
export { dashboardRoutes } from "./dashboard.js";
export { attentionRoutes } from "./attention.js";
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
export { sidebarPreferenceRoutes } from "./sidebar-preferences.js";
export { resourceMembershipRoutes } from "./resource-memberships.js";
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff. Show more