From 4cdb5ec4b38d792b432993a9a4b722090813a8ef Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 18:10:09 -0500 Subject: [PATCH] fix(runner): initialize Claude settings under lifetime ownership Co-Authored-By: Paperclip --- .../src/drivers/acpx/runtime-host.test.ts | 60 +++++++++++++++++++ .../src/drivers/acpx/runtime-host.ts | 5 ++ .../src/drivers/acpx/runtime-sandbox.test.ts | 4 ++ .../src/drivers/acpx/runtime-sandbox.ts | 7 ++- 4 files changed, 74 insertions(+), 2 deletions(-) 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 2459dab2763..970786fb199 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -237,6 +237,66 @@ describe("ACPX runtime host", () => { expect(openRuntime).not.toHaveBeenCalled(); }); + it("leaves an active Claude owner's effort settings unchanged when a second opener loses the lifetime lease", async () => { + const fixture = await hostFixture(); + let settingsPath = ""; + const openRuntime = vi.fn(async () => runtimePort({ getStatus: async () => ({ models: { currentModelId: "claude-sonnet-5" } }) })); + const dependencies = fixture.dependencies({ openRuntime }); + const prepare = dependencies.prepareSandbox!; + dependencies.prepareSandbox = async input => { + const sandbox = await prepare(input); + settingsPath = join(sandbox.agentHomeDirectory, "settings.json"); + return sandbox; + }; + const options = { ...fixture.options, agent: "claude" as const, model: "claude-sonnet-5", permissionMode: "approve-all" as const }; + const host = await AcpxRuntimeHost.open(options, dependencies); + try { + const ownedSettings = `${JSON.stringify({ ...JSON.parse(await readFile(settingsPath, "utf8")), effortLevel: "high", activeOwnerMarker: true })}\n`; + await writeFile(settingsPath, ownedSettings, { mode: 0o600 }); + await expect(AcpxRuntimeHost.open(options, dependencies)).rejects.toThrow("already has an active lease"); + expect(await readFile(settingsPath, "utf8")).toBe(ownedSettings); + expect(openRuntime).toHaveBeenCalledTimes(1); + } finally { await host.close({ reason: "ownership regression complete" }); } + const nextOwner = await waitForAcpxOperation(() => AcpxRuntimeHost.open(options, dependencies)); + try { + expect(JSON.parse(await readFile(settingsPath, "utf8"))).not.toHaveProperty("activeOwnerMarker"); + expect(openRuntime).toHaveBeenCalledTimes(2); + } finally { await nextOwner.close({ reason: "next owner complete" }); } + }); + + it("keeps the Claude lifetime lease until an owned settings write settles after cancellation", async () => { + const fixture = await hostFixture(); + const controller = new AbortController(); + let announceWrite!: () => void; + let finishWrite!: () => void; + const writeStarted = new Promise(resolve => { announceWrite = resolve; }); + const writePending = new Promise(resolve => { finishWrite = resolve; }); + const openRuntime = vi.fn(async () => runtimePort({ getStatus: async () => ({ models: { currentModelId: "claude-sonnet-5" } }) })); + const dependencies = fixture.dependencies({ openRuntime }); + const prepare = dependencies.prepareSandbox!; + dependencies.prepareSandbox = async input => { + const sandbox = await prepare(input); + const initialize = sandbox.initializeOwnedConfiguration; + return { ...sandbox, initializeOwnedConfiguration: async () => { + announceWrite(); + await writePending; + await initialize?.(); + } }; + }; + const options = { ...fixture.options, agent: "claude" as const, model: "claude-sonnet-5", permissionMode: "approve-all" as const }; + const opening = AcpxRuntimeHost.open({ ...options, signal: controller.signal }, dependencies); + const rejected = expect(opening).rejects.toThrow("settings admission cancelled"); + try { + await writeStarted; + controller.abort(new Error("settings admission cancelled")); + await expect(AcpxRuntimeHost.open(options, fixture.dependencies({ openRuntime }))).rejects.toThrow("already has an active lease"); + } finally { finishWrite(); } + await rejected; + expect(openRuntime).not.toHaveBeenCalled(); + const nextOwner = await waitForAcpxOperation(() => AcpxRuntimeHost.open(options, fixture.dependencies({ openRuntime }))); + await nextOwner.close({ reason: "cancelled initializer ownership released" }); + }); + it("registers the assigned connection gateway alongside the Claude task bridge", async () => { const fixture = await hostFixture(); let servers: AcpxRuntimePortOpenOptions["mcpServers"] = []; diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index f97d92b1690..9bd4402b9be 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -482,6 +482,11 @@ export class AcpxRuntimeHost { dependencies.reportRetainedCleanupFailure(failure), }); } + options.signal?.throwIfAborted(); + // Await this write under ownership even if cancellation arrives. Releasing + // the lease before it settles could let a late write affect the next owner. + await sandbox.initializeOwnedConfiguration?.(); + options.signal?.throwIfAborted(); assertAcpxProfileEnvironment(options.agent, sandbox.launchEnvironment); let launchEnvironment = sandbox.launchEnvironment; // This copy is registered by the authenticated controller for its company, diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts index 7225231f2aa..79171a180b2 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts @@ -98,6 +98,7 @@ describe("ACPX runtime sandbox", () => { binding: { ...fixture.binding, requestedModel: model }, agent: "claude", }); + await sandbox.initializeOwnedConfiguration?.(); const settingsPath = join(sandbox.agentHomeDirectory, "settings.json"); expect(JSON.parse(await readFile(settingsPath, "utf8"))).toMatchObject({ model, @@ -113,6 +114,7 @@ describe("ACPX runtime sandbox", () => { const binding = { ...fixture.binding, requestedModel: model, effectiveModel: model }; for (let attempt = 0; attempt < 2; attempt += 1) { const sandbox = await prepareAcpxRuntimeSandbox({ binding, agent: "claude" }); + await sandbox.initializeOwnedConfiguration?.(); const settings = JSON.parse(await readFile(join(sandbox.agentHomeDirectory, "settings.json"), "utf8")); expect(settings).toMatchObject({ model, availableModels: [model] }); } @@ -125,11 +127,13 @@ describe("ACPX runtime sandbox", () => { const binding = { ...fixture.binding, claudeEffort }; for (let attempt = 0; attempt < 2; attempt += 1) { const sandbox = await prepareAcpxRuntimeSandbox({ binding, agent: "claude" }); + await sandbox.initializeOwnedConfiguration?.(); const settings = JSON.parse(await readFile(join(sandbox.agentHomeDirectory, "settings.json"), "utf8")); expect(settings.effortLevel).toBe(claudeEffort); } } const sandbox = await prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "claude" }); + await sandbox.initializeOwnedConfiguration?.(); expect(JSON.parse(await readFile(join(sandbox.agentHomeDirectory, "settings.json"), "utf8"))).not.toHaveProperty("effortLevel"); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index d5ae8ce9e14..6b315ca4470 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -53,6 +53,8 @@ export interface AcpxRuntimeSandbox { cacheDirectory: string; agentHomeDirectory: string; workspaceRecordPath: string; + /** Mutate provider settings only while the host holds its lifetime lease. */ + initializeOwnedConfiguration?: () => Promise; launchEnvironment: Readonly; persistedEnvironment: Readonly; /** The host sandbox must protect these runtime/config paths from tools. */ @@ -394,7 +396,7 @@ export async function prepareAcpxRuntimeSandbox(input: { const configuration = piProviderConfiguration(input.environment); if (configuration) await writePrivateFile(join(agentHomeDirectory, "models.json"), configuration.json); } - if (input.agent === "claude") { + const initializeOwnedConfiguration = input.agent === "claude" ? async () => { // ACP otherwise rewrites exact IDs (including user-entered model IDs) to // picker aliases such as "sonnet". Its supported availableModels setting // preserves our requested ID through selection and model verification. @@ -412,7 +414,7 @@ export async function prepareAcpxRuntimeSandbox(input: { : {}), })}\n`, ); - } + } : undefined; if (input.agent === "grok") { // Native project rules merge with global rules. Ask rules win over project // allows, keeping operation approval at the runner's authenticated boundary. @@ -563,6 +565,7 @@ export async function prepareAcpxRuntimeSandbox(input: { cacheDirectory, agentHomeDirectory, workspaceRecordPath, + ...(initializeOwnedConfiguration ? { initializeOwnedConfiguration } : {}), protectedPaths, launchEnvironment: Object.freeze({ ...launchEnvironment }), persistedEnvironment: Object.freeze(persistedEnvironment),