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 `` in static and Vite-served UI pages. -It requires the existing Cloud-managed instance signal. Self-hosted instances -ignore this setting. No snippet is enabled by default. +`PAPERCLIP_CLOUD_UI_SNIPPET` and `PAPERCLIP_CLOUD_UI_SNIPPET_B64` are no longer +read by the server. They do not inject HTML or JavaScript into static or dev +pages. This removes an operator-controlled executable HTML path from the app. +Existing ordinary branding and plugin UI contributions are unchanged. -This is trusted deployment configuration, not user input. It executes in the -application origin and is visible to every browser that receives the UI shell. -Do not include secrets or customer data. Restart the app after changing it. -Operators must review scripts and any required CSP changes before deployment. +Before upgrading an installation that uses either setting, move the integration +to a trusted plugin using the supported [plugin UI slots](plugins/PLUGIN_AUTHORING_GUIDE.md). +Plugin UI runs as trusted same-origin code; a plugin is not a sandbox for +untrusted JavaScript. Remove the retired settings from the operator's desired +configuration and verify any provision/restart/wake mechanism cannot restore them. +Refresh existing browser tabs to unload previously injected code. -## Base64 variant - -Delivery pipelines that write env vars through provider APIs can sit behind -web application firewalls that reject values containing raw script markup. -`PAPERCLIP_CLOUD_UI_SNIPPET_B64` carries the same snippet through them as -standard base64 of the UTF-8 HTML: - -```sh -PAPERCLIP_CLOUD_UI_SNIPPET_B64="$(base64 < snippet.html)" -``` - -Whitespace and line wrapping in the value are tolerated. A value that is not -canonical padded base64 of UTF-8 text, or that decodes to blank, is ignored — -if the widget does not appear, check that the value round-trips through -`base64 -d`. A present `PAPERCLIP_CLOUD_UI_SNIPPET` always wins, blank -included: clearing the plain variable to blank disables injection even while -a base64 value is still deployed. Everything else about the snippet is -unchanged. - -Base64 does not defeat every firewall. Some decode the value before matching, -so they reject a base64 snippet whose decoded bytes still contain script -markup. Deliver a bare script body (below) through one of these. - -## Bare script body - -Set the value to the script body alone — the JavaScript with no surrounding -``, which would close the wrapper early. Because the value carries no -` -(function(d) { - var script = d.createElement('script'); - script.src = 'https://chat.cdn-plain.com/index.js'; - script.onload = function() { Plain.init({ appId: 'YOUR_CHAT_APP_ID' }); }; - d.head.appendChild(script); -})(document); - -``` - -No signing secret or Plain API key is required. No Paperclip customer identity -or organization data is passed. Plain manages the anonymous browser session; -there is no Paperclip account-switch integration. Ask users for identifying -information when needed. The existing feedback flag remains unchanged. - -Docs: [Plain chat](https://www.plain.com/docs/product/channels/chat). - -## Verification and rollback - -On staging, open `/`, `/index.html`, and an organization dashboard directly. -Confirm the bubble appears and a test message reaches Plain. Verify the support -reply returns. On a self-hosted instance, confirm no snippet or widget is loaded. -Unset the snippet and restart to remove it on the next page load. Existing open -tabs retain the widget until refreshed. No production deployment is implied. +For rollback, retain the prior image and configuration outside source control. +An older image can still use these settings. Do not activate the old integration +and its replacement together. 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/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/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/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: diff --git a/server/src/__tests__/cloud-ui-snippet.test.ts b/server/src/__tests__/cloud-ui-snippet.test.ts deleted file mode 100644 index 484b8343cb4..00000000000 --- a/server/src/__tests__/cloud-ui-snippet.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { injectCloudUiSnippet } from "../cloud-ui-snippet.js"; - -const html = '
'; -const snippet = ''; -const encoded = Buffer.from(snippet, "utf-8").toString("base64"); - -describe("Cloud UI snippet", () => { - it("leaves self-hosted HTML unchanged even when a snippet is configured", () => { - expect(injectCloudUiSnippet(html, { PAPERCLIP_CLOUD_UI_SNIPPET: snippet })).toBe(html); - expect(injectCloudUiSnippet(html, { PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded })).toBe(html); - }); - - it.each([ - { PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "test-token" }, - { PAPERCLIP_MANAGED_CONFIG: "{}" }, - ])("injects only on a configured Cloud instance: %j", (cloud) => { - expect(injectCloudUiSnippet(html, { ...cloud, PAPERCLIP_CLOUD_UI_SNIPPET: snippet })) - .toBe(html.replace("", `${snippet}\n`)); - expect(injectCloudUiSnippet(html, cloud)).toBe(html); - expect(injectCloudUiSnippet(html, { ...cloud, PAPERCLIP_CLOUD_UI_SNIPPET: " " })).toBe(html); - }); - - it("preserves literal replacement tokens in operator JavaScript", () => { - const script = ''; - const result = injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET: script, - }); - expect(result).toContain(script); - expect(result).not.toContain("test-token"); - }); - - it("decodes a base64 snippet on a Cloud instance", () => { - expect(injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded, - })).toBe(html.replace("", `${snippet}\n`)); - }); - - it("tolerates whitespace and line wrapping in the base64 value", () => { - const wrapped = ` ${encoded.slice(0, 20)}\n${encoded.slice(20)}\n`; - expect(injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET_B64: wrapped, - })).toBe(html.replace("", `${snippet}\n`)); - }); - - it("wraps a bare script body that carries no markup in a `; - expect(injectCloudUiSnippet(html, { PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET: body })) - .toBe(html.replace("", `${wrapped}\n`)); - expect(injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", - PAPERCLIP_CLOUD_UI_SNIPPET_B64: Buffer.from(body, "utf-8").toString("base64"), - })).toBe(html.replace("", `${wrapped}\n`)); - }); - - it("preserves literal replacement tokens when wrapping a bare script body", () => { - const body = 'console.log("$&", "$`", "$\'");'; - const result = injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET: body, - }); - expect(result).toContain(``); - }); - - it("prefers the plain snippet when both variables are set", () => { - const other = Buffer.from("", "utf-8").toString("base64"); - const result = injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", - PAPERCLIP_CLOUD_UI_SNIPPET: snippet, - PAPERCLIP_CLOUD_UI_SNIPPET_B64: other, - }); - expect(result).toContain(snippet); - expect(result).not.toContain("other()"); - }); - - it("treats a blank plain variable as disabled even when a base64 value is set", () => { - expect(injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", - PAPERCLIP_CLOUD_UI_SNIPPET: " ", - PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded, - })).toBe(html); - expect(injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", - PAPERCLIP_CLOUD_UI_SNIPPET: "", - PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded, - })).toBe(html); - }); - - it.each([ - { label: "invalid characters", value: "!!!not-base64!!!" }, - { label: "wrong length", value: "abcde" }, - { label: "unpadded", value: Buffer.from("x", "utf-8").toString("base64").replace(/=+$/, "") }, - { label: "carrying nonzero padding bits", value: "PB==" }, - { label: "not valid UTF-8 once decoded", value: "/w==" }, - { label: "blank once decoded", value: Buffer.from(" \n ", "utf-8").toString("base64") }, - { label: "blank", value: " " }, - ])("ignores a base64 value that is $label", ({ value }) => { - expect(injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET_B64: value, - })).toBe(html); - }); -}); 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/__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/__tests__/static-index-html.test.ts b/server/src/__tests__/static-index-html.test.ts index 9d21f8dfca2..baddb97a766 100644 --- a/server/src/__tests__/static-index-html.test.ts +++ b/server/src/__tests__/static-index-html.test.ts @@ -16,16 +16,19 @@ describe("static SPA fallback HTML", () => { } }); - it("includes the operator snippet only in Cloud-served static HTML", () => { + it("ignores retired snippet settings in managed and self-hosted static HTML", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cloud-html-")); tempDirs.push(dir); fs.writeFileSync(path.join(dir, "index.html"), "App"); vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET", ''); + vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET_B64", Buffer.from('').toString("base64")); vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", undefined); vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined); expect(readBrandedStaticIndexHtml(dir)).not.toContain("chat.js"); vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", "{}"); - expect(readBrandedStaticIndexHtml(dir)).toContain('chat.js">\n'); + expect(readBrandedStaticIndexHtml(dir)).not.toContain("chat.js"); + vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET", undefined); + expect(readBrandedStaticIndexHtml(dir)).not.toContain("legacy.js"); }); it("serves the current index.html instead of reusing stale asset hashes", async () => { diff --git a/server/src/__tests__/vite-html-renderer.test.ts b/server/src/__tests__/vite-html-renderer.test.ts index 46b065f913e..937c371cc6e 100644 --- a/server/src/__tests__/vite-html-renderer.test.ts +++ b/server/src/__tests__/vite-html-renderer.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { applyUiBranding } from "../ui-branding.js"; import { createCachedViteHtmlRenderer, type ViteWatcherHost } from "../vite-html-renderer.js"; function createWatcher() { @@ -27,11 +28,37 @@ describe("createCachedViteHtmlRenderer", () => { const tempDirs: string[] = []; afterEach(() => { + vi.unstubAllEnvs(); for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); + it("ignores retired snippet settings in branded development HTML", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-")); + tempDirs.push(tempDir); + fs.writeFileSync(path.join(tempDir, "index.html"), "App"); + vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", "{}"); + vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET", ''); + vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET_B64", Buffer.from('').toString("base64")); + const renderer = createCachedViteHtmlRenderer({ + vite: { watcher: createWatcher(), transformIndexHtml: async (_url, html) => html }, + uiRoot: tempDir, + brandHtml: applyUiBranding, + }); + try { + const html = await renderer.render("/"); + expect(html).toContain("App"); + expect(html).not.toContain("legacy-plain.js"); + expect(html).not.toContain("legacy-encoded.js"); + vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET", undefined); + + expect(await renderer.render("/issues")).not.toContain("legacy-encoded.js"); + } finally { + renderer.dispose(); + } + }); + it("caches the branded template until index.html changes while transforming every request", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-")); tempDirs.push(tempDir); diff --git a/server/src/app.ts b/server/src/app.ts index 74269f7f3e2..ea07f8fdd53 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -121,7 +121,6 @@ import { adapterRoutes } from "./routes/adapters.js"; import { managedAgentProfileRoutes } from "./routes/managed-agent-profiles.js"; import { remoteAgentProfileRoutes } from "./routes/remote-agent-profiles.js"; import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; -import { injectCloudUiSnippet } from "./cloud-ui-snippet.js"; import { readBrandedStaticIndexHtml } from "./static-index-html.js"; import { staticUiCacheControl } from "./static-ui-cache.js"; import { applyUiBranding } from "./ui-branding.js"; @@ -1094,7 +1093,7 @@ export async function createApp( viteHtmlRenderer = createCachedViteHtmlRenderer({ vite, uiRoot, - brandHtml: (html) => injectCloudUiSnippet(applyUiBranding(html)), + brandHtml: applyUiBranding, }); const renderViteHtml = viteHtmlRenderer; diff --git a/server/src/cloud-ui-snippet.ts b/server/src/cloud-ui-snippet.ts deleted file mode 100644 index 2d3e39454fb..00000000000 --- a/server/src/cloud-ui-snippet.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { isCloudManagedInstance, type CloudInstanceEnv } from "./services/cloud-instance.js"; - -/** Trusted operator HTML only. This content is public and runs in the app origin. */ -export function injectCloudUiSnippet(html: string, env: CloudInstanceEnv = process.env): string { - const snippet = resolveCloudUiSnippet(env); - if (!isCloudManagedInstance(env) || !snippet) return html; - return html.replace(/<\/body>/i, () => `${snippet}\n`); -} - -/** - * A present plain variable always wins — blank included, so clearing it to - * blank disables injection even when a base64 value is still deployed. The - * base64 variant exists because delivery pipelines that write env vars - * through provider APIs can sit behind web application firewalls that - * reject values containing raw script markup; base64 carries the same - * snippet through them unchanged. - */ -function resolveCloudUiSnippet(env: CloudInstanceEnv): string | null { - const plain = env.PAPERCLIP_CLOUD_UI_SNIPPET; - if (plain !== undefined) return asInjectableMarkup(plain); - const encoded = env.PAPERCLIP_CLOUD_UI_SNIPPET_B64?.replace(/\s+/g, ""); - if (!encoded) return null; - const decoded = decodeBase64(encoded); - return decoded !== null ? asInjectableMarkup(decoded) : null; -} - -/** - * A configured value that already looks like markup (it starts with `<`) is - * injected verbatim, preserving the original bytes. A value that does not is - * treated as a bare script body and wrapped in a ``; -} - -/** - * A value that is not canonical, padded base64 of valid UTF-8 is ignored - * rather than injected as garbage: the round trip rejects stray padding - * bits, and the fatal decoder rejects byte sequences that are not UTF-8. - */ -function decodeBase64(encoded: string): string | null { - if (encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) return null; - const bytes = Buffer.from(encoded, "base64"); - if (bytes.toString("base64") !== encoded) return null; - try { - return new TextDecoder("utf-8", { fatal: true }).decode(bytes); - } catch { - return null; - } -} 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/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 2f61683b440..75b7411db6d 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/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/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 1cc3797b21b..a6e79f988f1 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"; @@ -52,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; @@ -253,6 +263,7 @@ import { assertRemoteRunnerBuildMetadata, nativeSessionFailureDisposition, nativeFailedRunRetryStateIsSafe, + verifyStoppedNativeSessionForContinuation, nativePreProviderRetryAfterCleanupStateIsSafe, reconcileRetainedNativeSessionCleanup, retainedNativeCleanupJournalMatches, @@ -3853,6 +3864,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", @@ -6069,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 }, @@ -6132,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, @@ -6146,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( @@ -6256,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(); } @@ -6276,6 +6380,7 @@ describe("native warm session supervision", () => { }, runnerInstanceId: "runner-runnerd-warm", useRunnerd: true, + managedGitHub, runnerExecutionTarget: remoteTarget, }); if (projectless && replacesProvider) { @@ -6370,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({ @@ -6388,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 8a8bf41cd73..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 { @@ -1756,7 +1766,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 +1774,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 +2373,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. */ @@ -5713,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; @@ -5728,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(); @@ -5909,7 +6029,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", @@ -6951,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. */ @@ -7801,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 || @@ -7818,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 !== @@ -7827,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, @@ -7844,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( @@ -7956,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, @@ -8157,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, @@ -8711,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 && 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"), }; } diff --git a/server/src/static-index-html.ts b/server/src/static-index-html.ts index 7bbc16f4c11..13fa592c09a 100644 --- a/server/src/static-index-html.ts +++ b/server/src/static-index-html.ts @@ -1,8 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import { injectCloudUiSnippet } from "./cloud-ui-snippet.js"; import { applyUiBranding } from "./ui-branding.js"; export function readBrandedStaticIndexHtml(uiDist: string): string { - return injectCloudUiSnippet(applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8"))); + return applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); } 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/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/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/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/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. */ 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 }) => ( - ), @@ -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();