From 83abfa46f628e5ff6bd8ee333ec130aba4e5016c Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Tue, 22 Sep 2026 09:17:59 -0700
Subject: [PATCH 1/6] feat(ui): enable Grok in Cloud agent setup (#13791)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip manages AI agents and their execution settings.
> - The new-agent flow selects an adapter before configuring credentials
and a model.
> - Cloud uses one adapter policy for the picker and direct setup links.
> - That policy excludes Grok despite its existing adapter and xAI
connection support.
> - This change adds Grok to the Cloud policy.
> - Cloud users can configure Grok with the existing subscription or
API-key flow.
## Linked Issues or Issue Description
**What existing behavior does this improve?**
Agent creation on Cloud.
**Current behavior**
The Cloud picker offers Claude, Codex, and OpenCode. Direct Grok setup
links also fail the shared adapter check.
**Proposed behavior**
Offer Grok alongside the existing choices. Use the existing xAI
connection and managed sandbox setup.
**Reason and benefit**
Users can select an already-supported adapter through the Cloud creation
flow.
**Breaking changes**
None. Loaded and enabled checks still apply. Other excluded adapters
remain excluded.
## What Changed
- Add `grok_local` to the shared Cloud creation policy.
- Display four adapter choices in a 2×2 grid on desktop and mobile, and
reuse the theme-aware provider mark on the connection step so Grok is
visible in dark mode.
- Verify picker navigation and both Grok authentication methods through
sandbox setup, model testing, and agent creation.
- Verify that probe and hire payloads carry the xAI connection binding
without the entered API key.
- Update the agent configuration specification.
## Verification
- `cd ui && pnpm exec vitest run src/components/NewAgentDialog.test.tsx
src/pages/NewAgent.test.tsx
src/components/new-agent/AgentProviderConnection.test.tsx` — 69 tests
passed.
- Chromium checks against the production components and built stylesheet
— four cards occupy two rows and two columns at 1280px and 390px; the
connection step loads and displays the white Grok logo in dark mode and
the black logo in light mode.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- `pnpm --filter @paperclipai/ui build` — passed.
- `pnpm check:token-gates` — passed.
- `git diff origin/master...HEAD | gitleaks stdin --redact --no-banner`
— no leaks found; manual diff review found no private identifiers or
user data.
- `pnpm -r typecheck` and `pnpm build` — blocked at the existing runner
package because `cargo` is not installed locally.
- `pnpm test:run` — started locally, then stopped after the equivalent
CI suites passed.
- Initial [PR
CI](https://github.com/paperclipai/paperclip/actions/runs/35681351752)
passed, including full typecheck/build, general and serialized tests,
Rust checks, and all eight browser-test shards. One unchanged Cursor
test timed out on the first attempt; its five-test file passed locally
and the failed CI shard passed on retry.
- The [latest CI
run](https://github.com/paperclipai/paperclip/actions/runs/35683642812)
passed build, typecheck, all general and serialized tests, Rust checks,
and all eight browser-test shards. One unchanged
local-service-supervisor test failed its HTTP readiness check on the
first attempt; its six-test file passed locally, and the failed server
shard passed on retry.
- Live xAI login and model execution were not run; the setup tests mock
provider calls.
## Risks
Small UI policy change. The existing Grok adapter, authentication, and
secret storage paths remain in use. No schema or control-plane change is
required. Cloud must deploy a tenant-app release containing this change.
Revert the policy entry to hide Grok from new-agent setup again.
## Model Used
- OpenAI GPT-6 (Codex), with repository inspection, code editing, and
shell-based verification.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip
---
docs/specs/agent-config-ui.md | 4 +-
ui/src/components/NewAgentDialog.test.tsx | 11 +++-
.../new-agent/AgentBasicsDialog.tsx | 2 +-
.../new-agent/AgentProviderConnection.tsx | 9 +---
ui/src/lib/new-agent-adapters.ts | 1 +
ui/src/pages/NewAgent.test.tsx | 54 ++++++++++++++++++-
6 files changed, 68 insertions(+), 13 deletions(-)
diff --git a/docs/specs/agent-config-ui.md b/docs/specs/agent-config-ui.md
index 90d37537672..1b1090de778 100644
--- a/docs/specs/agent-config-ui.md
+++ b/docs/specs/agent-config-ui.md
@@ -2,7 +2,9 @@
## Current implementation (2026-09-07)
-The shipped new-agent flow starts from **Agents → New Agent**. A small dialog collects a name and an enabled adapter, then opens a setup page with numbered navigation. Claude and Codex have a subscription/API-key connection step. Configuration provides a searchable, free-text model selector and an environment selector; additional settings remain on the full agent page. **Finish setup** submits the existing governed hire request. Confirmation links to configuration and opens a new task with the created agent assigned; agents awaiting approval cannot be assigned work yet.
+The shipped new-agent flow starts from **Agents → New Agent**. A small dialog collects a name and an enabled adapter, then opens a setup page with numbered navigation. Claude, Codex, and Grok have a subscription/API-key connection step. Configuration provides a searchable, free-text model selector and an environment selector; additional settings remain on the full agent page. **Finish setup** submits the existing governed hire request. Confirmation links to configuration and opens a new task with the created agent assigned; agents awaiting approval cannot be assigned work yet.
+
+On Cloud, the new-agent picker and direct setup links allow Claude, Codex, OpenCode, and Grok. Four available adapters use a two-column grid on desktop and mobile. Grok uses the existing `grok_local` adapter with an xAI subscription or API-key connection and the managed sandbox environment. The picker and connection step share provider marks that adapt to light and dark mode. An adapter must also be loaded and enabled to be available.
Paperclip Runner offers native Codex (app server), Claude via ACPX, and OpenCode. The selected provider is passed through the existing runner configuration builder. Codex's default is the adapter catalog default. OpenCode and Pi require a provider/model ID; OpenRouter uses `openrouter//` and `OPENROUTER_API_KEY`. Entered keys are tested through the probe-only `testCredentials` field without storage. Finishing setup saves each key as an isolated user secret so a failed test cannot overwrite another agent’s credential, and an existing organization secret can also be bound. Agent configuration and revisions contain references, never the entered key.
diff --git a/ui/src/components/NewAgentDialog.test.tsx b/ui/src/components/NewAgentDialog.test.tsx
index 8ec88550b4d..3259e030f06 100644
--- a/ui/src/components/NewAgentDialog.test.tsx
+++ b/ui/src/components/NewAgentDialog.test.tsx
@@ -132,7 +132,7 @@ it.each([false, undefined])(
},
);
-it("offers only Claude, Codex, and OpenCode on Cloud, even with the runner enabled", async () => {
+it("offers Claude, Codex, OpenCode, and Grok on Cloud, even with the runner enabled", async () => {
await act(async () => {
cache.setQueryData(queryKeys.health, {
status: "ok",
@@ -160,8 +160,15 @@ it("offers only Claude, Codex, and OpenCode on Cloud, even with the runner enabl
[...document.querySelectorAll('input[type="radio"]')].map(
(input) => input.value,
),
- ).toEqual(["claude_local", "codex_local", "opencode_local"]);
+ ).toEqual(["claude_local", "codex_local", "opencode_local", "grok_local"]);
expect(document.body.textContent).not.toContain("CLI harness");
+ await act(async () =>
+ document.querySelector('input[value="grok_local"]')!.click(),
+ );
+ await click("Configure agent");
+ const query = new URL(state.navigate.mock.calls[0][0], "http://local").searchParams;
+ expect(query.get("adapterType")).toBe("grok_local");
+ expect(query.get("name")).toBe("Ada & Co");
});
it("keeps agent-only invitations reachable from the new-agent flow", async () => {
diff --git a/ui/src/components/new-agent/AgentBasicsDialog.tsx b/ui/src/components/new-agent/AgentBasicsDialog.tsx
index de8a3f7a841..173e1cd248d 100644
--- a/ui/src/components/new-agent/AgentBasicsDialog.tsx
+++ b/ui/src/components/new-agent/AgentBasicsDialog.tsx
@@ -226,7 +226,7 @@ export function AgentBasicsDialog({
{error.message}
)}
-
+
{choices.map((adapter) => {
const display = getAdapterDisplay(adapter.type);
return (
diff --git a/ui/src/components/new-agent/AgentProviderConnection.tsx b/ui/src/components/new-agent/AgentProviderConnection.tsx
index d51b1c04220..137adf39502 100644
--- a/ui/src/components/new-agent/AgentProviderConnection.tsx
+++ b/ui/src/components/new-agent/AgentProviderConnection.tsx
@@ -12,6 +12,7 @@ import {
import { agentsApi } from "@/api/agents";
import { queryKeys } from "@/lib/queryKeys";
import { AdapterLoginPanel } from "../AgentConfigForm";
+import { AdapterMark } from "./AgentBasicsDialog";
import {
LocalProviderLoginInstructions,
OnboardingCardField,
@@ -222,13 +223,7 @@ export function AgentProviderConnection({
{
id: adapterType,
label: provider,
- icon: (
-
- ),
+ icon:
,
},
]}
mode={method}
diff --git a/ui/src/lib/new-agent-adapters.ts b/ui/src/lib/new-agent-adapters.ts
index a6e8e4d00fb..6ca31f52d24 100644
--- a/ui/src/lib/new-agent-adapters.ts
+++ b/ui/src/lib/new-agent-adapters.ts
@@ -2,6 +2,7 @@ const CLOUD_ADAPTERS = new Set([
"claude_local",
"codex_local",
"opencode_local",
+ "grok_local",
]);
/** Creation policy shared by the picker and direct setup links. */
diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx
index d71f1ef6a77..c7a50167c8e 100644
--- a/ui/src/pages/NewAgent.test.tsx
+++ b/ui/src/pages/NewAgent.test.tsx
@@ -80,8 +80,8 @@ vi.mock("@/components/AgentConfigForm", () => ({
onChange={(e) => onChange(e.target.value)}
/>
),
- AdapterLoginPanel: ({ onStored }: { onStored: (id: string) => void }) => (
-
onStored("stored-claim")}>
+ AdapterLoginPanel: ({ onConnected }: { onConnected: (id: string) => void }) => (
+ onConnected("login-session")}>
Complete subscription login
),
@@ -213,6 +213,56 @@ describe("New agent setup", () => {
expect(container.textContent).toContain("This adapter is unavailable");
expect(api.hire).not.toHaveBeenCalled();
});
+ it.each(["subscription", "api_key"])("configures Grok on Cloud with an xAI %s connection", async (method) => {
+ cache.setQueryData(queryKeys.health, {
+ status: "ok",
+ cloud: { managed: true },
+ });
+ envApi.list.mockResolvedValue([
+ { id: "sandbox-1", name: "Paperclip Cloud", driver: "sandbox", config: { provider: "daytona" } },
+ ]);
+ envApi.capabilities.mockResolvedValue({
+ sandboxProviders: { daytona: { supportsLoginPty: true } },
+ });
+ settings.get.mockResolvedValue({ defaultEnvironmentId: "sandbox-1" });
+ settings.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true });
+ api.getAdapterAuthSignal.mockResolvedValue({ status: "missing" });
+ api.testEnvironment.mockResolvedValue({ ...pass, adapterType: "grok_local" });
+ await render("grok_local");
+ expect(container.textContent).toContain("Connect Atlas to Grok");
+ if (method === "subscription") {
+ await click("GrokSubscription");
+ await click("Complete subscription login");
+ } else {
+ await click("Use API key insteadUse subscription insteadUse API key instead");
+ await click("GrokAPI");
+ await fill("API key", "example-test-secret");
+ await click("Connect");
+ expect(managedApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
+ provider: "xai", method: "api_key", apiKey: "example-test-secret",
+ }));
+ }
+ await fill("Model", "grok-code-fast-1");
+ await click("Run test");
+ const binding = { provider: "xai", method, mode: "responsible_user" };
+ expect(api.testEnvironment).toHaveBeenLastCalledWith("company-1", "grok_local", expect.objectContaining({
+ environmentId: "sandbox-1",
+ adapterConfig: expect.objectContaining({ model: "grok-code-fast-1" }),
+ aiConnection: binding,
+ testCredentials: {},
+ }));
+ await click("Finish setup");
+ expect(api.hire).toHaveBeenCalledTimes(1);
+ expect(api.hire.mock.calls[0][1]).toMatchObject({
+ adapterType: "grok_local",
+ defaultEnvironmentId: "sandbox-1",
+ adapterConfig: { model: "grok-code-fast-1" },
+ runtimeConfig: { aiConnection: binding, heartbeat: { enabled: false } },
+ });
+ expect(JSON.stringify(api.testEnvironment.mock.calls)).not.toContain("example-test-secret");
+ expect(JSON.stringify(api.hire.mock.calls)).not.toContain("example-test-secret");
+ expect(container.textContent).toContain("Your agent is ready");
+ });
it("sends Cursor Cloud repo/ref and transient API key, then saves an organization secret", async () => {
await render("cursor_cloud");
expect(container.querySelector('[aria-label="Model"]')).toBeNull();
From 213866fae094b4593686a51763fc4e0efd64c8f2 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 22 Sep 2026 12:44:53 -0500
Subject: [PATCH 2/6] chore(lockfile): refresh pnpm-lock.yaml (#13780)
Auto-generated lockfile refresh after dependencies changed on master.
This PR only updates pnpm-lock.yaml.
Co-authored-by: lockfile-bot
---
pnpm-lock.yaml | 8 ++------
1 file changed, 2 insertions(+), 6 deletions(-)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c34d669b3be..27c8de29c7a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -12,6 +12,7 @@ overrides:
react-dom: ^19.2.8
'@codemirror/state': ^6.7.2
'@codemirror/view': ^6.43.11
+ '@lezer/common': ^1.5.2
patchedDependencies:
'@agentclientprotocol/claude-agent-acp@0.73.0':
@@ -2962,9 +2963,6 @@ packages:
typescript:
optional: true
- '@lezer/common@1.5.1':
- resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
-
'@lezer/common@1.5.2':
resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==}
@@ -10803,8 +10801,6 @@ snapshots:
optionalDependencies:
typescript: 7.0.2
- '@lezer/common@1.5.1': {}
-
'@lezer/common@1.5.2': {}
'@lezer/cpp@1.1.6':
@@ -10827,7 +10823,7 @@ snapshots:
'@lezer/highlight@1.2.3':
dependencies:
- '@lezer/common': 1.5.1
+ '@lezer/common': 1.5.2
'@lezer/html@1.3.13':
dependencies:
From 110d176fc970939c5d9377aae83225785228ae12 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Tue, 22 Sep 2026 10:46:13 -0700
Subject: [PATCH 3/6] fix: preserve chat message bindings in review recovery
(#13818)
## Thinking Path
> - Paperclip manages AI agents and their work.
> - External chat messages can start work on tasks that remain in review.
> - Paperclip queues one recovery run when a task loses its review path.
> - That recovery keeps the chat source but loses the admitted message IDs.
> - The authorization check then rejects the recovery before execution starts.
> - This change retains the message IDs so the existing check can verify current access.
## Linked Issues or Issue Description
Refs #13809.
**What happened?**
A successful external-chat run can leave an active task in review without a maintained review path. Its automatic recovery then fails with `reviewed_chat_execution_binding_not_authorized`. The recovery context retains `chat:slack` but drops `wakeCommentIds`.
**Expected behavior**
An eligible recovery should retain its admitted message references and pass a new authorization check. Missing or revoked access must still prevent execution.
**Steps to reproduce**
1. Finish a chat run whose task remains in review with no maintained review path.
2. Build the bounded review recovery from that run's context.
3. Dispatch the recovery through the reviewed-chat authorization check.
The new regression tests fail before this patch. Related PR #13809 handles answered conversations that become idle. This patch handles recovery when the conversation remains active.
## What Changed
- Retain the admitted message batch for external-chat review recovery. Derive the current comment reference from that batch.
- Share the existing supported-provider selector between recovery and run-bound chat authorization. AgentMail remains on its separate email inbox path.
- Keep the existing authorization check. Do not copy prior checkout, authorization, session, or prompt state.
- Test Slack and Discord recovery, missing batches, revoked access, and changed task or company bindings.
- Document the recovery authorization contract.
## Verification
- The new tests reproduced the missing-message failure before the fix.
- Six focused suites passed: 257 tests covering review recovery, reviewed-chat authorization, issue liveness, Slack lifecycle, comment-wake batching, and external-chat waits. PostgreSQL integration tests ran against a temporary local PostgreSQL database.
- Focused test files: `review-path-recovery.test.ts`, `heartbeat-reviewed-chat-binding.integration.test.ts`, `heartbeat-issue-liveness-escalation.test.ts`, `slack-conversation-lifecycle.test.ts`, `heartbeat-comment-wake-batching.test.ts`, and `external-chat-wait.integration.test.ts`.
- Server TypeScript check passed with scratch configuration that resolves this checkout's workspace packages. Existing dependency links point to another checkout; the default check reports stale shared-type errors.
- `node scripts/check-module-boundaries.mjs` and `git diff --check` passed.
- `git diff | gitleaks stdin --redact --no-banner` passed. The diff was also checked for private identifiers and user data.
- [Full PR CI](https://github.com/paperclipai/paperclip/actions/runs/35760757895) passed on the latest commit, including build, workspace typecheck, general and serialized tests, Rust checks, and all eight browser shards. The unchanged local-service readiness test and agent-chat page-load assertion passed when their failed shards were retried. The local-service suite also passed locally (6 tests). The first, superseded run lost a chat runner; its stuck browser job was cancelled to unblock the current run.
- Full local workspace typecheck, tests, and build were not run. The machine has about 2 GiB free, and those commands include Rust builds. The full PR CI checks passed before merge.
## Risks
Small context-construction change. Dispatch still checks current execution ownership and access for every admitted message. The recovery remains bounded to one attempt per consumed path. No schema changes. Existing failed runs are not retried by this patch.
## Model Used
OpenAI GPT-6 (Codex), with tool use and code execution.
## Checklist
- [x] I have included a thinking path that traces from project context to this change
- [x] I have specified the model used (with version and capability details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before requesting merge
Co-Authored-By: Paperclip
---
doc/execution-semantics.md | 6 +
...-reviewed-chat-binding.integration.test.ts | 107 ++++++++++++++++--
.../native-runtime/chat-attachment-reuse.ts | 25 +---
.../native-runtime/external-chat-provider.ts | 16 +++
.../recovery/review-path-recovery.test.ts | 82 ++++++++++++++
.../services/recovery/review-path-recovery.ts | 15 ++-
6 files changed, 219 insertions(+), 32 deletions(-)
create mode 100644 server/src/services/native-runtime/external-chat-provider.ts
diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md
index 2c53d8024af..ab60fffa370 100644
--- a/doc/execution-semantics.md
+++ b/doc/execution-semantics.md
@@ -353,6 +353,12 @@ 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
+A bounded review-path recovery for a task from a supported external-chat
+provider retains the source run's admitted message IDs. It does not inherit checkout or authorization
+markers. Before dispatch, Paperclip verifies the recovery run's task ownership
+and current conversation, endpoint, and principal access for every message.
+Missing message references or revoked access still prevent execution.
+
### 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:
diff --git a/server/src/__tests__/heartbeat-reviewed-chat-binding.integration.test.ts b/server/src/__tests__/heartbeat-reviewed-chat-binding.integration.test.ts
index 88e4192bf12..bdb3c06c70a 100644
--- a/server/src/__tests__/heartbeat-reviewed-chat-binding.integration.test.ts
+++ b/server/src/__tests__/heartbeat-reviewed-chat-binding.integration.test.ts
@@ -30,14 +30,16 @@ import {
listAuthorizedChatAttachments,
resolveExternalChatResponseWaitAuthorization,
} from "../services/native-runtime/chat-attachment-reuse.js";
+import { decideIssueReviewPathRecovery } from "../services/recovery/review-path-recovery.js";
import { resolveCurrentWakeCommentsBinding } from "../services/native-runtime/current-wake-comments.js";
-describe("reviewed external-chat execution binding", () => {
+describe.each(["slack", "discord"] as const)("reviewed %s execution binding", (provider) => {
let temporary: Awaited>;
let db: ReturnType;
const companyId = randomUUID(),
agentId = randomUUID(),
issueId = randomUUID(),
+ otherIssueId = randomUUID(),
runId = randomUUID();
const endpointId = randomUUID(),
resourceId = randomUUID(),
@@ -49,7 +51,7 @@ describe("reviewed external-chat execution binding", () => {
userId = "reviewed-chat-user";
const context = {
issueId,
- source: "chat:discord",
+ source: `chat:${provider}`,
wakeReason: "External chat message received",
wakeCommentIds: [commentId],
commentId,
@@ -93,6 +95,15 @@ describe("reviewed external-chat execution binding", () => {
workMode: "standard",
assigneeAgentId: agentId,
});
+ await db.insert(issues).values({
+ id: otherIssueId,
+ companyId,
+ title: "Unrelated conversation",
+ issueNumber: 2,
+ identifier: "RCB-2",
+ status: "in_review",
+ assigneeAgentId: agentId,
+ });
await db
.insert(heartbeatRuns)
.values({
@@ -132,7 +143,7 @@ describe("reviewed external-chat execution binding", () => {
.values({
id: applicationId,
companyId,
- applicationKey: `chat:discord:${endpointId}`,
+ applicationKey: `chat:${provider}:${endpointId}`,
name: "Discord",
type: "chat",
status: "active",
@@ -144,7 +155,7 @@ describe("reviewed external-chat execution binding", () => {
companyId,
applicationId,
name: "Discord",
- uid: `chat-discord-${endpointId}`,
+ uid: `chat-${provider}-${endpointId}`,
connectionPurpose: "channel",
transport: "chat_sdk",
status: "active",
@@ -156,7 +167,7 @@ describe("reviewed external-chat execution binding", () => {
id: endpointId,
companyId,
connectionId,
- provider: "discord",
+ provider,
publicId: randomUUID(),
assignedAgentId: agentId,
status: "active",
@@ -193,7 +204,7 @@ describe("reviewed external-chat execution binding", () => {
.values({
id: principalId,
companyId,
- provider: "discord",
+ provider,
providerAccountId: "guild-1",
externalId: "user-1",
kind: "user",
@@ -296,7 +307,7 @@ describe("reviewed external-chat execution binding", () => {
expect(wake).toMatchObject({
checkedOutByHarness: false,
externalChatExecutionBound: true,
- externalChatProvider: "discord",
+ externalChatProvider: provider,
});
const prompt = renderPaperclipWakePrompt(wake);
expect(prompt).toContain("not a checkout, approval");
@@ -304,6 +315,84 @@ describe("reviewed external-chat execution binding", () => {
expect(prompt).not.toContain("checked out the issue for this run");
});
+ async function withReviewRecovery(
+ check: (recovery: Record) => Promise,
+ ) {
+ const decision = decideIssueReviewPathRecovery({
+ issueId,
+ sourceRunId: randomUUID(),
+ assigneeAgentId: agentId,
+ contextSnapshot: {
+ ...context,
+ paperclipHarnessCheckedOut: true,
+ paperclipExternalChatExecutionBound: true,
+ },
+ reviewAttention: { state: "stalled", paths: [], reason: "Review path consumed" },
+ existingWake: false,
+ });
+ expect(decision.kind).toBe("enqueue");
+ if (decision.kind !== "enqueue") return;
+ await db.update(heartbeatRuns)
+ .set({ contextSnapshot: decision.contextSnapshot })
+ .where(eq(heartbeatRuns.id, runId));
+ try {
+ await check(decision.contextSnapshot);
+ } finally {
+ await db.update(heartbeatRuns)
+ .set({ contextSnapshot: context })
+ .where(eq(heartbeatRuns.id, runId));
+ }
+ }
+
+ it("reauthorizes a review recovery from its retained message batch without approving the task", async () => {
+ const [before] = await db.select().from(issues).where(eq(issues.id, issueId));
+ await withReviewRecovery(async (recovery) => {
+ expect(recovery).not.toHaveProperty("paperclipHarnessCheckedOut");
+ expect(recovery).not.toHaveProperty("paperclipExternalChatExecutionBound");
+ await expect(attest(recovery)).resolves.toBe(true);
+ expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toEqual(before);
+ expect((await db.select().from(issueThreadInteractions)
+ .where(eq(issueThreadInteractions.id, interactionId)))[0].status).toBe("pending");
+ });
+ });
+
+ it("denies recovery after access revocation or a changed company, task, or message binding", async () => {
+ await withReviewRecovery(async (recovery) => {
+ await expect(attest(recovery)).resolves.toBe(true);
+ await expect(attestReviewedExternalChatRun({
+ db, ...binding, companyId: randomUUID(), contextSnapshot: recovery,
+ })).resolves.toBe(false);
+ await expect(attestReviewedExternalChatRun({
+ db, ...binding, issueId: otherIssueId, contextSnapshot: recovery,
+ })).resolves.toBe(false);
+ await expect(attest({ ...recovery, wakeCommentIds: [randomUUID()] })).resolves.toBe(false);
+ await db.update(companyMemberships).set({ status: "suspended" })
+ .where(eq(companyMemberships.principalId, userId));
+ try {
+ await expect(attest(recovery)).resolves.toBe(false);
+ } finally {
+ await db.update(companyMemberships).set({ status: "active" })
+ .where(eq(companyMemberships.principalId, userId));
+ }
+ await db.update(chatEndpointResources).set({ enabled: false })
+ .where(eq(chatEndpointResources.id, resourceId));
+ try {
+ await expect(attest(recovery)).resolves.toBe(false);
+ } finally {
+ await db.update(chatEndpointResources).set({ enabled: true })
+ .where(eq(chatEndpointResources.id, resourceId));
+ }
+ await db.update(chatConversations).set({ issueId: otherIssueId })
+ .where(eq(chatConversations.id, conversationId));
+ try {
+ await expect(attest(recovery)).resolves.toBe(false);
+ } finally {
+ await db.update(chatConversations).set({ issueId })
+ .where(eq(chatConversations.id, conversationId));
+ }
+ });
+ });
+
it("does not trust a supplied marker, owner mismatch, different wake batch, or wrong provider", async () => {
await db
.update(issues)
@@ -338,7 +427,7 @@ describe("reviewed external-chat execution binding", () => {
} finally {
await db
.update(chatEndpoints)
- .set({ provider: "discord" })
+ .set({ provider })
.where(eq(chatEndpoints.id, endpointId));
}
});
@@ -493,7 +582,7 @@ describe("reviewed external-chat execution binding", () => {
await expect(
resolveCurrentWakeCommentsBinding(db, binding),
).resolves.toMatchObject({
- provider: "discord",
+ provider,
commentIds: [commentId],
});
await db
diff --git a/server/src/services/native-runtime/chat-attachment-reuse.ts b/server/src/services/native-runtime/chat-attachment-reuse.ts
index 61b7f1b7270..5afbd960805 100644
--- a/server/src/services/native-runtime/chat-attachment-reuse.ts
+++ b/server/src/services/native-runtime/chat-attachment-reuse.ts
@@ -29,6 +29,7 @@ import {
import { getStorageService } from "../../storage/index.js";
import type { StorageService } from "../../storage/types.js";
import { issueService } from "../issues.js";
+import { boundExternalChatProvider } from "./external-chat-provider.js";
import { resolveExternalChatQuestionResponse } from "./external-chat-question-response.js";
export const LIST_CHAT_ATTACHMENTS_TOOL_NAME = "list_chat_attachments";
@@ -418,17 +419,7 @@ export async function authorizeChatConversationForBoundRun(
context = answer.authorizationContext;
}
const source = typeof context.source === "string" ? context.source : "";
- const provider = [
- "slack",
- "github",
- "discord",
- "microsoft-teams",
- "telegram",
- "imessage-photon",
- ].find(
- (candidate) =>
- source === `chat:${candidate}` || source === `chat:${candidate}:recovery`,
- );
+ const provider = boundExternalChatProvider(source);
const commentIds = wakeCommentIds(context);
if (
!provider ||
@@ -549,17 +540,7 @@ function externalChatWaitCandidate(
): { provider: string; commentIds: string[] } | null {
const context = record(contextSnapshot);
const source = typeof context.source === "string" ? context.source : "";
- const provider = [
- "slack",
- "github",
- "discord",
- "microsoft-teams",
- "telegram",
- "imessage-photon",
- ].find(
- (candidate) =>
- source === `chat:${candidate}` || source === `chat:${candidate}:recovery`,
- );
+ const provider = boundExternalChatProvider(source);
const commentIds = wakeCommentIds(context);
const wake = record(context.paperclipWake);
const wakeIssue = record(wake.issue);
diff --git a/server/src/services/native-runtime/external-chat-provider.ts b/server/src/services/native-runtime/external-chat-provider.ts
new file mode 100644
index 00000000000..e16a3108360
--- /dev/null
+++ b/server/src/services/native-runtime/external-chat-provider.ts
@@ -0,0 +1,16 @@
+// Providers supported by the run-bound external-chat authorization path.
+// AgentMail uses the email inbox path and is not admitted by this boundary.
+const BOUND_EXTERNAL_CHAT_PROVIDERS = [
+ "slack",
+ "github",
+ "discord",
+ "microsoft-teams",
+ "telegram",
+ "imessage-photon",
+] as const;
+
+export function boundExternalChatProvider(source: unknown) {
+ return BOUND_EXTERNAL_CHAT_PROVIDERS.find(
+ (provider) => source === `chat:${provider}` || source === `chat:${provider}:recovery`,
+ ) ?? null;
+}
diff --git a/server/src/services/recovery/review-path-recovery.test.ts b/server/src/services/recovery/review-path-recovery.test.ts
index 16f27b5f996..5e651d4594a 100644
--- a/server/src/services/recovery/review-path-recovery.test.ts
+++ b/server/src/services/recovery/review-path-recovery.test.ts
@@ -57,6 +57,88 @@ describe("review-path recovery", () => {
expect(duplicate).toEqual({ kind: "skip", reason: "review-path recovery wake already exists" });
});
+ it.each(["chat:slack", "chat:slack:recovery", "chat:discord"])(
+ "retains the admitted message batch for %s without inheriting authorization",
+ (source) => {
+ const decision = decideIssueReviewPathRecovery({
+ issueId: "issue-1",
+ sourceRunId: "run-1",
+ assigneeAgentId: "agent-1",
+ contextSnapshot: {
+ issueId: "issue-1",
+ source,
+ wakeCommentIds: ["comment-1", "comment-2", "comment-1"],
+ wakeCommentId: "stale-comment",
+ commentId: "stale-comment",
+ paperclipHarnessCheckedOut: true,
+ paperclipExternalChatExecutionBound: true,
+ paperclipWake: { checkedOutByHarness: true },
+ sessionId: "old-session",
+ instruction: "old instruction",
+ },
+ reviewAttention: stalled,
+ existingWake: false,
+ });
+ expect(decision.kind).toBe("enqueue");
+ if (decision.kind !== "enqueue") return;
+ expect(decision.contextSnapshot).toMatchObject({
+ issueId: "issue-1",
+ source,
+ wakeReason: ISSUE_REVIEW_PATH_LOST_WAKE_REASON,
+ wakeCommentIds: ["comment-1", "comment-2"],
+ wakeCommentId: "comment-2",
+ commentId: "comment-2",
+ });
+ for (const key of [
+ "paperclipHarnessCheckedOut",
+ "paperclipExternalChatExecutionBound",
+ "paperclipWake",
+ "sessionId",
+ ]) expect(decision.contextSnapshot).not.toHaveProperty(key);
+ expect(decision.contextSnapshot.instruction).not.toBe("old instruction");
+ },
+ );
+
+ it.each([undefined, null, "comment-1", [], [null, 1, " "]])(
+ "does not invent an admitted message batch from invalid input %j",
+ (wakeCommentIds) => {
+ const decision = decideIssueReviewPathRecovery({
+ issueId: "issue-1",
+ sourceRunId: "run-1",
+ assigneeAgentId: "agent-1",
+ contextSnapshot: {
+ source: "chat:slack",
+ wakeCommentIds,
+ wakeCommentId: "unproven-comment",
+ },
+ reviewAttention: stalled,
+ existingWake: false,
+ });
+ expect(decision.kind).toBe("enqueue");
+ if (decision.kind !== "enqueue") return;
+ expect(decision.contextSnapshot.source).toBe("chat:slack");
+ expect(decision.contextSnapshot).not.toHaveProperty("wakeCommentIds");
+ expect(decision.contextSnapshot).not.toHaveProperty("wakeCommentId");
+ },
+ );
+
+ it.each(["issue.comment", "chat:agentmail", "chat:agentmail:recovery"])(
+ "does not carry a chat batch for unsupported source %s",
+ (source) => {
+ const decision = decideIssueReviewPathRecovery({
+ issueId: "issue-1",
+ sourceRunId: "run-1",
+ assigneeAgentId: "agent-1",
+ contextSnapshot: { source, wakeCommentIds: ["comment-1"] },
+ reviewAttention: stalled,
+ existingWake: false,
+ });
+ expect(decision.kind).toBe("enqueue");
+ if (decision.kind !== "enqueue") return;
+ expect(decision.contextSnapshot).not.toHaveProperty("wakeCommentIds");
+ },
+ );
+
it("does not requeue when the bounded recovery run also ends pathless", () => {
const decision = decideIssueReviewPathRecovery({
issueId: "issue-1",
diff --git a/server/src/services/recovery/review-path-recovery.ts b/server/src/services/recovery/review-path-recovery.ts
index 27ef24234f7..afcd315647e 100644
--- a/server/src/services/recovery/review-path-recovery.ts
+++ b/server/src/services/recovery/review-path-recovery.ts
@@ -1,5 +1,7 @@
import { createHash } from "node:crypto";
import type { IssueReviewAttention } from "@paperclipai/shared";
+import { boundExternalChatProvider } from "../native-runtime/external-chat-provider.js";
+import { extractWakeCommentIds } from "../../modules/run-dispatch/index.js";
import { withRecoveryContext } from "./status-only-context.js";
export const ISSUE_REVIEW_PATH_LOST_WAKE_REASON = "issue_review_path_lost";
@@ -100,6 +102,9 @@ export function decideIssueReviewPathRecovery(input: {
});
if (input.existingWake) return { kind: "skip", reason: "review-path recovery wake already exists" };
+ const source = readNonEmptyString(context.source) ?? "heartbeat.review_path_disposition";
+ const chatCommentIds = boundExternalChatProvider(source) ? extractWakeCommentIds(context) : [];
+
const payload = withRecoveryContext({
issueId: input.issueId,
taskId: input.issueId,
@@ -119,7 +124,15 @@ export function decideIssueReviewPathRecovery(input: {
contextSnapshot: withRecoveryContext({
...payload,
wakeReason: ISSUE_REVIEW_PATH_LOST_WAKE_REASON,
- source: readNonEmptyString(context.source) ?? "heartbeat.review_path_disposition",
+ source,
+ // Keep the admitted message references, not the source run's authority.
+ // Dispatch must prove the new run owns this task and recheck current
+ // endpoint, conversation, and principal access for the entire batch.
+ ...(chatCommentIds.length > 0 ? {
+ wakeCommentIds: chatCommentIds,
+ wakeCommentId: chatCommentIds.at(-1),
+ commentId: chatCommentIds.at(-1),
+ } : {}),
}, "normal_model"),
};
}
From 74a9730acb5a0cb78331194dc940e8d639406bba Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Tue, 22 Sep 2026 13:01:45 -0500
Subject: [PATCH 4/6] fix: continue native agent chats after worker loss
(#13813)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent Chat uses native workers to run Claude and Codex
conversations.
> - A worker crash leaves a cleanup hold because its provider did not
acknowledge suspension.
> - A new user message must not reuse that unverified session or repeat
old tool calls.
> - The existing continuation path can preserve history and start a
fresh session, but local cleanup ownership remained held.
> - This pull request verifies the stopped local owners and releases
only their cleanup hold for a new user turn.
## Linked Issues or Issue Description
Refs #13775.
**What happened?**
After a native worker crashed, both providers retained cleanup
quarantine. A saved plan survived, but the conversation could not
produce another answer.
**Expected behavior**
Once the old worker and provider process groups have stopped, a new user
message can continue in a fresh session with the saved work and prior
action history.
**Steps to reproduce**
Run the opt-in `agent-chat-qualification` suite with case
`worker-crash-retry` on native Codex and native Claude. The fixture
saves a plan, kills the exact worker through a Linux pidfd, releases a
local read-only brief, and sends a new message.
## What Changed
- Verify the exact local worker stop receipt, provider identity
receipts, released leases, and retained state before retiring a native
cleanup hold.
- Recheck process liveness and state before admission. Keep the old run
and durable session files intact.
- Use the existing explicit conversation continuation path. Generic
Retry remains blocked for cleanup quarantine, including on the old
failed-run marker after a successful continuation.
- Extend the live oracle to require a successful fresh session, correct
predecessor context, unchanged plan, one original message, and one
answer containing a reference introduced after the crash.
- Add physical-proof and database-backed admission tests. Document the
precise qualification scope.
## Verification
- Live Product E2E: **2/2 passed**, **2/2 cleanup passed**, with real
native `gpt-5.6-sol` and `claude-sonnet-5`, Chromium, server, database,
and public APIs.
- Core recovery proof source:
`3592b04c2bc76e23795fcdf964720e38a409dc4d`. Suite definition version 8:
`9867367994d81a0c726956d91f2c7fddab6417a12f41b5cef3f7e62f3be417da`.
- [Core recovery
campaign](https://github.com/paperclipai/paperclip/actions/runs/35741746990)
· [Public evidence
report](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-35741746990-1/).
- Both cells verify the real crash boundary, blocked generic Retry,
unchanged saved plan, one original prompt, one fresh successor with
predecessor context, and one run-attributed answer containing the
post-crash reference. Billing coverage is partial because the crashed
runs did not report complete usage; missing cost is not zero cost.
- [Master
baseline](https://github.com/paperclipai/paperclip/actions/runs/35737364443):
both providers stopped at quarantine, with cleanup passing.
- [Follow-up baseline without the
fix](https://github.com/paperclipai/paperclip/actions/runs/35738638866):
Codex produced a complete red result. Claude reached the same error, but
its artifact upload was canceled.
- [Complete Claude baseline with the same version-8
definition](https://github.com/paperclipai/paperclip/actions/runs/35740555177):
red at cleanup quarantine, cleanup passed, source
`6479a90c5754044356b39a9278b9a3e92ce8e55e`.
- Earlier candidate attempts remain retained:
[first](https://github.com/paperclipai/paperclip/actions/runs/35738449214)
passed Codex and found a Claude fixture wait race;
[second](https://github.com/paperclipai/paperclip/actions/runs/35740408065)
exposed the normalized session-open receipt mismatch. Both corrections
are in the final source.
- Targeted server suites: 570 passed before the final two additional
receipt regression cases. The physical-proof suite, including those
cases, passed 46/46. Eval oracle and catalog: 40 passed. Server and
Product E2E typechecks passed.
- **All 54 PR checks passed on final head `db6f775df`**, including
repository typecheck, build, tests, browser shards, and canary dry run.
The canary job required one retry after its runner received a shutdown
signal. On the earlier core proof head, two timing-sensitive tests
passed in isolation and on a single CI retry.
- Final UI regression checks: 5 passed; UI typecheck and token gates
passed. Updated eval oracle/catalog: 40 passed; eval typecheck passed.
- Final version-9 two-provider campaign: **2/2 passed, 2/2 cleanup
passed**, including the browser assertion that the quarantined
historical run never regains Try again. [Final
campaign](https://github.com/paperclipai/paperclip/actions/runs/35747416013),
source `db6f775dfff405e1514ec02fedb0450d42c7dad2`, definition hash
`bf5abf1cc45cb6dad4e082fbf818b8fa0f4d8c282776a7e98762b22919eadab7`.
[Final public evidence
report](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-35747416013-1/).
Final screenshots and retained state inspected for both providers; cost
coverage remains partial.
## Risks
- Missing or conflicting stop evidence keeps the conversation blocked.
This change does not kill an unverified process.
- This qualifies new user input after local worker loss. It does not
enable automatic replay, exact-session recovery, remote crash recovery,
or native onboarding defaults.
- Old action outcomes remain part of the continuation. A process exit is
not proof that an action did not happen.
- No schema migration or production prompt change.
## Model Used
OpenAI Codex, GPT-6. The runtime does not expose a more specific model
identifier or context-window size. Used reasoning, repository
inspection, code editing, shell tools, and test execution.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip
---
.../explicit-native-continuation.test.ts | 27 ++++
.../services/explicit-native-continuation.ts | 19 +++
.../native-session-executor.test.ts | 81 ++++++++++++
.../native-runtime/native-session-executor.ts | 118 +++++++++++++++++-
tests/runner-e2e/README.md | 26 ++--
tests/runner-e2e/catalog.ts | 2 +-
tests/runner-e2e/chat-qualification.test.ts | 6 +-
tests/runner-e2e/chat-qualification.ts | 31 ++---
ui/src/components/TaskChatThread.test.tsx | 14 +++
ui/src/components/TaskChatThread.tsx | 1 +
.../task-chat/TaskChatThreadView.tsx | 2 +-
.../components/task-chat/task-chat-model.ts | 2 +
12 files changed, 298 insertions(+), 31 deletions(-)
diff --git a/server/src/services/explicit-native-continuation.test.ts b/server/src/services/explicit-native-continuation.test.ts
index 4c02a743e2e..936a81c1a8b 100644
--- a/server/src/services/explicit-native-continuation.test.ts
+++ b/server/src/services/explicit-native-continuation.test.ts
@@ -1,3 +1,4 @@
+import * as nativeExecutor from "./native-runtime/native-session-executor.js";
import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
import { recordNativeLocalProcessStop, hasNativeLocalProcessStop, PROCESS_START_REQUESTED } from "./native-local-process-stop.js";
import { remoteTerminationReceipt } from "./remote-execution-termination.js";
@@ -374,6 +375,32 @@ const support = await getEmbeddedPostgresTestSupport();
return result;
});
+ it.each(["verified", "unproven", "changed", "dry_run", "retry", "duplicate"])(
+ "requires exact local cleanup for a new turn after worker loss (%s)", async mode => {
+ const f = await seed();
+ await db.update(heartbeatRuns).set({ errorCode: "native_session_cleanup_quarantined" }).where(eq(heartbeatRuns.id, f.sourceRunId));
+ const retire = vi.fn(() => mode !== "changed");
+ const verify = vi.spyOn(nativeExecutor, "verifyStoppedNativeSessionForContinuation").mockResolvedValue(
+ mode === "unproven" ? null : { evidence: { runId: f.sourceRunId, schema: "paperclip.stopped_native_conversation.v1" }, retire });
+ try {
+ const result = mode === "retry"
+ ? await db.transaction(tx => admitExplicitNativeContinuation({ ...f, db: tx as unknown as typeof db,
+ reason: "retry_failed_run", failedRunId: f.sourceRunId }))
+ : await admit(f, mode === "dry_run");
+ expect(Boolean(result)).toBe(["verified", "dry_run", "duplicate"].includes(mode));
+ expect(retire).toHaveBeenCalledTimes(["verified", "changed", "duplicate"].includes(mode) ? 1 : 0);
+ const actions = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
+ expect(Boolean(actions[0].evidence.explicitUserContinuation)).toBe(["verified", "duplicate"].includes(mode));
+ const events = await db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.runId, f.sourceRunId),
+ eq(heartbeatRunEvents.eventType, "native.stopped_conversation_verified")));
+ expect(events).toHaveLength(["verified", "duplicate"].includes(mode) ? 1 : 0);
+ if (mode === "duplicate") {
+ expect(await admit(f)).toBeNull();
+ expect(retire).toHaveBeenCalledTimes(1);
+ }
+ } finally { verify.mockRestore(); }
+ });
+
it.each(["suspended", "ready", "wrong_run", "wrong_thread", "active_provider", "pending_tool", "pending_output", "missing_state", "new_launch"])(
"recovers a historical run without process metadata only from exact suspended state (%s)", async kind => {
const f = await seed();
diff --git a/server/src/services/explicit-native-continuation.ts b/server/src/services/explicit-native-continuation.ts
index dc7bf15d655..2063bcf68a4 100644
--- a/server/src/services/explicit-native-continuation.ts
+++ b/server/src/services/explicit-native-continuation.ts
@@ -1,3 +1,4 @@
+import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
import { readQueuedInteractionResponse } from "./queued-interaction-response.js";
import { isCancelledNativeStartup } from "./cancelled-native-startup.js";
import { hasNativeLocalProcessStop, hasHistoricalSuspendedNativeSession } from "./native-local-process-stop.js";
@@ -146,6 +147,7 @@ export async function admitExplicitNativeContinuation(input: {
if (pendingInteraction || pendingApproval) return blocked("decision_pending", "A pending approval or question must be resolved before this message can start.");
const sources: Run[] = [];
+ const stoppedSessions: Array<{ evidence: Record; retire: () => boolean }> = [];
const cancelledStartupIds = new Set();
for (const action of actions) {
const runId = action.evidence.runId ?? action.evidence.sourceRunId;
@@ -235,6 +237,14 @@ export async function admitExplicitNativeContinuation(input: {
if (run.processGroupId && !processStopped(-run.processGroupId)) return blocked("process_running", "Waiting for the previous process to stop. Your message will start automatically.");
}
}
+ if (!remote && run.runtimeMode === "native" && run.errorCode === "native_session_cleanup_quarantined") {
+ // This is new user input, never permission to retry the interrupted turn.
+ if (retry) return blocked("cleanup_quarantined", "Send a new message after the previous provider has stopped.");
+ const { verifyStoppedNativeSessionForContinuation } = await import("./native-runtime/native-session-executor.js");
+ const stopped = await verifyStoppedNativeSessionForContinuation(db, run);
+ if (!stopped) return blocked("local_cleanup", "Waiting for the previous provider and its tools to stop. Your message is saved.");
+ stoppedSessions.push(stopped);
+ }
sources.push(run);
}
const nativeSources = sources.filter(run => run.runtimeMode === "native");
@@ -253,6 +263,15 @@ export async function admitExplicitNativeContinuation(input: {
context: { previousRunId: previous.id, wakeCommentId: commentId },
summary: null, exposeLowTrustRaw: false });
if (input.dryRun) return { previousRunId: previous.id, commentId, ...(retry ? { failedRunId: input.failedRunId! } : {}) };
+ for (const stopped of stoppedSessions) {
+ if (!stopped.retire()) return blocked("local_cleanup", "The previous provider cleanup changed. Your message is saved.");
+ await appendHeartbeatRunEvent(db, {
+ companyId, runId: String(stopped.evidence.runId), agentId,
+ eventType: "native.stopped_conversation_verified", stream: "system", level: "info",
+ message: "The old runner and provider stopped. New user input will use a fresh session; prior action outcomes remain recorded.",
+ payload: stopped.evidence,
+ });
+ }
const authorization = { actorId, commentId, ...(response ? { interactionId: response.source.interactionId } : {}), ...(retry ? { failedRunId: input.failedRunId } : {}),
...(queuedInterrupt ? { queuedCommentInterruptId: input.queuedCommentInterruptId } : {}),
...(queuedRequest ? { queuedCommentRequestId: input.queuedCommentRequestId } : {}), runId: input.successorRunId,
diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts
index 1cc3797b21b..cab3d6de20d 100644
--- a/server/src/services/native-runtime/native-session-executor.test.ts
+++ b/server/src/services/native-runtime/native-session-executor.test.ts
@@ -29,6 +29,7 @@ import {
createPrpSemanticToolResultEnvelope,
validatePrpStructuredRunResult,
validatePrpEvent,
+ parseNativeExecutionInput,
type NativeExecutionInputV1,
type PrpEvent,
} from "@paperclipai/paperclip-runner";
@@ -253,6 +254,7 @@ import {
assertRemoteRunnerBuildMetadata,
nativeSessionFailureDisposition,
nativeFailedRunRetryStateIsSafe,
+ verifyStoppedNativeSessionForContinuation,
nativePreProviderRetryAfterCleanupStateIsSafe,
reconcileRetainedNativeSessionCleanup,
retainedNativeCleanupJournalMatches,
@@ -3853,6 +3855,85 @@ describe("retained native cleanup activation", () => {
});
});
+describe("stopped native conversation physical cleanup", () => {
+ it.each(["codex", "acpx"].flatMap(provider => [
+ "stopped", "provider_alive", "worker_alive", "missing_receipt", "wrong_receipt", "new_launch",
+ "foreign_run", "foreign_company", "foreign_runner", "remote", "unreleased", "changed_state", "changed_pid", "symlink", "startup_intent", "pending_identity", "wrong_schema", "replacement", "replacement_alive", "agent_alive", "checkpoint_owner_alive", "diagnostic_owner_alive", "normalized_session_receipt",
+ ].map(mode => ({ provider, mode }))))("$provider $mode", async ({ provider, mode }) => {
+ const base = await mkdtemp(join(tmpdir(), "native-conversation-cleanup-"));
+ const previous = process.env.PAPERCLIP_RUNNER_STATE_DIR;
+ process.env.PAPERCLIP_RUNNER_STATE_DIR = base;
+ const input = parseNativeExecutionInput({ ...execution,
+ provider: provider === "codex" ? execution.provider : { kind: "acpx", agent: "claude", model: "claude-sonnet-5", permissionPolicy: "interactive",
+ profile: { driverKind: "acpx_runtime", protocolVersion: 1, acpxVersion: "0.13.1", agent: "claude", agentProfileVersion: 1,
+ agentServerPackage: "@zed-industries/claude-agent-acp", agentServerVersion: "1", agentRuntimePackage: null,
+ agentRuntimeVersion: null, commandDigest: "fixture" } },
+ session: { ...execution.session, driverKind: provider === "codex" ? "codex_app_server" : "acpx_runtime" },
+ });
+ const canonical = (value: any): string => value && typeof value === "object" && !Array.isArray(value)
+ ? `{${Object.entries(value).filter(([, v]) => v !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([k,v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`
+ : JSON.stringify(value);
+ const hash = (value: unknown) => createHash("sha256").update(canonical(value)).digest("hex");
+ const providerScope = input.provider.kind === "acpx" ? { kind: "acpx", agent: input.provider.agent, profile: input.provider.profile } : { kind: "codex" };
+ const root = join(base, hash({ schema: "paperclip.native-session-scope.v2", companyId: input.binding.companyId,
+ agentId: input.binding.agentId, workspace: { kind: "managed", executionWorkspaceId: input.binding.executionWorkspaceId },
+ provider: { driverKind: input.session.driverKind, identity: providerScope }, normalizedSessionId: input.session.normalizedSessionId }));
+ const identity = { runId: input.binding.runId, normalizedSessionId: input.session.normalizedSessionId,
+ runnerInstanceId: "runner-crashed", environmentLeaseId: "lease", turnId: "turn", itemId: "item" };
+ const event = { schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", sourceSeq: 1,
+ sourceEventId: "provider-identity", sourceInstanceId: identity.runnerInstanceId, runId: identity.runId,
+ normalizedSessionId: identity.normalizedSessionId, turnId: identity.turnId, itemId: identity.itemId,
+ priority: 0, emittedAt: new Date().toISOString(), eventType: "session.started",
+ payload: { providerDescriptor: { agentProcessId: mode === "agent_alive" ? process.pid : 99_999_996 }, providerSessionId: "provider-session", processId: mode === "provider_alive" ? process.pid : 99_999_998 } };
+ const replacement = { ...event, sourceSeq: 2, sourceEventId: "replacement", eventType: "session.reconciled",
+ payload: { ...event.payload, processId: mode === "replacement_alive" ? process.pid : 99_999_997, previousProcessId: event.payload.processId } };
+ const providerEvents = mode.startsWith("replacement") ? [event, replacement] : [event];
+ if (mode === "diagnostic_owner_alive") providerEvents.push({ ...event, eventType: "harness.diagnostic",
+ payload: { providerMethod: "acpx/process", role: "acp_agent", pid: process.pid } } as unknown as typeof event);
+ const providerState = { schema: mode === "wrong_schema" ? "unknown" : `paperclip.runner.${provider}-provider-state.${provider === "codex" ? "v1" : "v3"}`, lifecycle: "turn_active",
+ ...(mode === "startup_intent" ? { startupAttempt: { phase: "intent" } } : {}),
+ ...(mode === "pending_identity" ? { pendingEvents: [{ eventType: "session.started" }] } : {}),
+ };
+ const normalizedEvent = mode === "normalized_session_receipt" ? { ...event, turnId: undefined, itemId: undefined } : event;
+ const receipt = { sourceEventId: `${identity.runnerInstanceId}:${identity.runId}:1`,
+ sourcePayloadSha256: mode === "wrong_receipt" ? "wrong" : hash(normalizedEvent), payload: { prpEvent: normalizedEvent } };
+ const stop = { eventType: mode === "new_launch" ? "native.process_start_requested" : "native.local_process_stopped",
+ payload: { processPid: mode === "worker_alive" ? process.pid : 99_999_999, processGroupId: mode === "worker_alive" ? process.pid : 99_999_999 } };
+ let queryIndex = 0;
+ const db = { select() { const rows = [
+ [{ provider: mode === "remote" ? "daytona" : "local", releasedAt: mode === "unreleased" ? null : new Date() }],
+ [stop], mode === "missing_receipt" ? [] : [receipt, ...(mode.startsWith("replacement") ? [{ ...receipt, sourceEventId: `${identity.runnerInstanceId}:${identity.runId}:2`, sourcePayloadSha256: hash(replacement), payload: { prpEvent: replacement } }] : [])],
+ ][queryIndex++] ?? [];
+ const q: any = { from: () => q, where: () => q, orderBy: () => q, limit: () => q,
+ then: Promise.resolve(rows).then.bind(Promise.resolve(rows)) }; return q;
+ } } as unknown as Db;
+ const run = { id: input.binding.runId, companyId: mode === "foreign_company" ? "foreign" : input.binding.companyId,
+ agentId: input.binding.agentId, nativeIssueId: input.binding.issueId, runtimeMode: "native", status: "failed", finishedAt: new Date(),
+ nativeSessionId: input.session.normalizedSessionId, runnerInstanceId: mode === "foreign_runner" ? "foreign" : identity.runnerInstanceId,
+ runnerProfileJson: { sessionCheckpoint: { process: { codexPid: mode === "checkpoint_owner_alive" ? process.pid : null } }, nativeExecutionInput: input, nativeToolContractFingerprint: nativeToolContractFingerprintForTarget("local") } } as unknown as typeof heartbeatRuns.$inferSelect;
+ try {
+ await mkdir(join(root, "runner"), { recursive: true });
+ await mkdir(join(root, "control-plane"), { recursive: true });
+ const runnerPath = join(root, "runner/runner-state.json");
+ await writeFile(join(root, "control-plane/control-plane-state.json"), JSON.stringify({ ...durableControlPlaneState(identity), committedEvents: providerEvents.map(payload => ({ envelope: { payload } })) }));
+ await writeFile(runnerPath, JSON.stringify(durableRunnerState({ ...identity, ...(mode === "foreign_run" ? { runId: "foreign" } : {}) }, "ready")));
+ await writeFile(join(root, `runner/${provider}-provider-state.json`), JSON.stringify(providerState));
+ if (mode === "symlink") { await rename(runnerPath, join(base, "external")); await symlink(join(base, "external"), runnerPath); }
+ const proof = await verifyStoppedNativeSessionForContinuation(db, run);
+ if (["stopped", "changed_state", "changed_pid", "replacement", "normalized_session_receipt"].includes(mode)) {
+ expect(proof).not.toBeNull();
+ if (mode === "changed_state") await writeFile(runnerPath, "{}");
+ const kill = mode === "changed_pid" ? vi.spyOn(process, "kill").mockReturnValue(true) : null;
+ try { expect(proof!.retire()).toBe(["stopped", "replacement", "normalized_session_receipt"].includes(mode)); } finally { kill?.mockRestore(); }
+ expect(await readFile(join(root, `runner/${provider}-provider-state.json`), "utf8")).toBe(JSON.stringify(providerState));
+ } else expect(proof).toBeNull();
+ } finally {
+ if (previous === undefined) delete process.env.PAPERCLIP_RUNNER_STATE_DIR; else process.env.PAPERCLIP_RUNNER_STATE_DIR = previous;
+ await rm(base, { recursive: true, force: true });
+ }
+ });
+});
+
describe("explicit failed native retry physical evidence", () => {
it.each([
"suspended",
diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts
index 8a8bf41cd73..41b0262134a 100644
--- a/server/src/services/native-runtime/native-session-executor.ts
+++ b/server/src/services/native-runtime/native-session-executor.ts
@@ -1756,7 +1756,7 @@ const CLEANUP_CANONICAL_FILES = [
] as const;
const CLEANUP_ACTIVATION_FILE = "cleanup-activation.json";
-function cleanupStateSnapshot(root: string) {
+function cleanupStateSnapshot(root: string, providerFile = "codex-provider-state.json") {
if (
![root, resolve(root, "runner"), resolve(root, "control-plane")].every(
isSafeNativeStateDirectory,
@@ -1764,7 +1764,8 @@ function cleanupStateSnapshot(root: string) {
) {
throw new Error("native_cleanup_maintenance_unproven");
}
- const bytes = CLEANUP_CANONICAL_FILES.map((file) =>
+ const files = [...CLEANUP_CANONICAL_FILES.slice(0, 2), `runner/${providerFile}`];
+ const bytes = files.map((file) =>
readBoundedNativeFile(
resolve(root, file),
NATIVE_RUNNER_STATE_MAX_BYTES,
@@ -2362,6 +2363,117 @@ export function rebaseRetainedNativeCleanupProviderHome(
}
}
+/** Physical cleanup for an explicitly authorized NEW conversation turn. Unlike
+ * automatic replacement, this does not certify or replay the interrupted actions.
+ * The caller holds the issue/controller/run locks and preserves their history.
+ * Retain the old durable root permanently; only the exact in-memory cleanup
+ * owner is retired, and the successor must use a fresh normalized session.
+ */
+export async function verifyStoppedNativeSessionForContinuation(
+ db: Db,
+ run: typeof heartbeatRuns.$inferSelect,
+): Promise<{ evidence: Record; retire: () => boolean } | null> {
+ try {
+ if (run.runtimeMode !== "native" || !["failed", "cancelled", "interrupted", "timed_out"].includes(run.status) ||
+ !run.finishedAt || !run.nativeIssueId || !run.nativeSessionId || !run.runnerInstanceId) return null;
+ const execution = parseNativeExecutionInput(record(run.runnerProfileJson).nativeExecutionInput);
+ if (!(["codex", "acpx"] as string[]).includes(execution.provider.kind) ||
+ execution.binding.runId !== run.id || execution.binding.companyId !== run.companyId ||
+ execution.binding.agentId !== run.agentId || execution.binding.issueId !== run.nativeIssueId ||
+ nativeSessionKey(execution) !== run.nativeSessionId ||
+ record(run.runnerProfileJson).nativeToolContractFingerprint !== nativeToolContractFingerprintForTarget("local")) return null;
+ const scope = nativeSessionScopeKey(execution);
+ const idle = () => !activeNativeSessions.has(run.id) && !executingRunnerdSessionScopes.has(scope) &&
+ !initializingSessionToolAuthorities.has(scope) && !warmNativeSessions.has(scope);
+ if (!idle()) return null;
+ const leases = await db.select().from(environmentLeases).where(and(
+ eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id)));
+ if (leases.some(lease => lease.provider !== "local" || !lease.releasedAt || lease.cleanupStatus === "failed")) return null;
+ const stopped = await readNativeLocalProcessStop(db, run.companyId, run.id);
+ if (!stopped) return null;
+ const root = scopedRunnerdStateRoot(execution);
+ const providerFile = runnerProviderStateFilename(execution);
+ const snapshot = cleanupStateSnapshot(root, providerFile);
+ const identity = record(snapshot.control.identity);
+ if (!durableIdentityMatchesExecution(identity, execution) || identity.runnerInstanceId !== run.runnerInstanceId ||
+ !["runnerInstanceId", "environmentLeaseId", "runId", "normalizedSessionId", "turnId", "itemId"].every(key =>
+ typeof identity[key] === "string" && identity[key] && snapshot.runner[key] === identity[key]) ||
+ !Array.isArray(snapshot.control.committedEvents)) return null;
+ // An incomplete provider launch can own a process that never emitted its
+ // session identity. It cannot be certified from an earlier owner's receipt.
+ if (snapshot.control.schema !== "paperclip.runner.durable.control-plane-state.v1" ||
+ snapshot.runner.schema !== RUNNERD_STATE_SCHEMA ||
+ snapshot.provider.schema !== (execution.provider.kind === "codex" ? "paperclip.runner.codex-provider-state.v1" : ACPX_PROVIDER_STATE_SCHEMA) ||
+ !["turn_active", "prepared", "suspended"].includes(String(snapshot.provider.lifecycle)) ||
+ snapshot.provider.startupAttempt != null) return null;
+ const pending = JSON.stringify([snapshot.runner.outbox, snapshot.provider.pendingEvents, snapshot.provider.queuedEvents]);
+ if (/session\.(started|resumed|reconciled)/.test(pending)) return null;
+ const events = snapshot.control.committedEvents.map(entry => record(record(record(entry).envelope).payload));
+ const providerEvents = events.filter(event => ["session.started", "session.resumed", "session.reconciled"].includes(String(event.eventType)));
+ if (!providerEvents.length) return null;
+ const receipts = await db.select().from(heartbeatRunEvents).where(and(
+ eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id),
+ eq(heartbeatRunEvents.sourceInstanceId, run.runnerInstanceId),
+ inArray(heartbeatRunEvents.eventType, ["session.started", "session.resumed", "session.reconciled", "harness.diagnostic"])));
+ const providerPids = new Set();
+ for (const event of providerEvents) {
+ const provider = record(event.payload);
+ if (!validatePrpEvent(event).ok || event.sourceKind !== "runner" || event.sourceInstanceId !== run.runnerInstanceId ||
+ event.runId !== run.id || event.normalizedSessionId !== run.nativeSessionId ||
+ typeof provider.providerSessionId !== "string" || !provider.providerSessionId ||
+ !cleanupProcessAbsent(provider.processId) || provider.processId === stopped.processPid) return null;
+ if (event.eventType === "session.reconciled" &&
+ (!providerPids.has(Number(provider.previousProcessId)) || !cleanupProcessAbsent(provider.previousProcessId))) return null;
+ const receipt = receipts.find(row => row.sourceEventId === `${run.runnerInstanceId}:${run.id}:${event.sourceSeq}`);
+ const durable = record(record(receipt?.payload).prpEvent);
+ const durableProvider = record(durable.payload);
+ if (!receipt || !validatePrpEvent(durable).ok || durable.sourceInstanceId !== event.sourceInstanceId ||
+ durable.runId !== run.id || durable.normalizedSessionId !== run.nativeSessionId ||
+ // Normalized session-open receipts omit turn/item IDs. Those belong
+ // to the durable runner identity checked above, not the open event.
+ durable.sourceSeq !== event.sourceSeq || durable.eventType !== event.eventType ||
+ receipt.sourcePayloadSha256 !== nativeSha256(durable) ||
+ (durableProvider.processId !== undefined && durableProvider.processId !== provider.processId) ||
+ (durableProvider.driverSessionId ?? durableProvider.providerSessionId) !== provider.providerSessionId) return null;
+ providerPids.add(provider.processId);
+ }
+ if (receipts.filter(row => record(record(row.payload).prpEvent).eventType !== "harness.diagnostic").length !== providerEvents.length) return null;
+ // ACPX's sidecar is not its agent process. Include separately recorded
+ // owners from both the durable journal and the independent DB/checkpoint.
+ // Extra evidence can only add a stop requirement, never waive one.
+ const owners: Record[] = [record(record(run.runnerProfileJson?.sessionCheckpoint).process),
+ snapshot.provider, record(snapshot.provider.identity), record(snapshot.provider.descriptor)];
+ for (const event of [...events, ...receipts.map(row => record(record(row.payload).prpEvent))]) {
+ const payload = record(event.payload);
+ if (["session.started", "session.resumed", "session.reconciled"].includes(String(event.eventType))) {
+ owners.push(payload, record(payload.providerDescriptor), record(payload.providerIdentity), record(payload.runtimeIdentity));
+ } else if (event.eventType === "harness.diagnostic" && payload.providerMethod === "acpx/process") {
+ owners.push({ agentPid: payload.pid });
+ }
+ }
+ for (const owner of owners) for (const key of [
+ "processId", "process_id", "processGroupId", "providerPid", "codexPid", "sidecarPid", "agentPid", "agentProcessId",
+ ]) {
+ const pid = owner[key];
+ if (pid === null || pid === undefined) continue;
+ if (!cleanupProcessAbsent(pid)) return null;
+ providerPids.add(pid);
+ }
+ const unchanged = () => idle() && cleanupProcessAbsent(stopped.processPid) &&
+ [...providerPids].every(cleanupProcessAbsent) && cleanupStateSnapshot(root, providerFile).fingerprint === snapshot.fingerprint;
+ return {
+ evidence: { schema: "paperclip.stopped_native_conversation.v1", runId: run.id,
+ nativeSessionId: run.nativeSessionId, runnerInstanceId: run.runnerInstanceId,
+ processPid: stopped.processPid, providerPids: [...providerPids], stateFingerprint: snapshot.fingerprint },
+ retire: () => {
+ try { return unchanged() && completeTerminatedLocalNativeSessionCleanup({
+ companyId: run.companyId, runId: run.id, runnerInstanceId: run.runnerInstanceId!,
+ }); } catch { return false; }
+ },
+ };
+ } catch { return null; }
+}
+
/** Prove that a crashed local Codex turn ended without external effects. No provider
* is launched and no retained state is rewritten. The successor must use a fresh
* normalized session; the old directory remains available for investigation. */
@@ -5909,7 +6021,7 @@ export function nativeSessionFailureSourceCode(
}
const NATIVE_CLEANUP_OPERATOR_RECOVERY_MESSAGE =
- "Verify the prior session's retained process ownership and checkpoint before a controlled server restart and explicit task retry. Clearing a task session does not resolve this quarantine. Automatic retries are stopped.";
+ "Send a new message to continue after Paperclip verifies that the previous provider and its tools have stopped. If cleanup cannot be verified, inspect the run and its environment. Clearing a task session does not resolve this quarantine. Automatic retries are stopped.";
const PROVIDER_DURABLE_EVENT_TYPES = new Set([
"harness.ready",
diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md
index 88c04773584..15b642759e3 100644
--- a/tests/runner-e2e/README.md
+++ b/tests/runner-e2e/README.md
@@ -903,12 +903,19 @@ successor-owned document. It budgets three provider runs.
Worker recovery requires Linux with Python pidfd support (as on the CI workers).
It kills only the exact running native worker PID from the public
run record, after verifying its command-line run ID, process start identity, and isolated
-local workspace. The signal uses an owned pidfd so PID reuse cannot retarget it. The real UI must show one Retry button. Clicking it must produce one
-successful attempt, consume the original message once, preserve the saved plan,
-and return a reference that was supplied only after the crash. This qualifies
-**user-initiated Retry after worker process loss**, not automatic recovery of
-arbitrary provider failures. It budgets two provider runs. Unexpected failures
-remain fatal; only the positively identified injected-fault run is exempted.
+local workspace. The signal uses an owned pidfd so PID reuse cannot retarget it.
+The UI must preserve the plan and withhold generic Retry while cleanup remains
+quarantined; the public retry API must return 409 without admitting another run.
+The fixture then releases the read-only brief wait and sends a new chat message
+that records the known saved-plan and interrupted-command outcomes. The server
+must verify that the recorded worker and provider process groups stopped before
+admitting exactly one successful fresh session. The answer must contain the
+reference supplied only after the crash, and the saved plan must remain unchanged. The old quarantined run must not regain
+a misleading Try again control after the fresh turn succeeds.
+This qualifies **explicit conversation continuation after local worker loss**.
+It does not qualify replay of uncertain actions, automatic recovery, remote worker
+loss, or exact-session resumption. It budgets two provider runs. Unexpected
+failures remain fatal; only the positively identified injected-fault run is exempted.
Answer quality uses two read-only turns over public fixture tasks and conflicting
historical comments. Exact structured propositions grade current blockers,
@@ -933,7 +940,6 @@ it does not certify a native option in the wizard, which is not offered yet.
Current proof and remaining decisions are recorded in
[the 21 September qualification report](QUALIFICATION-2026-09-21.md). In particular,
-worker loss currently quarantines both providers. The crash eval retains a red
-qualification result when no usable recovery exists, while also verifying that
-quarantine preserves the plan and rejects a misleading generic Retry. A passing
-quarantine guard is not a recovered workflow.
+the original worker-loss attempts quarantined both providers. The version 9 crash
+eval requires a usable fresh conversation after verified cleanup. A passing
+quarantine guard alone is not a recovered workflow.
diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts
index 4f39bec9214..28fa16c2d36 100644
--- a/tests/runner-e2e/catalog.ts
+++ b/tests/runner-e2e/catalog.ts
@@ -975,7 +975,7 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [
profiles: runnerProfiles.filter(profile => ["runner-codex", "runner-acpx-claude"].includes(profile.id))
.map(profile => productionStoryProfile(defaultPermissionProfile(profile))),
environments: [localEnvironment], tasks: chatQualificationTasks, expectedMatrixSize: 6,
- definitionMetadata: { version: 6, permissions: "production-defaults", instructions: "production", crashBoundary: "verified-native-worker-pid-at-file-wait", recovery: "user-visible-retry", answerGrading: "exact-grounded-propositions-plus-separate-semantic-review", scheduling: "explicit-only" },
+ definitionMetadata: { version: 9, permissions: "production-defaults", instructions: "production", crashBoundary: "verified-native-worker-pid-at-file-wait", recovery: "new-user-message-after-verified-cleanup", answerGrading: "exact-grounded-propositions-plus-separate-semantic-review", scheduling: "explicit-only" },
},
...(process.env.PAPERCLIP_RUNNER_E2E_CONNECTION_REVIEWS === "1" ? [connectionReviewSuite] : []),
{
diff --git a/tests/runner-e2e/chat-qualification.test.ts b/tests/runner-e2e/chat-qualification.test.ts
index ed45d52d80b..224e0b96ce2 100644
--- a/tests/runner-e2e/chat-qualification.test.ts
+++ b/tests/runner-e2e/chat-qualification.test.ts
@@ -5,12 +5,12 @@ import { assertActiveHandoff, assertAnswerFacts, assertCrashRecovered, assertWor
import { runnerMatrix } from "./catalog.js";
import { buildRunnerE2EProcessEnvironment } from "./harness-env.js";
-const run = { id: "old", agentId: "original", companyId: "company", runtimeMode: "native", status: "cancelled", errorCode: "issue_reassigned", finishedAt: "2026-09-21T00:01:00Z", contextSnapshot: { issueId: "task" } };
+const run = { id: "old", nativeSessionId: "old-session", agentId: "original", companyId: "company", runtimeMode: "native", status: "cancelled", errorCode: "issue_reassigned", finishedAt: "2026-09-21T00:01:00Z", contextSnapshot: { issueId: "task" } };
const successor = { ...run, id: "next", agentId: "successor", status: "succeeded", startedAt: "2026-09-21T00:01:01Z" };
const before = { id: "task", assigneeAgentId: "original", description: "Preserve scope", projectId: null };
const plan = { body: "Friday REFERENCE", latestRevisionId: "revision" };
const handoff = { before, after: { ...before, status: "done", assigneeAgentId: "successor" }, oldRun: run, boundary: { ...run, status: "running" }, runs: [run, successor], successorId: "successor", planBefore: plan, planAfter: plan, draft: { id: "draft", latestRevisionId: "v1", body: "REFERENCE" }, draftAfter: { id: "draft", body: "REFERENCE" }, draftRevisions: [{ id: "v1", body: "REFERENCE" }], output: { body: "REFERENCE", createdByAgentId: "successor" }, reference: "REFERENCE", audit: [{ action: "issue.reassigned", details: { source: "paperclip_runner_protocol" } }], taskIds: ["task"] };
-const recovery = { boundary: { ...run, status: "running" }, failed: { ...run, status: "failed" }, runs: [{ ...run, status: "failed" }, { ...successor, agentId: "original" }], issueId: "task", prompt: "Read my brief", comments: [{ body: "Read my brief" }, { body: "REFERENCE MARKER", authorAgentId: "original", createdByRunId: "next" }], reference: "REFERENCE", marker: "MARKER", planBefore: { body: "plan MARKER", latestRevisionId: "v1" }, planAfter: { body: "plan MARKER", latestRevisionId: "v1" } };
+const recovery = { boundary: { ...run, status: "running" }, failed: { ...run, status: "failed" }, runs: [{ ...run, status: "failed" }, { ...successor, agentId: "original", nativeSessionId: "fresh-session", contextSnapshot: { issueId: "task", previousRunId: "old", forceFreshSession: true } }], issueId: "task", prompt: "Read my brief", comments: [{ body: "Read my brief" }, { body: "REFERENCE MARKER", authorAgentId: "original", createdByRunId: "next" }], reference: "REFERENCE", marker: "MARKER", planBefore: { body: "plan MARKER", latestRevisionId: "v1" }, planAfter: { body: "plan MARKER", latestRevisionId: "v1" } };
describe("remaining native chat qualification", () => {
it("calibrates the pidfd helper against reuse and wrong-identity faults", () => {
@@ -44,6 +44,8 @@ describe("remaining native chat qualification", () => {
{ failed: { ...recovery.failed, id: "unrelated" } },
{ runs: [recovery.runs[0]!, { ...successor, status: "failed" }] },
{ runs: [recovery.runs[0]!, { ...successor, contextSnapshot: { issueId: "new-chat" } }] },
+ { runs: [recovery.runs[0]!, { ...recovery.runs[1]!, nativeSessionId: "old-session" }] },
+ { runs: [recovery.runs[0]!, { ...recovery.runs[1]!, contextSnapshot: { issueId: "task", previousRunId: "unrelated", forceFreshSession: true } }] },
{ comments: [...recovery.comments, recovery.comments[0]!] },
{ comments: [recovery.comments[0]!, { ...recovery.comments[1], createdByRunId: "old" }] },
{ comments: [recovery.comments[0]!, { ...recovery.comments[1], body: "MARKER" }] },
diff --git a/tests/runner-e2e/chat-qualification.ts b/tests/runner-e2e/chat-qualification.ts
index 102016ce82a..a57cb170d3b 100644
--- a/tests/runner-e2e/chat-qualification.ts
+++ b/tests/runner-e2e/chat-qualification.ts
@@ -63,6 +63,9 @@ export function assertCrashRecovered(e: {
expect(e.runs.find(r => r.id === e.failed.id)).toMatchObject({ status: "failed", contextSnapshot: { issueId: e.issueId } });
const retry = e.runs.find(r => r.id !== e.failed.id)!;
expect(retry).toMatchObject({ agentId: e.failed.agentId, status: "succeeded", runtimeMode: "native", contextSnapshot: { issueId: e.issueId } });
+ expect(retry.contextSnapshot).toMatchObject({ previousRunId: e.failed.id, forceFreshSession: true });
+ expect((retry as Row).nativeSessionId).not.toBe(e.failed.nativeSessionId);
+ expect((retry as Row).nativeSessionId).toBeTruthy();
expect(e.comments.filter(c => !c.authorAgentId && c.body === e.prompt)).toHaveLength(1);
const replies = e.comments.filter(c => c.authorAgentId && c.body.includes(e.marker));
expect(replies).toHaveLength(1);
@@ -145,7 +148,7 @@ export async function runWorkerCrash(context: Context) {
const processIdentity = JSON.parse(execFileSync("python3", [faultHelper, "inspect", String(boundary.processPid), boundary.id], { encoding: "utf8" }));
const command = execFileSync("ps", ["-p", String(boundary.processPid), "-o", "command="], { encoding: "utf8" });
assertWorkerIdentity(boundary, command, input.execution.environment.id);
- await input.evidence("chat-worker-fault.json", { boundary, planBefore, processIdentity, fault: "SIGKILL through verified Linux pidfd", recovery: "visible Retry button" });
+ await input.evidence("chat-worker-fault.json", { boundary, planBefore, processIdentity, fault: "SIGKILL through verified Linux pidfd", recovery: "new user message after verified provider cleanup" });
const fault = JSON.parse(execFileSync("python3", [faultHelper, "kill", String(boundary.processPid), boundary.id, processIdentity.startTicks], { encoding: "utf8" }));
expect(fault.signalled).toBe(true);
await input.evidence("chat-worker-fault-delivered.json", fault);
@@ -156,10 +159,11 @@ export async function runWorkerCrash(context: Context) {
let failed: Row = {};
await expect.poll(async () => {
failed = await input.api.get(`/api/heartbeat-runs/${boundary.id}`);
- return ["failed", "recovery_needed"].includes(failed.execution?.phase);
+ return failed.status === "failed" && failed.finishedAt && failed.nativePhase === "terminal_failure" &&
+ ["failed", "recovery_needed"].includes(failed.execution?.phase);
}, { timeout: 120_000 }).toBe(true);
await input.evidence("chat-worker-settled-failure.json", failed);
- await input.capture("worker-failed", "Worker loss before user Retry", "worker-failed.png");
+ await input.capture("worker-failed", "Worker loss before a fresh user turn", "worker-failed.png");
await writeFile(wait.gate, reference);
if (failed.errorCode === "native_session_cleanup_quarantined") {
// Preserve the red qualification result, but verify the stop is honest:
@@ -167,7 +171,7 @@ export async function runWorkerCrash(context: Context) {
await input.page.reload({ waitUntil: "domcontentloaded" });
await expect(input.page.getByTestId("task-chat-composer-input")).toBeVisible();
await expect(input.page.getByRole("status", { name: "Task recovery" }).getByRole("link", { name: "Inspect run" })).toBeVisible();
- await expect(input.page.getByRole("button", { name: "Retry", exact: true })).toHaveCount(0);
+ await expect(input.page.getByRole("button", { name: /^(Retry|Try again)$/ })).toHaveCount(0);
const refused = await input.api.request.post(`/api/agents/${input.fixtures.agent.id}/wakeup`, {
data: { failedRunId: failed.id, reason: "retry_failed_run" },
});
@@ -177,23 +181,22 @@ export async function runWorkerCrash(context: Context) {
await input.evidence("chat-worker-quarantine.json", { failed, retryStatus: refused.status(), savedPlanPreserved: true,
usableRecovery: false, classification: "product recovery boundary; no provider retry was admitted" });
await input.capture("worker-quarantined", "Worker recovery requires reconciliation", "worker-quarantined.png");
- throw new Error("worker_crash_recovery_unqualified: native_session_cleanup_quarantined requires explicit reconciliation; generic Retry is correctly unavailable");
+ // Reconcile the fixture's known outcomes through ordinary user input.
+ // The saved plan is verified above; the only interrupted command reads a
+ // local brief. The new provider must discover its reference from the file.
+ await sendChatMessage(input.page, `The prior worker failed. The plan is already saved and must remain unchanged. The interrupted command only waited for my local brief, which is now available. Continue in a fresh conversation turn: read the brief using node ${wait.scriptPath}, then reply with its reference and ${marker}. Do not create tasks or projects.`);
+ }
+ else {
+ throw new Error(`worker_crash_unexpected_recovery_boundary: ${failed.errorCode}`);
}
- const retry = input.page.getByRole("button", { name: "Retry", exact: true });
- await expect(retry).toHaveCount(1, { timeout: 60_000 });
- const [retryResponse] = await Promise.all([
- input.page.waitForResponse(response => response.request().method() === "POST" &&
- new URL(response.url()).pathname === `/api/agents/${input.fixtures.agent.id}/wakeup`),
- retry.click(),
- ]);
- await input.evidence("chat-worker-retry-response.json", { status: retryResponse.status(), body: await retryResponse.json() });
- expect(retryResponse.ok(), "The visible Retry must admit an attempt before waiting for its result").toBe(true);
await context.idle(2);
const e = { boundary, failed, runs: await context.allRuns(), issueId: context.issue().id,
prompt, comments: await context.comments(), reference, marker, planBefore,
planAfter: await input.api.get(`/api/issues/${context.issue().id}/documents/plan`) };
await input.evidence("chat-worker-recovery.json", e);
assertCrashRecovered(e);
+ // A fresh success must not re-enable Retry on the quarantined historical run.
+ await expect(input.page.getByTestId("task-chat-run-failed-try-again")).toHaveCount(0);
expect(await input.api.get(`/api/companies/${input.fixtures.company.id}/issues`)).toEqual([]);
} finally {
await writeFile(wait.gate, reference);
diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx
index d8c060cfa7e..1df30e6039c 100644
--- a/ui/src/components/TaskChatThread.test.tsx
+++ b/ui/src/components/TaskChatThread.test.tsx
@@ -1170,6 +1170,20 @@ describe("TaskChatThread runtime transcript selection", () => {
expect(onRetryFailedRun).toHaveBeenCalledExactlyOnceWith("failed-bootstrap");
});
+ it.each([false, true])("never offers the old quarantined run as Try again after continuation: %s", continued => {
+ render( {}} issueStatus="in_progress"
+ onRetryFailedRun={vi.fn()} linkedRuns={[{
+ runId: "quarantined", runtimeMode: "native", status: "failed", errorCode: "native_session_cleanup_quarantined",
+ agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner",
+ createdAt: "2026-08-25T18:00:00.000Z", startedAt: "2026-08-25T18:00:00.000Z", finishedAt: "2026-08-25T18:00:02.000Z",
+ ...(continued ? { execution: { phase: "completed" as const, label: "Continued in another run", cause: "native_session_cleanup_quarantined",
+ lastConfirmedActivityAt: null, retryAt: null, attempt: 2, maxAttempts: 3, recoveryOwner: null, nextAction: null,
+ permittedActions: ["inspect_run" as const], predecessorRunId: null, successorRunId: "fresh-run" } } : {}),
+ }]} />);
+ expect(container.textContent).toContain("Run failed");
+ expect(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')).toBeNull();
+ });
+
it("explains a native provider usage limit without exposing its error code", async () => {
const onRetryFailedRun = vi.fn();
render(
diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx
index 7047ab14184..6c468c25625 100644
--- a/ui/src/components/TaskChatThread.tsx
+++ b/ui/src/components/TaskChatThread.tsx
@@ -1614,6 +1614,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
tone: source.status === "cancelled" ? "neutral" : "error",
label,
detail,
+ retryable: code !== "native_session_cleanup_quarantined",
collapsible: true,
runId: source.id,
createdAtIso: finishedAt
diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx
index 52d900ecbfc..0046380e0e4 100644
--- a/ui/src/components/task-chat/TaskChatThreadView.tsx
+++ b/ui/src/components/task-chat/TaskChatThreadView.tsx
@@ -172,7 +172,7 @@ function renderItem(
onRetryFailedRun(item.runId!)
: onTryAgainNoLiveExecutionPath
diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts
index 9dbab8ada14..717b9b36b56 100644
--- a/ui/src/components/task-chat/task-chat-model.ts
+++ b/ui/src/components/task-chat/task-chat-model.ts
@@ -246,6 +246,8 @@ export interface TaskChatMarkerItem {
variant: "session_start" | "interrupted" | "turn_boundary";
label: string;
detail?: string;
+ /** False when the recorded run cannot be retried, even after the chat continues. */
+ retryable?: boolean;
/** Renders the marker as a quiet disclosure row with detail beneath it. */
collapsible?: boolean;
/** Expected cancellation is neutral; unexpected failures remain destructive. */
From 3d78e3a4ec1e4ac3c49d540482bdddb580ef6648 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Tue, 22 Sep 2026 13:07:13 -0500
Subject: [PATCH 5/6] fix(runner): keep warm sessions alive with managed GitHub
access (#13815)
## Thinking Path
> - Paperclip manages AI agents and their work.
> - The native Runner keeps a live provider process between task turns.
> - Managed GitHub access used a token tied to one run.
> - A new run forced Paperclip to replace that process to replace its
token.
> - This PR gives the session a stable credential transport and binds
each operation to the active run.
> - The agent can keep its process while Paperclip checks current
identity and grants.
## Linked Issues or Issue Description
Follow-up to #13738. Related credential-rotation work: #11770 and #8208
use process replacement for other adapter credentials; this change
applies to managed GitHub access in the native Runner.
**What happened?**
A configured GitHub connection forced a warm native provider process to
close at each new run. The saved conversation survived, but the live
process did not.
**Expected behavior**
Keep the warm provider process. Resolve GitHub access for the current
run when each command starts. Deny access while idle or after the run
ends.
**Steps to reproduce**
1. Configure managed GitHub access for a native Runner agent with a warm
session.
2. Complete a turn, then send another message to the same task.
3. Observe the provider process close with the reason `warm native
session configuration changed`.
**Paperclip version or commit**
Reproduced on master `8326e33ad`. Rebased onto `e3d8fb087` before
submission.
**Deployment mode**
Local and remote native execution, including the sandbox callback
bridge.
## What Changed
- Move configured native GitHub transport and launcher ownership from
the run to the provider session.
- Bind the broker only after the executor acquires session ownership.
Clear that binding when the run exits.
- Keep the shared live-run, identity, grant, and trust-policy checks for
each credential request.
- Reject wrong scopes, idle requests, and credential responses that
arrive after their run binding changes.
- Retire transport and launcher files with the provider session. Keep
anonymous commands available if bridge startup fails.
- Add red/green executor tests, real subprocess and callback-bridge
tests, and database checks. Update the runtime documentation.
## Verification
- Before the fix, both new local and remote warm-session reuse tests
failed.
- After the fix, 435 targeted tests passed across the executor, broker,
launcher, token, and database suites.
- A real long-lived test process kept the same PID and original
environment across two runs, including through the production callback
bridge on local test processes.
- Server typecheck and TypeScript compilation passed.
- Full workspace typecheck and build passed. Server typecheck passed
again after the review fix.
- The fallback-logging regression failed before the fix; all 9 broker
tests pass afterward.
- The exact chat sidebar browser scenario passed locally. The initial CI
timeout showed failed Vite module downloads; all eight browser shards
pass on the latest commit.
- All 53 latest-head checks passed, including the full CI test matrix
and security checks (two unrelated conditional checks skipped).
- The duplicate full local test run was stopped after CI passed; it is
not claimed as a completed local pass. Targeted local tests, workspace
typecheck/build, and the browser scenario passed.
- Greptile reviewed the latest commit at 5/5 with no unresolved
findings.
- No fresh paid provider or Daytona campaign has run for this change.
## Risks
- The broker now lives as long as the provider session. Tests cover idle
denial, late cleanup, late responses, shutdown, and failed startup.
- Its in-memory authority does not survive a controller restart.
Existing checkpoint and process-recovery rules still apply.
- Raw GitHub credentials remain confined to individual command
processes. The session transport token cannot select a different task,
agent, company, or run.
- No database migration or public API change.
## Model Used
OpenAI Codex, GPT-6, with reasoning, terminal tools, and code execution.
The exact serving model ID and context-window size are not exposed in
this session.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip
---
doc/execution-github-identity.md | 15 +-
.../github-operation-credentials.test.ts | 34 +++
.../heartbeat-github-launchers.test.ts | 16 +-
.../services/heartbeat-github-launchers.ts | 8 +-
server/src/services/heartbeat.ts | 286 ++++++++----------
.../native-github-access.test.ts | 128 ++++++++
.../native-runtime/native-github-access.ts | 146 +++++++++
.../native-session-executor.test.ts | 46 ++-
.../native-runtime/native-session-executor.ts | 59 +++-
9 files changed, 548 insertions(+), 190 deletions(-)
create mode 100644 server/src/services/native-runtime/native-github-access.test.ts
create mode 100644 server/src/services/native-runtime/native-github-access.ts
diff --git a/doc/execution-github-identity.md b/doc/execution-github-identity.md
index d86c82d95b7..8c6ef1e2a05 100644
--- a/doc/execution-github-identity.md
+++ b/doc/execution-github-identity.md
@@ -12,9 +12,13 @@ Delegated work and interactions persist their originating context. Retries retai
## Managed GitHub operations
-Executions with managed GitHub configured receive token-free `git` and `gh` launchers and a run-scoped capability. Each launcher invocation requests the active context through the authenticated runtime transport and resolves one eligible credential at operation start. A `gh` command's child Git processes inherit that command's captured identity. Later steering does not change already-started operations. When a subsequent run resumes a settled native conversation, the controller starts a fresh provider process with that run’s capability and rebinds its token-free launcher paths. The durable conversation and protected provider settings remain unchanged. Local and remote durable runners complete their bounded suspension before the controller releases the session for the next run, so a queued continuation cannot race unfinished cleanup.
+Executions with managed GitHub configured receive token-free `git` and `gh` launchers. Each launcher invocation resolves one eligible credential from the current run's accepted identity at operation start. A `gh` command's child Git processes inherit that command's captured identity; later steering does not change already-started operations.
-The broker endpoint rejects browser origins and session cookies, validates a distinct signed runtime scope, and rechecks the company, agent, and live run. Sandboxes relay the capability through the existing authenticated callback bridge. Tokens are returned only to the managed command process. They are not persisted in identity history or injected into the long-lived provider process.
+Native runners retain a session-owned broker and launcher path across warm turns. The provider keeps an opaque transport token, not a GitHub credential or the previous run's signed capability. After acquiring exclusive session ownership, the controller binds the broker to the current company, agent, task, and run. Requests cannot choose another run or responsible person. The broker rejects requests while idle and discards credential responses if their run binding changed during acquisition. Each operation still rechecks the live run, accepted identity, grants, and trust policy through the shared credential resolver. Changing run IDs alone no longer replaces the provider process; changes to authentication mode, provider credentials, permissions, or other session configuration retain their existing retirement rules.
+
+The session broker listens only on controller loopback and accepts only its authenticated GitHub credential operation. Remote executions reach it through the existing authenticated callback bridge. Its transport and launchers are retired with the provider session. If remote bridge startup fails, anonymous launchers keep ordinary work available; the next run retries setup with a fresh session. Controller restart/cold recovery still uses the existing checkpoint and process-recovery rules; the broker's in-memory authority is not persisted for adoption.
+
+Other adapters continue using the run-scoped signed capability and public broker endpoint. That endpoint rejects browser origins and session cookies, validates a distinct signed runtime scope, and rechecks the company, agent, and live run. GitHub credentials are returned only to the managed command process, never persisted in identity history or injected into the long-lived provider process.
Low-trust executions cannot receive raw GitHub credentials, including dedicated
agent tokens. The broker rechecks current agent, project, task, and retained run
@@ -123,6 +127,7 @@ Remote acceptance uses the existing paid runner workflow with a narrow selection
Identity history survives deletion of the originating agent or run, so surviving
subtasks and approvals retain their responsible person. The company foreign key and company-deletion service remove
-these company-scoped records when their company is deleted. Completed runs remove their managed launcher files
-before releasing a remote environment; same-run recovery retains them until the
-terminal boundary. Cleanup failures are logged and do not change the run result.
+these company-scoped records when their company is deleted. Run-scoped adapters remove their managed launcher files at the terminal boundary.
+Native warm sessions retain their token-free launchers and inactive broker until
+session retirement; environment deletion and orderly controller shutdown close
+idle owners first. Cleanup failures are logged and do not change the run result.
diff --git a/server/src/__tests__/github-operation-credentials.test.ts b/server/src/__tests__/github-operation-credentials.test.ts
index cd9c4cc10c2..02d1c864bcd 100644
--- a/server/src/__tests__/github-operation-credentials.test.ts
+++ b/server/src/__tests__/github-operation-credentials.test.ts
@@ -1,3 +1,4 @@
+import { createNativeGitHubAccess } from "../services/native-runtime/native-github-access.js";
import express from "express";
import request from "supertest";
import { runtimeConnectionIntentRoutes } from "../routes/connection-intents.js";
@@ -800,6 +801,39 @@ const support = await getEmbeddedPostgresTestSupport();
}
});
+ it("reuses a session broker across runs while rechecking live-run identity and revocation", async () => {
+ const input = await seed();
+ await grant(input, "A");
+ await grant(input, "B");
+ const broker = await createNativeGitHubAccess({
+ scope: input, target: null, cwd: process.cwd(), env: { PATH: process.env.PATH },
+ resolveCredentials: (binding) => resolveGitHubOperationCredentials(db, binding),
+ });
+ const post = () => fetch(`${broker.env.PAPERCLIP_GITHUB_BROKER_URL}/runtime-tools/github/credentials`, {
+ method: "POST", headers: { authorization: `Bearer ${broker.env.PAPERCLIP_GITHUB_BRIDGE_TOKEN}` },
+ });
+ try {
+ const releaseA = broker.activate(input);
+ const a = await post();
+ expect(a.status).toBe(200);
+ expect((await a.json()).login).toBe("A");
+ await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, input.runId));
+ // Even a delayed controller release cannot authorize a finished DB run.
+ expect((await post()).status).toBe(403);
+ releaseA();
+ const next = { ...input, runId: randomUUID() };
+ await db.insert(heartbeatRuns).values({ id: next.runId, companyId: next.companyId, agentId: next.agentId, status: "running", contextSnapshot: { issueId: input.issueId } });
+ await initializeRunIdentity(db, { companyId: input.companyId, runId: next.runId, responsibleUserId: "B", cause: "instruction" });
+ broker.activate(next);
+ const b = await post();
+ expect(b.status).toBe(200);
+ expect((await b.json()).login).toBe("B");
+ await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.companyId, input.companyId));
+ const revoked = await post();
+ expect((await revoked.json()).env).toEqual({});
+ } finally { await broker.stop(); }
+ });
+
it("requires a run-scoped runtime capability and never accepts browser authentication or supplied identities", async () => {
const input = await seed();
await grant(input, "A");
diff --git a/server/src/services/heartbeat-github-launchers.test.ts b/server/src/services/heartbeat-github-launchers.test.ts
index 4d8771bc896..5bb4038bacb 100644
--- a/server/src/services/heartbeat-github-launchers.test.ts
+++ b/server/src/services/heartbeat-github-launchers.test.ts
@@ -11,6 +11,18 @@ import { prepareHeartbeatGitHubLaunchers } from "./heartbeat-github-launchers.js
const target = { kind: "remote" as const, transport: "sandbox" as const, providerKey: "daytona", remoteCwd: "/workspace" };
describe("heartbeat GitHub launcher lifetime", () => {
+ it.each([target, null])("defers native managed authorization/staging to the session owner (%j)", async (target) => {
+ const prepare = vi.fn();
+ const mint = vi.fn();
+ const result = await prepareHeartbeatGitHubLaunchers({
+ native: true, githubConfigured: true, agentId: "agent-a", runId: "run-a", target,
+ cwd: "/workspace", env: { GH_TOKEN: "ambient" }, brokerUrl: "https://paperclip.test", createBrokerToken: mint,
+ }, prepare);
+ expect(prepare).not.toHaveBeenCalled();
+ expect(mint).not.toHaveBeenCalled();
+ expect(result.cleanupLocation).toBeNull();
+ expect(result.env).toMatchObject({ GH_TOKEN: "", PAPERCLIP_GITHUB_BROKER_TOKEN: "" });
+ });
it("keeps anonymous native sandbox launchers stable without issuing a run capability", async () => {
const createBrokerToken = vi.fn(() => "run-secret");
const prepareLaunchers = vi.fn(async (input) => input.env);
@@ -31,7 +43,7 @@ describe("heartbeat GitHub launcher lifetime", () => {
if (cleanupFails) throw new Error("cleanup unavailable");
});
await expect(prepareHeartbeatGitHubLaunchers({
- native: true, githubConfigured: true, agentId: "agent-a", target,
+ native: false, githubConfigured: true, agentId: "agent-a", target,
runId: "failed-run", cwd: "/workspace", env: {}, brokerUrl: "https://paperclip.test",
createBrokerToken: () => "current-run-secret",
}, prepareLaunchers, cleanupLaunchers)).rejects.toBe(stagingError);
@@ -50,7 +62,7 @@ describe("heartbeat GitHub launcher lifetime", () => {
});
it.each([
- { native: true, githubConfigured: true, target },
+ { native: false, githubConfigured: true, target },
{ native: false, githubConfigured: false, target },
{ native: true, githubConfigured: false, target: null },
])("preserves run-scoped managed capabilities outside anonymous native sandboxes: %j", async (mode) => {
diff --git a/server/src/services/heartbeat-github-launchers.ts b/server/src/services/heartbeat-github-launchers.ts
index 5295c1f4564..83786a48eb2 100644
--- a/server/src/services/heartbeat-github-launchers.ts
+++ b/server/src/services/heartbeat-github-launchers.ts
@@ -15,10 +15,16 @@ export async function prepareHeartbeatGitHubLaunchers(
prepareLaunchers = prepareGitHubOperationLaunchers,
cleanupLaunchers = cleanupGitHubOperationLaunchers,
) {
+ // Native configured access is owned by the provider session supervisor.
+ // Defer staging until it has acquired that session; never mutate a live
+ // process's authorization from heartbeat preparation.
+ if (input.native && input.githubConfigured) {
+ return { env: githubBrokerEnvironment(input.env, { url: "", token: "" }), cleanupLocation: null };
+ }
// An unconfigured sandbox has no managed GitHub identity to broker. Its
// token-free wrappers still isolate image credentials, but may live as long
// as the workspace so a warm provider never inherits a deleted run path.
- // Configured identities keep their run-scoped capability/retirement rules.
+ // Other adapter paths retain their run-scoped capability/retirement rules.
const anonymous = input.native && !input.githubConfigured &&
input.target?.kind === "remote" && input.target.transport === "sandbox";
const location = {
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index 86578694376..b2fbcb5a584 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -24031,173 +24031,129 @@ export function heartbeatService(
nativeDispatchAtMs,
}),
);
- // Native Git/gh uses the same authenticated remote callback
- // transport as managed adapters. A bridge failure must not make
- // GitHub a prerequisite for otherwise unrelated native work.
- let nativeGitHubBridge: Awaited<
- ReturnType
- > = null;
- if (
- executionTarget?.kind === "remote" &&
- adapterEnv.PAPERCLIP_GITHUB_BROKER_TOKEN
- ) {
- try {
- nativeGitHubBridge =
- await startAdapterExecutionTargetPaperclipBridge({
- runId: run.id,
- target: executionTarget,
- runtimeRootDir: path.posix.join(
- executionTarget.remoteCwd,
- ".paperclip-runtime",
- "github",
- run.id,
- ),
- adapterKey: "native-github",
- hostApiToken: adapterEnv.PAPERCLIP_GITHUB_BROKER_TOKEN,
- hostApiUrl: adapterEnv.PAPERCLIP_GITHUB_BROKER_URL,
+ const guardedDispatch =
+ await dispatchResolvedInteractionContinuationWithAtomicGate(
+ (markDispatchStarted) =>
+ executePaperclipNativeSession({
+ db,
+ execution: nativeExecution,
+ conversationMode: isConversation(issueContext),
+ turnTimeoutMs: Math.max(0, asNumber(runtimeConfig.timeoutSec, 0)) * 1_000,
+ runnerInstanceId: nativeRunnerInstanceId,
+ leaseOwner: runOptions.nativeLeaseOwner,
+ restartRecovery: runOptions.nativeRestartRecovery,
+ backend:
+ options.nativeSessionBackendFactory?.(nativeExecution),
+ useRunnerd: agent.adapterType === "paperclip_runner",
+ adapterType: agent.adapterType,
+ sessionGoalControl,
+ resumeSessionGoalHeartbeat:
+ context.resumeSessionGoalHeartbeat === true ||
+ completedGoalControl,
+ onGoalCheckpoint: async (snapshot) => {
+ if (!taskKey) return;
+ const params =
+ attachPaperclipSessionMetadataToSessionParams(
+ {
+ ...runtimeSessionParamsForAdapter,
+ sessionId: snapshot.identity.sessionId,
+ cwd: executionWorkspace.cwd,
+ },
+ configuredModel,
+ sessionConfigMetadata,
+ )!;
+ const displayId =
+ snapshot.providerSessionId ?? snapshot.sessionId;
+ await upsertTaskSession({
+ companyId: agent.companyId,
+ agentId: agent.id,
+ adapterType: agent.adapterType,
+ taskKey,
+ sessionParamsJson: params,
+ sessionDisplayId: displayId,
+ lastRunId: run.id,
+ lastError: null,
+ });
+ goalCheckpointSession.current = { params, displayId };
+ },
onLog,
- });
- } catch {
- await onLog(
- "stderr",
- "[paperclip] GitHub runtime transport unavailable; continuing without managed GitHub access.\n",
- );
- }
- }
- try {
- const guardedDispatch =
- await dispatchResolvedInteractionContinuationWithAtomicGate(
- (markDispatchStarted) =>
- executePaperclipNativeSession({
- db,
- execution: nativeExecution,
- conversationMode: isConversation(issueContext),
- turnTimeoutMs: Math.max(0, asNumber(runtimeConfig.timeoutSec, 0)) * 1_000,
- runnerInstanceId: nativeRunnerInstanceId,
- leaseOwner: runOptions.nativeLeaseOwner,
- restartRecovery: runOptions.nativeRestartRecovery,
- backend:
- options.nativeSessionBackendFactory?.(nativeExecution),
- useRunnerd: agent.adapterType === "paperclip_runner",
- adapterType: agent.adapterType,
- sessionGoalControl,
- resumeSessionGoalHeartbeat:
- context.resumeSessionGoalHeartbeat === true ||
- completedGoalControl,
- onGoalCheckpoint: async (snapshot) => {
- if (!taskKey) return;
- const params =
- attachPaperclipSessionMetadataToSessionParams(
- {
- ...runtimeSessionParamsForAdapter,
- sessionId: snapshot.identity.sessionId,
- cwd: executionWorkspace.cwd,
- },
- configuredModel,
- sessionConfigMetadata,
- )!;
- const displayId =
- snapshot.providerSessionId ?? snapshot.sessionId;
- await upsertTaskSession({
- companyId: agent.companyId,
- agentId: agent.id,
- adapterType: agent.adapterType,
- taskKey,
- sessionParamsJson: params,
- sessionDisplayId: displayId,
- lastRunId: run.id,
- lastError: null,
- });
- goalCheckpointSession.current = { params, displayId };
- },
- onLog,
- onEvent: onAdapterEvent,
- preparationSpans: nativeRunnerPreparationSpans,
- // Bootstrap with executable/home discovery while keeping
- // configured provider values and the server-selected
- // workspace boundary authoritative.
- managedAiCredentialIdentity: managedAiRuntime?.identity,
- managedAiCredentialHome: managedAiRuntime ? String((managedAiRuntime.config.env as Record).CODEX_HOME) : undefined,
- runnerEnvironment: {
- ...buildNativeProviderEnvironment(
- adapterEnv,
- process.env,
- executionWorkspace.cwd,
- ),
- ...(nativeGitHubBridge
- ? {
- PAPERCLIP_GITHUB_BROKER_URL:
- nativeGitHubBridge.env.PAPERCLIP_API_URL,
- PAPERCLIP_GITHUB_BRIDGE_TOKEN:
- nativeGitHubBridge.env.PAPERCLIP_API_KEY,
- }
- : {}),
- ...(nativeMcpServer
- ? {
- PAPERCLIP_NATIVE_MCP_NAME: nativeMcpServer.name,
- PAPERCLIP_NATIVE_MCP_URL: nativeMcpServer.url,
- PAPERCLIP_NATIVE_MCP_TOKEN: nativeMcpServer.token,
- }
- : {}),
- ...(providerTraceCapture
- ? {
- PAPERCLIP_PROVIDER_TRACE_PATH:
- providerTraceCapture.path,
- PAPERCLIP_PROVIDER_TRACE_MAX_BYTES: String(
- PROVIDER_TRACE_MAX_BYTES,
- ),
- }
- : {}),
- },
- runnerExecutionTarget: executionTarget,
- runnerIngressAuthorized: isRunnerIngressAuthorized(
- nativeRuntimeResolution,
+ onEvent: onAdapterEvent,
+ preparationSpans: nativeRunnerPreparationSpans,
+ // Bootstrap with executable/home discovery while keeping
+ // configured provider values and the server-selected
+ // workspace boundary authoritative.
+ managedGitHub: !useHostGitHub && githubSelection.configured,
+ managedAiCredentialIdentity: managedAiRuntime?.identity,
+ managedAiCredentialHome: managedAiRuntime ? String((managedAiRuntime.config.env as Record).CODEX_HOME) : undefined,
+ runnerEnvironment: {
+ ...buildNativeProviderEnvironment(
+ adapterEnv,
+ process.env,
+ executionWorkspace.cwd,
),
- runnerPublicUrl:
- runtimeEnv.PAPERCLIP_RUNNER_PUBLIC_URL?.trim() || null,
- runnerCaBundlePath:
- runtimeEnv.PAPERCLIP_RUNNER_CA_BUNDLE_PATH?.trim() ||
- null,
- runnerRemoteBinaryPath:
- runtimeEnv.PAPERCLIP_RUNNER_REMOTE_BINARY_PATH?.trim() ||
- null,
- runnerRemoteCodexPath:
- runtimeEnv.PAPERCLIP_RUNNER_REMOTE_CODEX_PATH?.trim() ||
- null,
- runnerRemoteCodexNpmSpec:
- runtimeEnv.PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC?.trim() ||
- null,
- runnerRemoteProviderPackPath:
- runtimeEnv.PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH?.trim() ||
- null,
- stopTaskForReassignment: async (target) => {
- await settleLiveRunnerGoalBeforeInterrupt(db, target);
- if (!target.runId) return;
- const prior = await getRun(target.runId);
- if (!prior || prior.companyId !== target.companyId || prior.agentId !== target.agentId) {
- throw conflict("Reassignment run binding changed");
- }
- const stopped = await cancelRunInternal(target.runId, "Cancelled for task reassignment", {
- errorCode: "issue_reassigned", suppressImmediateRecovery: true,
- resultJson: { reassignmentStopConfirmed: true },
- });
- if (stopped && ["running", "queued", "scheduled_retry"].includes(stopped.status)) {
- throw conflict("The previous run did not stop; reassignment was not applied");
- }
- },
- enqueueWakeup,
- onSpawn: async (meta) => {
- markDispatchStarted();
- await persistRunProcessMetadata(run.id, meta);
- },
- }),
- );
- if (!guardedDispatch.dispatched) return;
- nativeDispatchStarted = true;
- adapterResult = await guardedDispatch.resultPromise;
- } finally {
- await nativeGitHubBridge?.stop();
- }
+ ...(nativeMcpServer
+ ? {
+ PAPERCLIP_NATIVE_MCP_NAME: nativeMcpServer.name,
+ PAPERCLIP_NATIVE_MCP_URL: nativeMcpServer.url,
+ PAPERCLIP_NATIVE_MCP_TOKEN: nativeMcpServer.token,
+ }
+ : {}),
+ ...(providerTraceCapture
+ ? {
+ PAPERCLIP_PROVIDER_TRACE_PATH:
+ providerTraceCapture.path,
+ PAPERCLIP_PROVIDER_TRACE_MAX_BYTES: String(
+ PROVIDER_TRACE_MAX_BYTES,
+ ),
+ }
+ : {}),
+ },
+ runnerExecutionTarget: executionTarget,
+ runnerIngressAuthorized: isRunnerIngressAuthorized(
+ nativeRuntimeResolution,
+ ),
+ runnerPublicUrl:
+ runtimeEnv.PAPERCLIP_RUNNER_PUBLIC_URL?.trim() || null,
+ runnerCaBundlePath:
+ runtimeEnv.PAPERCLIP_RUNNER_CA_BUNDLE_PATH?.trim() ||
+ null,
+ runnerRemoteBinaryPath:
+ runtimeEnv.PAPERCLIP_RUNNER_REMOTE_BINARY_PATH?.trim() ||
+ null,
+ runnerRemoteCodexPath:
+ runtimeEnv.PAPERCLIP_RUNNER_REMOTE_CODEX_PATH?.trim() ||
+ null,
+ runnerRemoteCodexNpmSpec:
+ runtimeEnv.PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC?.trim() ||
+ null,
+ runnerRemoteProviderPackPath:
+ runtimeEnv.PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH?.trim() ||
+ null,
+ stopTaskForReassignment: async (target) => {
+ await settleLiveRunnerGoalBeforeInterrupt(db, target);
+ if (!target.runId) return;
+ const prior = await getRun(target.runId);
+ if (!prior || prior.companyId !== target.companyId || prior.agentId !== target.agentId) {
+ throw conflict("Reassignment run binding changed");
+ }
+ const stopped = await cancelRunInternal(target.runId, "Cancelled for task reassignment", {
+ errorCode: "issue_reassigned", suppressImmediateRecovery: true,
+ resultJson: { reassignmentStopConfirmed: true },
+ });
+ if (stopped && ["running", "queued", "scheduled_retry"].includes(stopped.status)) {
+ throw conflict("The previous run did not stop; reassignment was not applied");
+ }
+ },
+ enqueueWakeup,
+ onSpawn: async (meta) => {
+ markDispatchStarted();
+ await persistRunProcessMetadata(run.id, meta);
+ },
+ }),
+ );
+ if (!guardedDispatch.dispatched) return;
+ nativeDispatchStarted = true;
+ adapterResult = await guardedDispatch.resultPromise;
} else {
const interactionId = readNonEmptyString(context.interactionId);
const legacyQuestionResponse =
diff --git a/server/src/services/native-runtime/native-github-access.test.ts b/server/src/services/native-runtime/native-github-access.test.ts
new file mode 100644
index 00000000000..e6c1aaea539
--- /dev/null
+++ b/server/src/services/native-runtime/native-github-access.test.ts
@@ -0,0 +1,128 @@
+import { afterEach, expect, it, vi } from "vitest";
+import { spawn, execFile } from "node:child_process";
+import { mkdtemp, mkdir, readFile, rm, writeFile, access } from "node:fs/promises";
+import { promisify } from "node:util";
+import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { createInterface } from "node:readline";
+import { createNativeGitHubAccess, type NativeGitHubAccess } from "./native-github-access.js";
+const scope = { companyId: "company-a", agentId: "agent-a", issueId: "task-a" };
+const run = (runId: string) => ({ ...scope, runId });
+const cleanups: (() => Promise)[] = [];
+afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); });
+async function broker(resolveCredentials = vi.fn(async (binding: ReturnType) => ({
+ status: "available", env: { GH_TOKEN: `fixture-${binding.runId}` },
+})), remote = false, bridgeUnavailable = false, onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise) {
+ const root = await mkdtemp(path.join(tmpdir(), "native-github-reuse-"));
+ cleanups.push(() => rm(root, { recursive: true, force: true }));
+ const bin = path.join(root, "real-bin");
+ await mkdir(bin);
+ await writeFile(path.join(bin, "gh"), `#!${process.execPath}\nprocess.stdout.write(process.env.GH_TOKEN || 'anonymous');`, { mode: 0o700 });
+ const execute: CommandManagedRuntimeRunner["execute"] = async (input) => {
+ const startedAt = new Date().toISOString();
+ const child = promisify(execFile)(input.command, input.args ?? [], {
+ cwd: input.cwd ?? root, env: { ...process.env, ...input.env }, timeout: 15_000, maxBuffer: 4 * 1024 * 1024,
+ });
+ child.child.stdin?.on("error", () => undefined);
+ child.child.stdin?.end(input.stdin ?? "");
+ const result = await child;
+ return { ...result, exitCode: 0, signal: null, timedOut: false, pid: null, startedAt };
+ };
+ const target = remote ? { kind: "remote" as const, transport: "sandbox" as const, providerKey: "test",
+ remoteCwd: root, runner: { execute }, streamRunLogs: false } : null;
+ const result = await createNativeGitHubAccess({ scope, target, cwd: root,
+ env: { PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, PAPERCLIP_API_KEY: "old-run-api-key" }, resolveCredentials, onLog },
+ bridgeUnavailable ? async () => { throw new Error("fixture bridge unavailable"); } : undefined);
+ cleanups.push(result.stop);
+ return { ...result, root, resolveCredentials };
+}
+function request(broker: NativeGitHubAccess, extra: RequestInit = {}, endpoint = "/runtime-tools/github/credentials") {
+ return fetch(broker.env.PAPERCLIP_GITHUB_BROKER_URL + endpoint, {
+ method: "POST", body: "{}", headers: { authorization: `Bearer ${broker.env.PAPERCLIP_GITHUB_BRIDGE_TOKEN}`, "content-type": "application/json" }, ...extra,
+ });
+}
+it.each([false, true])("keeps one live parent and its original launcher environment across two authorized runs (callback bridge: %s)", async (remote) => {
+ const b = await broker(undefined, remote);
+ const parent = spawn(process.execPath, ["-e", `
+ const {execFile}=require('node:child_process');
+ require('node:readline').createInterface({input:process.stdin}).on('line', () => {
+ execFile('gh', [], (error, stdout, stderr) => console.log(JSON.stringify({pid:process.pid, value:stdout, error:error?.message, stderr})));
+ });
+ `], { env: { ...process.env, ...b.env, PAPERCLIP_API_KEY: "stale-api-key" }, stdio: ["pipe", "pipe", "pipe"] });
+ cleanups.push(async () => { const exited = new Promise(resolve => parent.once("exit", () => resolve())); parent.kill(); await exited; });
+ const lines = createInterface({ input: parent.stdout });
+ const operation = () => new Promise<{ pid: number; value: string; stderr: string }>(resolve => {
+ lines.once("line", line => resolve(JSON.parse(line))); parent.stdin.write("run\n");
+ });
+ expect((await request(b)).status).toBe(403);
+ const releaseA = b.activate(run("run-a"));
+ const a = await operation();
+ expect(a.value).toBe("fixture-run-a");
+ releaseA();
+ expect((await operation()).value).toBe("anonymous");
+ const releaseB = b.activate(run("run-b"));
+ releaseA(); // A's delayed cleanup cannot revoke B.
+ const second = await operation();
+ expect(second.value).toBe("fixture-run-b");
+ expect(second.pid).toBe(a.pid);
+ expect(b.resolveCredentials.mock.calls.map(([binding]) => binding.runId)).toEqual(["run-a", "run-b"]);
+ expect(await readFile(path.join(b.env.PAPERCLIP_GITHUB_LAUNCHER_DIR, "gh"), "utf8")).not.toContain(b.env.PAPERCLIP_GITHUB_BROKER_TOKEN);
+ releaseB(); await b.stop();
+ await expect(access(b.env.PAPERCLIP_GITHUB_LAUNCHER_DIR)).rejects.toThrow();
+}, 45_000);
+it("rejects other scopes, concurrent bindings, browser requests and forged authority", async () => {
+ const b = await broker();
+ for (const field of ["companyId", "agentId", "issueId"] as const) {
+ expect(() => b.activate({ ...run("a"), [field]: "other" })).toThrow("scope mismatch");
+ }
+ const release = b.activate(run("a"));
+ expect(() => b.activate(run("b"))).toThrow("busy");
+ const rejectedHeaders: Record[] = [{ authorization: "Bearer wrong" }, { authorization: "é".repeat(71) },
+ { authorization: `Bearer ${b.env.PAPERCLIP_GITHUB_BRIDGE_TOKEN}`, origin: "https://browser.test" }];
+ for (const headers of rejectedHeaders) {
+ expect((await request(b, { headers })).status).toBe(403);
+ }
+ expect((await request(b, {}, "/api/companies")).status).toBe(404);
+ expect((await request(b, { method: "GET", body: undefined })).status).toBe(404);
+ const response = await request(b, { body: JSON.stringify({ runId: "b", responsibleUserId: "other" }) });
+ expect(response.status).toBe(200);
+ expect(response.headers.get("cache-control")).toBe("no-store");
+ expect(b.resolveCredentials).toHaveBeenCalledExactlyOnceWith(run("a"));
+ release(); await b.stop();
+ expect(() => b.activate(run("b"))).toThrow("closed");
+});
+it("does not release an in-flight credential after its run has been replaced", async () => {
+ let complete!: (value: { status: string; env: { GH_TOKEN: string } }) => void;
+ const resolver = vi.fn(() => new Promise<{ status: string; env: { GH_TOKEN: string } }>(resolve => { complete = resolve; }));
+ const b = await broker(resolver);
+ const release = b.activate(run("a"));
+ const pending = request(b);
+ await vi.waitFor(() => expect(resolver).toHaveBeenCalledOnce());
+ release(); b.activate(run("b"));
+ complete({ status: "available", env: { GH_TOKEN: "must-not-escape" } });
+ const response = await pending;
+ expect(response.status).toBe(403);
+ expect(await response.text()).not.toContain("must-not-escape");
+});
+it.each([403, 409, 500])("preserves denial/steering without leaking errors (%s)", async (status) => {
+ const b = await broker(vi.fn(async () => { throw Object.assign(new Error("secret-value"), { status }); }));
+ b.activate(run("a"));
+ const response = await request(b);
+ expect(response.status).toBe(status === 500 ? 503 : status);
+ expect(await response.text()).not.toContain("secret-value");
+});
+
+it.each([false, true])("keeps anonymous launchers usable when the remote broker cannot start (logging fails: %s)", async (loggingFails) => {
+ const onLog = vi.fn(async () => { if (loggingFails) throw new Error("fixture log sink unavailable"); });
+ const b = await broker(undefined, true, true, onLog);
+ expect(onLog).toHaveBeenCalledWith("stderr", expect.stringContaining("continuing without managed GitHub access"));
+ b.activate(run("a"));
+ expect(b.ready).toBe(false);
+ expect(b.env.PAPERCLIP_GITHUB_BROKER_TOKEN).toBe("");
+ const result = await promisify(execFile)(path.join(b.env.PAPERCLIP_GITHUB_LAUNCHER_DIR, "gh"), [], {
+ env: { ...process.env, ...b.env, GH_TOKEN: "ambient-must-not-leak" },
+ });
+ expect(result.stdout).toBe("anonymous");
+ expect(b.resolveCredentials).not.toHaveBeenCalled();
+});
diff --git a/server/src/services/native-runtime/native-github-access.ts b/server/src/services/native-runtime/native-github-access.ts
new file mode 100644
index 00000000000..f0245291f41
--- /dev/null
+++ b/server/src/services/native-runtime/native-github-access.ts
@@ -0,0 +1,146 @@
+import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
+import { createServer } from "node:http";
+import path from "node:path";
+import {
+ cleanupGitHubOperationLaunchers,
+ prepareGitHubOperationLaunchers,
+ startAdapterExecutionTargetPaperclipBridge,
+} from "@paperclipai/adapter-utils/execution-target";
+import { githubBrokerEnvironment } from "@paperclipai/adapter-utils/github-launcher";
+
+type Binding = { companyId: string; agentId: string; issueId: string; runId: string };
+type LauncherInput = Parameters[0];
+export type NativeGitHubAccess = Awaited>;
+
+/** A process-lifetime transport, with authority only while its controller owns a run.
+ * No run token or GitHub credential is stored in the provider's environment/files.
+ * The resolver still checks the active run and current identity/grants per operation.
+ */
+export async function createNativeGitHubAccess(input: {
+ scope: Omit;
+ target: LauncherInput["target"];
+ cwd: string;
+ env: NodeJS.ProcessEnv;
+ resolveCredentials: (binding: Binding) => Promise;
+ onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise;
+}, startBridge = startAdapterExecutionTargetPaperclipBridge) {
+ const token = randomBytes(32).toString("hex");
+ const location = { runId: `native-session-${randomUUID()}`, target: input.target };
+ let active: Binding | null = null;
+ let stopped = false;
+ let ready = true;
+ let stopping: Promise | undefined;
+ let bridge: Awaited> = null;
+ const server = createServer(async (req, res) => {
+ res.setHeader("Cache-Control", "no-store");
+ res.setHeader("Content-Type", "application/json");
+ req.resume();
+ const reply = (status: number, body: unknown) => {
+ res.writeHead(status);
+ res.end(JSON.stringify(body));
+ };
+ const bearer = req.headers.authorization ?? "";
+ const expected = `Bearer ${token}`;
+ if (req.headers.origin || req.headers.cookie || req.headers["sec-fetch-site"] ||
+ Buffer.byteLength(bearer) !== Buffer.byteLength(expected) ||
+ !timingSafeEqual(Buffer.from(bearer), Buffer.from(expected))) {
+ reply(403, { error: "GitHub session authentication required" });
+ return;
+ }
+ if (req.method !== "POST" || req.url !== "/runtime-tools/github/credentials") {
+ reply(404, { error: "Unknown GitHub session operation" });
+ return;
+ }
+ const binding = active;
+ if (!binding || stopped) {
+ reply(403, { error: "No active GitHub run" });
+ return;
+ }
+ try {
+ // Bind at receipt; body/headers cannot select a run or responsible user.
+ const result = await input.resolveCredentials({ ...binding });
+ if (active !== binding || stopped) {
+ reply(403, { error: "GitHub run ended during credential acquisition" });
+ return;
+ }
+ reply(200, result);
+ } catch (error) {
+ const status = (error as { status?: number })?.status;
+ // Never leak secret-store/provider errors. Preserve steering's retry signal.
+ reply(status === 409 ? 409 : status === 403 ? 403 : 503,
+ { error: "GitHub credentials unavailable" });
+ }
+ });
+ server.requestTimeout = 15_000;
+ server.headersTimeout = 10_000;
+ const stop = () => stopping ??= (async () => {
+ stopped = true;
+ active = null;
+ server.closeAllConnections();
+ const results = await Promise.allSettled([
+ bridge?.stop(),
+ new Promise(resolve => server.close(() => resolve())),
+ cleanupGitHubOperationLaunchers(location),
+ ]);
+ if (results.some(result => result.status === "rejected")) {
+ await input.onLog?.("stderr", "[paperclip] GitHub session cleanup incomplete.\n").catch(() => undefined);
+ }
+ })();
+ try {
+ await new Promise((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("GitHub broker did not listen");
+ const url = `http://127.0.0.1:${address.port}`;
+ try {
+ bridge = await startBridge({
+ ...location,
+ runtimeRootDir: input.target?.kind === "remote"
+ ? path.posix.join(input.target.remoteCwd, ".paperclip-runtime", "github", location.runId)
+ : null,
+ adapterKey: "native-github",
+ hostApiToken: token,
+ hostApiUrl: url,
+ onLog: input.onLog,
+ });
+ if (input.target?.kind === "remote" && !bridge) {
+ throw new Error("GitHub session requires a remote callback bridge");
+ }
+ } catch {
+ // GitHub remains optional. Stage anonymous wrappers for this session;
+ // the supervisor retries transport setup on the next run.
+ ready = false;
+ await new Promise(resolve => server.close(() => resolve()));
+ await input.onLog?.("stderr", "[paperclip] GitHub runtime transport unavailable; continuing without managed GitHub access.\n").catch(() => undefined);
+ }
+ const env = await prepareGitHubOperationLaunchers({
+ ...location, cwd: input.cwd,
+ env: {
+ ...githubBrokerEnvironment({ PATH: input.env.PATH }, {
+ url: ready ? bridge?.env.PAPERCLIP_API_URL ?? url : "",
+ token: ready ? bridge?.env.PAPERCLIP_API_KEY ?? token : "",
+ }),
+ // Never retain an old run's bridge authentication override.
+ PAPERCLIP_GITHUB_BRIDGE_TOKEN: ready ? bridge?.env.PAPERCLIP_API_KEY ?? token : "",
+ },
+ });
+ return {
+ env,
+ ready,
+ activate(binding: Binding) {
+ if (stopped || active) throw new Error("GitHub session is closed or busy");
+ if (binding.companyId !== input.scope.companyId || binding.agentId !== input.scope.agentId ||
+ binding.issueId !== input.scope.issueId) throw new Error("GitHub session scope mismatch");
+ const owner = { ...binding };
+ active = owner;
+ return () => { if (active === owner) active = null; };
+ },
+ stop,
+ };
+ } catch (error) {
+ await stop().catch(() => undefined);
+ throw error;
+ }
+}
diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts
index cab3d6de20d..a6e79f988f1 100644
--- a/server/src/services/native-runtime/native-session-executor.test.ts
+++ b/server/src/services/native-runtime/native-session-executor.test.ts
@@ -53,6 +53,15 @@ import { buildNativeHeartbeatPreparationSpans } from "./native-run-trace.js";
import { NativeRunnerOwnershipUnverifiedError } from "./native-runner-ownership.js";
import type { AdapterRuntimeEvent } from "../../adapters/index.js";
+const githubAccess = vi.hoisted(() => ({
+ activate: vi.fn((_binding: { runId: string }) => vi.fn()),
+ stop: vi.fn(async () => undefined),
+ create: vi.fn(),
+}));
+vi.mock("./native-github-access.js", () => ({
+ createNativeGitHubAccess: githubAccess.create,
+}));
+
type BackendFactoryOptions = {
runnerInstanceId?: string;
acpxRuntimeDirectory?: string;
@@ -6150,6 +6159,10 @@ describe("native warm session supervision", () => {
it.each(
[
+ ...[false, true].flatMap((local) => [true, false].map(brokerReady => ({
+ firstBroker: true, secondBroker: true, projectless: false, local, brokerReady,
+ firstMode: "managed", secondMode: "managed", managedGitHub: true,
+ }))),
...[
{ firstBroker: false, secondBroker: false },
{ firstBroker: false, secondBroker: true },
@@ -6213,10 +6226,12 @@ describe("native warm session supervision", () => {
firstNetwork: "disabled",
secondNetwork: "disabled",
checkpointContract: "valid",
+ managedGitHub: false,
+ brokerReady: true,
...scenario,
})),
)(
- "verifies a live warm owner before refreshing run authority (broker: $firstBroker -> $secondBroker, projectless: $projectless, local: $local, auth: $firstMode -> $secondMode, network: $firstNetwork -> $secondNetwork, checkpoint: $checkpointContract)",
+ "verifies a live warm owner before refreshing run authority (broker: $firstBroker -> $secondBroker, projectless: $projectless, local: $local, auth: $firstMode -> $secondMode, network: $firstNetwork -> $secondNetwork, checkpoint: $checkpointContract, managed access: $managedGitHub, broker ready: $brokerReady)",
async ({
firstBroker,
secondBroker,
@@ -6227,10 +6242,18 @@ describe("native warm session supervision", () => {
firstNetwork,
secondNetwork,
checkpointContract,
+ managedGitHub,
+ brokerReady,
}) => {
+ githubAccess.create.mockReset().mockResolvedValue({
+ env: { PAPERCLIP_GITHUB_BROKER_TOKEN: "stable-session-capability" },
+ ready: brokerReady,
+ activate: githubAccess.activate.mockReset().mockImplementation(() => vi.fn()),
+ stop: githubAccess.stop.mockReset().mockResolvedValue(undefined),
+ });
const replacesProvider =
- firstBroker ||
- secondBroker ||
+ !brokerReady ||
+ (!managedGitHub && (firstBroker || secondBroker)) ||
firstMode !== secondMode ||
firstNetwork !== secondNetwork;
const stateBase = await mkdtemp(
@@ -6337,7 +6360,7 @@ describe("native warm session supervision", () => {
// the undefined value when a live owner is reused without a load.
expect(options.persistedSession).toBeNull();
}
- } else {
+ } else if (!managedGitHub) {
expect(options.existingSession).toBe(firstSession);
expect(options.persistedSession).toBeUndefined();
}
@@ -6357,6 +6380,7 @@ describe("native warm session supervision", () => {
},
runnerInstanceId: "runner-runnerd-warm",
useRunnerd: true,
+ managedGitHub,
runnerExecutionTarget: remoteTarget,
});
if (projectless && replacesProvider) {
@@ -6451,8 +6475,21 @@ describe("native warm session supervision", () => {
},
runnerInstanceId: "runner-runnerd-warm",
useRunnerd: true,
+ managedGitHub,
runnerExecutionTarget: remoteTarget,
});
+ if (managedGitHub) {
+ expect(state.execute.mock.calls[1]?.[0].existingSession).toBe(brokerReady ? firstSession : undefined);
+ expect(githubAccess.create).toHaveBeenCalledTimes(brokerReady ? 1 : 2);
+ expect(githubAccess.activate.mock.calls.map(([binding]) => binding.runId))
+ .toEqual([first.binding.runId, second.binding.runId]);
+ // Replacement fixture publishes no new provider handle: both the old
+ // owner and the unclaimed replacement broker must be cleaned up.
+ expect(githubAccess.stop).toHaveBeenCalledTimes(brokerReady ? 0 : 2);
+ for (const activation of githubAccess.activate.mock.results) {
+ expect(activation.value).toHaveBeenCalledOnce();
+ }
+ }
if (replacesProvider) {
expect(firstClose).toHaveBeenCalledOnce();
expect(firstClose).toHaveBeenCalledWith({
@@ -6469,6 +6506,7 @@ describe("native warm session supervision", () => {
timeout: 1_500,
},
);
+ if (managedGitHub) expect(githubAccess.stop).toHaveBeenCalledOnce();
}
} finally {
if (previousStateDirectory === undefined) {
diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts
index 41b0262134a..5f350aaa7bc 100644
--- a/server/src/services/native-runtime/native-session-executor.ts
+++ b/server/src/services/native-runtime/native-session-executor.ts
@@ -1,3 +1,5 @@
+import { createNativeGitHubAccess, type NativeGitHubAccess } from "./native-github-access.js";
+import { resolveGitHubOperationCredentials } from "../github-operation-credentials.js";
import { bindManagedNativeCredentialTurn, completeManagedNativeCredentialTurn } from "./managed-native-credentials.js";
import { createLocalNativeQuestionBridge } from "./local-native-question-bridge.js";
import { readVerifiedRemoteWorkspaceFile } from "./remote-deliverable-file.js";
@@ -376,6 +378,7 @@ function clearNativeRuntimeRequestResolutions(runId: string): void {
type WarmNativeSession = {
managedAiCredentialIdentity?: string;
credentialRunId?: string;
+ githubAccess?: NativeGitHubAccess;
githubAuthenticationMode?: string;
networkAccess: boolean;
session: NativeSession;
@@ -389,6 +392,13 @@ type WarmNativeSession = {
lastActivityAt: string;
};
+async function closeWarmNativeSession(entry: WarmNativeSession, reason: string) {
+ // Revoke before awaiting process retirement/checkpoint IO.
+ const stopping = entry.githubAccess?.stop();
+ try { await entry.session.close({ reason }); }
+ finally { await stopping; }
+}
+
const warmNativeSessions = new Map();
// Closing a remote owner saves its checkpoint asynchronously. A new turn must
// not inspect or quarantine that owner's state until the save has finished.
@@ -444,7 +454,7 @@ async function closeIdleWarmNativeSessions(input: {
// adopt a session whose transport is already shutting down.
warmNativeSessions.delete(sessionId);
const closing = Promise.resolve().then(() =>
- entry.session.close({ reason: input.reason }),
+ closeWarmNativeSession(entry, input.reason),
);
closingWarmNativeSessions.set(sessionId, closing);
try {
@@ -5825,9 +5835,8 @@ async function releaseWarmNativeSession(
if (entry.idleTimer !== null) clearTimeout(entry.idleTimer);
if (failed || entry.closeOnReleaseReason !== undefined) {
warmNativeSessions.delete(sessionId);
- const closing = entry.session.close({
- reason: entry.closeOnReleaseReason ?? "warm native session failed",
- });
+ const closing = closeWarmNativeSession(entry,
+ entry.closeOnReleaseReason ?? "warm native session failed");
// Restart checkpointing is required to restore this successful session.
// Surface failure instead of reporting a clean release without authority.
if (entry.closeOnReleaseReason !== undefined) await closing;
@@ -5840,8 +5849,7 @@ async function releaseWarmNativeSession(
// is the idle timer's ownership fence across a later warm acquisition.
if (current !== entry || current.busy) return;
warmNativeSessions.delete(sessionId);
- void current.session
- .close({ reason: "warm native session idle timeout" })
+ void closeWarmNativeSession(current, "warm native session idle timeout")
.catch(() => undefined);
}, idleTimeoutMs);
entry.idleTimer.unref();
@@ -7063,6 +7071,8 @@ export async function executePaperclipNativeSession(input: {
sessionGoalControl?: NativeSessionGoalControl | null;
resumeSessionGoalHeartbeat?: boolean;
preparationSpans?: NativeRunHistoricalSpan[];
+ /** Use a session-owned GitHub broker, rebound only after run ownership is acquired. */
+ managedGitHub?: boolean;
/** Resolved adapter env; the runner transport applies a provider allowlist before spawn. */
runnerEnvironment?: NodeJS.ProcessEnv;
/** Private grant materialization; never a user-configured host path. */
@@ -7913,14 +7923,16 @@ async function executePaperclipNativeSessionWithinScope(
);
let existingWarmSession: NativeSession | undefined;
let managedCredentialSession: NativeSession | undefined;
+ let githubAccess: NativeGitHubAccess | undefined;
+ let releaseGitHubRun: (() => void) | undefined;
let persistedWarmSession: PersistedNativeSession | null | undefined;
if (warmSessionId !== null && warmConfigDigest !== null) {
const entry = warmNativeSessions.get(warmSessionId);
if (entry) {
- // Run-scoped broker capabilities must rotate with the process, while the
- // settled provider checkpoint retains the conversation across runs.
+ // Old run-scoped environments still require process replacement. A
+ // session-owned broker can change run authority without replacing it.
const hasBrokerCapability = Boolean(
- input.runnerEnvironment?.PAPERCLIP_GITHUB_BROKER_TOKEN,
+ !input.managedGitHub && input.runnerEnvironment?.PAPERCLIP_GITHUB_BROKER_TOKEN,
);
const credentialRunChanged =
Boolean(entry.credentialRunId) !== hasBrokerCapability ||
@@ -7930,6 +7942,8 @@ async function executePaperclipNativeSessionWithinScope(
entry.configDigest !== warmConfigDigest ||
entry.managedAiCredentialIdentity !== input.managedAiCredentialIdentity ||
credentialRunChanged ||
+ Boolean(entry.githubAccess) !== Boolean(input.managedGitHub) ||
+ entry.githubAccess?.ready === false ||
entry.githubAuthenticationMode !==
input.runnerEnvironment?.PAPERCLIP_GITHUB_AUTH_MODE ||
entry.networkAccess !==
@@ -7939,9 +7953,7 @@ async function executePaperclipNativeSessionWithinScope(
if (entry.busy) throw new Error("native_session_supervisor_busy");
if (entry.idleTimer !== null) clearTimeout(entry.idleTimer);
warmNativeSessions.delete(warmSessionId);
- await entry.session.close({
- reason: "warm native session configuration changed",
- });
+ await closeWarmNativeSession(entry, "warm native session configuration changed");
persistedWarmSession = loadWarmNativeCheckpoint(
input.execution,
warmConfigDigest,
@@ -7956,6 +7968,7 @@ async function executePaperclipNativeSessionWithinScope(
if (entry.idleTimer !== null) clearTimeout(entry.idleTimer);
entry.idleTimer = null;
existingWarmSession = entry.session;
+ githubAccess = entry.githubAccess;
}
} else {
persistedWarmSession = loadWarmNativeCheckpoint(
@@ -8068,6 +8081,18 @@ async function executePaperclipNativeSessionWithinScope(
controller,
});
try {
+ if (input.managedGitHub) {
+ githubAccess ??= await createNativeGitHubAccess({
+ scope: input.execution.binding,
+ target: input.runnerExecutionTarget,
+ cwd: input.execution.workspace.cwd,
+ env: input.runnerEnvironment ?? process.env,
+ resolveCredentials: (binding) => resolveGitHubOperationCredentials(input.db, binding),
+ onLog: input.onLog,
+ });
+ releaseGitHubRun = githubAccess.activate(input.execution.binding);
+ input = { ...input, runnerEnvironment: { ...input.runnerEnvironment, ...githubAccess.env } };
+ }
const expectedCurrentWakeComments = await resolveCurrentWakeCommentsBinding(
input.db,
input.execution.binding,
@@ -8269,7 +8294,8 @@ async function executePaperclipNativeSessionWithinScope(
networkAccess:
input.runnerEnvironment
?.PAPERCLIP_RUNNER_NETWORK_ACCESS === "enabled",
- credentialRunId: input.runnerEnvironment
+ githubAccess,
+ credentialRunId: !input.managedGitHub && input.runnerEnvironment
?.PAPERCLIP_GITHUB_BROKER_TOKEN
? input.execution.binding.runId
: undefined,
@@ -8823,6 +8849,13 @@ async function executePaperclipNativeSessionWithinScope(
if (ownershipUnverified) throw new NativeRunnerOwnershipUnverifiedError();
if (protocolIntegrityFailure !== null) throw protocolIntegrityFailure;
}
+ } finally {
+ releaseGitHubRun?.();
+ // A startup failure or onSession(null) must not leak a transport. A warm
+ // owner retains only the inactive broker until its normal retirement.
+ if (githubAccess && (!warmSessionId || warmNativeSessions.get(warmSessionId)?.githubAccess !== githubAccess)) {
+ await githubAccess.stop();
+ }
}
if (
planSynchronizations.length === 0 &&
From 5f1100e3b32bac1890cc093a25c95e13e7e2105e Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Tue, 22 Sep 2026 11:18:46 -0700
Subject: [PATCH 6/6] refactor(server): remove retired operator UI snippet
injection (#13789)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators can extend its interface through trusted plugin UI
contributions.
> - The server also accepts executable HTML through two legacy
environment settings.
> - This older path bypasses the plugin installation and lifecycle
model.
> - This pull request removes snippet injection from static and
development pages.
> - Operators must migrate existing integrations before upgrading.
## Linked Issues or Issue Description
**What existing behavior does this improve?**
Retire the operator HTML injection path from the server. Related public
changes: #13168, #13245 and #13496 introduced the legacy settings;
#13646 supplies the generic plugin host contract.
**Current behavior**
Managed instances append operator-supplied HTML or a decoded script body
to every page. The same integration can use supported trusted plugin UI
slots.
**Proposed behavior**
Ignore both retired settings. Serve normal branded static and
development HTML, and keep plugin contributions unchanged.
**Breaking changes**
Installations that rely on `PAPERCLIP_CLOUD_UI_SNIPPET` or
`PAPERCLIP_CLOUD_UI_SNIPPET_B64` must migrate before upgrading. The
maintainer-owned staging and production deployments have completed the
migration prerequisite. Other operators must migrate their integrations
before adopting this change.
## What Changed
- Remove the snippet injector and its static/dev rendering integration.
- Remove injector-specific tests and retain a regression that old
settings no longer change served HTML.
- Replace setup instructions with a retirement and plugin-migration
note.
## Verification
- Rebased onto current master; `pnpm exec vitest run
server/src/__tests__/static-index-html.test.ts
server/src/__tests__/vite-html-renderer.test.ts`: 5 tests passed.
- With repository-pinned Rust/Cargo installed, full `pnpm -r typecheck`
and `pnpm build` pass after rebase.
- Previous full local `pnpm test:run` encountered unrelated macOS
runtime-cache rename `EACCES` errors and a missing AgentMail skill path;
a focused reproduction confirmed 5 failures / 95 passes. That is not a
passing full-suite result. The unchanged focused suites, build and
typecheck were repeated after rebase; the full local suite was not
repeated. All 54 refreshed GitHub checks/contexts passed on
`bd62bff63f9d7980bfd10e54cb0102693d27ecfb`; fresh Greptile is 5/5 with
no unresolved threads.
- No UI component styling, database or API contract changed.
- Maintainer approved the remaining rollout and cleanup. Staging snippet
retirement and sleep/wake verification are complete. Production
migration and removal of the legacy settings are complete for serving
tenant instances. Remaining old warm inventory is excluded from new
signups until configuration reconciliation completes. The maintainer
authorized upgrading the remaining old deployments and clearing their
pins.
## Risks
- Removing the settings disables integrations that still depend on them;
operators outside the completed maintainer rollout must migrate before
upgrading. This is an intentional behavior change, documented at the
existing setup-doc path.
- Keep a previous image and its configuration for rollback. Existing
browser tabs need a refresh to unload already-injected code.
- Plugin UI remains trusted same-origin code. This does not add a
security sandbox or change ordinary branding.
## Model Used
- OpenAI GPT-6 (Codex; exact deployment variant and context-window size
are not exposed in this session). Reasoning, repository inspection,
local code execution and GitHub tools.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (focused rendering tests;
unrelated local full-suite failures are disclosed above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All refreshed checks pass on
`bd62bff63f9d7980bfd10e54cb0102693d27ecfb`
- [x] Fresh Greptile is 5/5 on the current head, with no unresolved
findings
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip
---
doc/cloud-ui-snippet.md | 94 +++-------------
server/src/__tests__/cloud-ui-snippet.test.ts | 102 ------------------
.../src/__tests__/static-index-html.test.ts | 7 +-
.../src/__tests__/vite-html-renderer.test.ts | 27 +++++
server/src/app.ts | 3 +-
server/src/cloud-ui-snippet.ts | 59 ----------
server/src/static-index-html.ts | 3 +-
7 files changed, 48 insertions(+), 247 deletions(-)
delete mode 100644 server/src/__tests__/cloud-ui-snippet.test.ts
delete mode 100644 server/src/cloud-ui-snippet.ts
diff --git a/doc/cloud-ui-snippet.md b/doc/cloud-ui-snippet.md
index 4787f3b9e08..2d0b591888e 100644
--- a/doc/cloud-ui-snippet.md
+++ b/doc/cloud-ui-snippet.md
@@ -1,83 +1,17 @@
-# Cloud UI snippet
+# Retired Cloud UI snippet settings
-Cloud operators can set `PAPERCLIP_CLOUD_UI_SNIPPET` to an HTML snippet.
-The server inserts it before `