diff --git a/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts b/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts index acf5efce76a..054956788cb 100644 --- a/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts @@ -9,6 +9,7 @@ import { describe("ACPX driver profile", () => { it.each([ ["codex", "available"], + ["grok", "available"], ["claude", "available"], ["pi", "unsupported"], ] as const)( @@ -48,6 +49,7 @@ describe("ACPX driver profile", () => { it.each([ ["claude", "claude-sonnet-5"], ["codex", "gpt-5.6-sol"], + ["grok", "grok-4.7"], ] as const)("accepts the exact qualified %s model", (agent, model) => { expect(validateAcpxDriverConfig({ agent, model })).toEqual({ ok: true, diff --git a/packages/paperclip-runner/src/drivers/acpx/grok-credentials.ts b/packages/paperclip-runner/src/drivers/acpx/grok-credentials.ts index a54a0681983..e99709d3646 100644 --- a/packages/paperclip-runner/src/drivers/acpx/grok-credentials.ts +++ b/packages/paperclip-runner/src/drivers/acpx/grok-credentials.ts @@ -12,6 +12,7 @@ export const GROK_AUTH_REFRESH_FILE = "auth-refresh.json"; export async function stageManagedGrokCredential(input: { agentHomeDirectory: string; environment?: NodeJS.ProcessEnv; + retainRefresh?: () => boolean; }): Promise { const source = input.environment ?? {}; const inline = source.PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET; @@ -51,7 +52,7 @@ export async function stageManagedGrokCredential(input: { // The runtime host calls close only after verified provider exit. Retain // a private refresh handoff for the controller's existing Grok merge // predicate, then scrub auth.json before releasing the process fence. - if (inline) { + if (inline && (input.retainRefresh?.() ?? true)) { const auth = await open(join(input.agentHomeDirectory, "auth.json"), constants.O_RDONLY | constants.O_NOFOLLOW).catch((error) => { if (error.code === "ENOENT") return null; throw error; diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts index 6451a369f51..9e4dfab31dc 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts @@ -7,7 +7,7 @@ import { describe("qualified ACPX profiles", () => { it("binds each agent to one immutable package and model declaration", () => { - for (const agent of ["pi", "claude", "codex"] as const) { + for (const agent of ["pi", "claude", "codex", "grok"] as const) { const profile = QUALIFIED_ACPX_PROFILES[agent]; expect(profile.agent).toBe(agent); expect(profile.commandDigest).toMatch(/^sha256:[a-f0-9]{64}$/); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index d7aeda29e7f..75b2faa14ff 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -828,6 +828,25 @@ describe("ACPX runtime host", () => { expect(openRuntime).not.toHaveBeenCalled(); }); + it.each(["expired credential", "provider process died"])("cleans Grok credentials after failed initialization: %s", async (failure) => { + const fixture = await hostFixture(); + let home = ""; + await expect(AcpxRuntimeHost.open({ + ...fixture.options, agent: "grok", model: "grok-4.7", permissionMode: "approve-reads", + environment: { PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: JSON.stringify({ "https://accounts.x.ai::11111111-1111-4111-8111-111111111111": { key: "test-expired", refresh_token: "test-refresh" } }) }, + }, fixture.dependencies({ openRuntime: async (options) => { + home = options.launchEnvironment.GROK_HOME!; + expect(options.permissionMode).toBe("approve-reads"); + expect(options.launchEnvironment).not.toHaveProperty("PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET"); + expect(await readFile(join(home, "auth.json"), "utf8")).toContain("test-expired"); + throw new Error(failure); + } }))).rejects.toThrow(failure); + for (const filename of ["auth.json", "auth-refresh.json"]) { + await expect(readFile(join(home, filename))).rejects.toMatchObject({ code: "ENOENT" }); + } + expect(fixture.commandClose).toHaveBeenCalledOnce(); + }); + it("cleans credentials and command leases when provider open fails", async () => { const fixture = await hostFixture(); let authPath = ""; diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index 8b4e9ea1026..20de8c9aace 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -338,6 +338,7 @@ export class AcpxRuntimeHost { if (installation.commandDigest !== profile.commandDigest) { throw new Error("Verified ACPX installation does not match its profile"); } + let admissionSucceeded = false; let command: VerifiedAcpxCommandLease | null = null; let credential: AcpxProviderLifetimeLease | null = null; let toolBridge: RunnerToolBridge | null = null; @@ -408,7 +409,7 @@ export class AcpxRuntimeHost { } else if (options.agent === "grok") { credential = await acquireAbortableAdmissionResource({ signal: options.signal, - acquire: () => stageManagedGrokCredential({ agentHomeDirectory: sandbox.agentHomeDirectory, environment: options.environment }), + acquire: () => stageManagedGrokCredential({ agentHomeDirectory: sandbox.agentHomeDirectory, environment: options.environment, retainRefresh: () => admissionSucceeded }), resource: "credential", releaseLate: (lease) => lease.close(), reportFailure: (failure) => dependencies.reportRetainedCleanupFailure(failure), }); @@ -550,6 +551,7 @@ export class AcpxRuntimeHost { verifyExpectedAcpxIdentity(options.expectedIdentity, binding, identity); } options.signal?.throwIfAborted(); + admissionSucceeded = true; return new AcpxRuntimeHost({ runtime, binding, diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 012ea5b6ec0..f8f622794f8 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -428,7 +428,7 @@ export async function prepareAcpxRuntimeSandbox(input: { XDG_CACHE_HOME: cacheDirectory, PAPERCLIP_ACPX_PROFILE: input.agent, PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", - ...(input.agent === "grok" ? { GROK_HOME: agentHomeDirectory, NO_BROWSER: "1" } : {}), + ...(input.agent === "grok" ? { GROK_HOME: agentHomeDirectory, NO_BROWSER: "1", GROK_DISABLE_AUTOUPDATER: "1" } : {}), ...(input.agent === "pi" ? { PI_CODING_AGENT_DIR: agentHomeDirectory, diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 4d23eb9f94e..d6a01fcd02f 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -3254,7 +3254,7 @@ export function createRunnerdCodexAppServerArgs(input: { ); } -function unwrapToolResponse(response: Record): { +export function unwrapToolResponse(response: Record, preserveEnvelope = false): { readonly __paperclipSemanticToolOutcome: true; readonly result: unknown; readonly isError: boolean; @@ -3265,7 +3265,7 @@ function unwrapToolResponse(response: Record): { const value = record(items[0]).text; let result: unknown = response; try { - if (typeof value === "string") result = JSON.parse(value); + if (!preserveEnvelope && typeof value === "string") result = JSON.parse(value); } catch { result = response; } @@ -5461,6 +5461,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { } : {}), }), + this.options.provider === "acpx" && + (call.operationId === "paperclip_finish" || call.operationId === "paperclip_block"), ); if (call.operationId === "call_api") { const result = record(outcome.result); diff --git a/packages/paperclip-runner/src/live/runnerd-tool-response.test.ts b/packages/paperclip-runner/src/live/runnerd-tool-response.test.ts new file mode 100644 index 00000000000..536e41ae2b0 --- /dev/null +++ b/packages/paperclip-runner/src/live/runnerd-tool-response.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { unwrapToolResponse } from "./runnerd-codex-transport.js"; + +describe("runnerd semantic tool response", () => { + it("preserves the validated ACPX terminal acknowledgement envelope", () => { + const response = { success: true, contentItems: [{ type: "inputText", text: JSON.stringify({ ok: true, accepted: true }) }] }; + expect(unwrapToolResponse(response, true)).toEqual({ __paperclipSemanticToolOutcome: true, result: response, isError: false }); + expect(unwrapToolResponse(response)).toEqual({ __paperclipSemanticToolOutcome: true, result: { ok: true, accepted: true }, isError: false }); + }); + it("keeps denial feedback and does not promote rejected terminal reports to success", () => { + const response = { success: false, contentItems: [{ type: "inputText", text: JSON.stringify({ ok: false, denial: { code: "stale_completion_contract" } }) }] }; + expect(unwrapToolResponse(response, true)).toMatchObject({ isError: true, result: { success: false } }); + }); +});