mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
fix(runner): initialize Claude settings under lifetime ownership
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4cb61fd13b
commit
4cdb5ec4b3
4 files changed
+74
-2
No files matched your search
@@ -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<void>(resolve => { announceWrite = resolve; });
|
||||
const writePending = new Promise<void>(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"] = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
launchEnvironment: Readonly<NodeJS.ProcessEnv>;
|
||||
persistedEnvironment: Readonly<NodeJS.ProcessEnv>;
|
||||
/** 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),
|
||||
|
||||
Reference in new issue
Block a user