mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
Reuse Runner plugin capability checks (#20987)
This commit is contained in:
1 parent
42fd790e80
commit
45170b0cac
8 files changed
+36
-18
No files matched your search
@@ -265,6 +265,7 @@ checks. Providers that omit the capability do not support predecessor recovery.
|
||||
## Launch authentication validation
|
||||
|
||||
Declare `projectAuthCheckVersion: 1` in `describe` for each native task launch.
|
||||
For a new task, Core reads this flag from the same description it uses to set up Runner.
|
||||
Core calls `check_project_auth` after preparation and before credential transfer.
|
||||
The request names the configuration ID, provider, and authorized project IDs.
|
||||
It contains no credential.
|
||||
|
||||
@@ -1984,6 +1984,21 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
} finally { execute.mockRestore(); }
|
||||
});
|
||||
it("defers a new plugin task's capability check to native launch and still checks retained tasks", async () => {
|
||||
const runnerTask = vi.fn().mockResolvedValue({ kind: "description", taskId: "task", projectAuthCheckVersion: 1,
|
||||
runner: { version: "fixture", digest: `sha256:${"a".repeat(64)}` } });
|
||||
const target = { kind: "remote", transport: "sandbox", providerKey: "plugin", remoteCwd: ".", runnerTask,
|
||||
sandboxLeaseAcquisition: { outcome: "created", providerLeaseId: "task" } } as const;
|
||||
const options = { deferNewRunnerTaskCapabilityCheck: true };
|
||||
|
||||
await expect(assertManagedAiProjectAuth({}, "openai", target, options)).resolves.toBeUndefined();
|
||||
await expect(assertManagedAiProjectAuth({ args: ["--api-key=override"] }, "openai", target, options)).rejects.toThrow("overrides");
|
||||
expect(runnerTask).not.toHaveBeenCalled();
|
||||
|
||||
const retained = { ...target, sandboxLeaseAcquisition: { ...target.sandboxLeaseAcquisition, outcome: "resumed" as const } };
|
||||
await expect(assertManagedAiProjectAuth({}, "openai", retained, options)).resolves.toBeUndefined();
|
||||
expect(runnerTask).toHaveBeenCalledExactlyOnceWith({ kind: "describe" });
|
||||
});
|
||||
it("keeps personal defaults separate and does not replace the first default", async () => {
|
||||
const first = await create("alice", "Alice first");
|
||||
await create("alice", "Alice second");
|
||||
|
||||
@@ -103,6 +103,7 @@ export async function assertManagedAiProjectAuth(
|
||||
config: Record<string, unknown>,
|
||||
provider: AiConnectionBinding["provider"],
|
||||
target?: AdapterExecutionTarget | null,
|
||||
options: { deferNewRunnerTaskCapabilityCheck?: boolean } = {},
|
||||
) {
|
||||
const extraArgs = [
|
||||
...(Array.isArray(config.extraArgs) ? config.extraArgs : []),
|
||||
@@ -131,6 +132,7 @@ export async function assertManagedAiProjectAuth(
|
||||
const pattern =
|
||||
"apiKeyHelper|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|CLAUDE_CODE_OAUTH_TOKEN|OPENAI_API_KEY|model_provider[[:space:]]*=|env_key[[:space:]]*=|experimental_bearer_token|cli_auth_credentials_store";
|
||||
if (target?.kind === "remote" && target.transport === "sandbox" && target.runnerTask && files.length) {
|
||||
if (options.deferNewRunnerTaskCapabilityCheck && target.sandboxLeaseAcquisition?.outcome === "created") return;
|
||||
const description = environmentTaskResultSchema.parse(await target.runnerTask({ kind: "describe" }));
|
||||
if (description.kind !== "description" || description.projectAuthCheckVersion !== 1) {
|
||||
throw unprocessable("The plugin environment cannot validate managed project authentication", { code: "ai_connection_incompatible" });
|
||||
|
||||
@@ -2944,8 +2944,13 @@ export function heartbeatService(
|
||||
appendRunEvent,
|
||||
});
|
||||
if (managedAiRuntime && aiBinding) {
|
||||
try { await assertManagedAiProjectAuth({ ...resolvedConfig, cwd: executionWorkspace.cwd }, aiBinding.provider, executionTarget); }
|
||||
catch (error) { throw classifyManagedProjectAuthFailure(error, agent.id); }
|
||||
try {
|
||||
await assertManagedAiProjectAuth({ ...resolvedConfig, cwd: executionWorkspace.cwd }, aiBinding.provider, executionTarget, {
|
||||
deferNewRunnerTaskCapabilityCheck: agent.adapterType === "paperclip_runner" && !runOptions.nativeRestartRecovery,
|
||||
});
|
||||
} catch (error) {
|
||||
throw classifyManagedProjectAuthFailure(error, agent.id);
|
||||
}
|
||||
}
|
||||
const remoteExecution = realizationResult.remoteExecution;
|
||||
if (nativeChatWorkspaceScope && materializeNativeChatWorktree) {
|
||||
|
||||
@@ -16022,6 +16022,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
}) : undefined,
|
||||
}),
|
||||
warmTaskVersion: description.warmTaskVersion,
|
||||
githubCredentialBrokerVersion: description.githubCredentialBrokerVersion,
|
||||
releaseResumeVersion: description.releaseResumeVersion,
|
||||
authorityRunId: description.releaseResumeVersion === 1 ? input.execution.binding.runId : undefined,
|
||||
releasedSource: input.remoteWarmRecovery?.releasedSource,
|
||||
|
||||
@@ -44,7 +44,8 @@ export async function probePluginRunnerEnvironment(context: AdapterEnvironmentTe
|
||||
identity: managed?.identity, accountIdentity: managed?.accountIdentity,
|
||||
home: managed?.home, environment,
|
||||
});
|
||||
const owner = createPluginRunnerTaskOwner({ ...credentials, taskId: description.taskId, projectAuthCheckVersion: description.projectAuthCheckVersion, task, projectIds: [],
|
||||
const owner = createPluginRunnerTaskOwner({ ...credentials, taskId: description.taskId, projectAuthCheckVersion: description.projectAuthCheckVersion,
|
||||
githubCredentialBrokerVersion: description.githubCredentialBrokerVersion, task, projectIds: [],
|
||||
harness: profile.provider === "acpx" ? profile.acpxAgent! : profile.provider,
|
||||
credentialIdentity: managed?.identity });
|
||||
probeStarted = true;
|
||||
|
||||
@@ -33,8 +33,6 @@ async function prepare(owner: ReturnType<typeof createPluginRunnerTaskOwner>, cu
|
||||
function provider() {
|
||||
let stopped = false;
|
||||
const task = vi.fn(async (operation: any): Promise<unknown> => {
|
||||
if (operation.kind === "describe") return { kind: "description", taskId: "task", githubCredentialBrokerVersion: 1,
|
||||
runner: {version: "fixture", digest: `sha256:${"a".repeat(64)}`} };
|
||||
if (operation.kind === "check_project_auth") return { kind: "project_auth", taskId: "task", allowed: true,
|
||||
provider: operation.provider, configurationId: operation.configurationId };
|
||||
if (operation.kind === "prepare") return { kind: "prepared", taskId: "task", launch: {
|
||||
@@ -747,7 +745,8 @@ it.each(["codex", "claude"])("reuses a warm %s process across run authorities wi
|
||||
ingress.connect.mockReturnValue({ready:Promise.resolve(),close:async()=>{}});
|
||||
const retained = vi.fn(async()=>{});
|
||||
const input = {taskId:"task",task,projectIds:[],harness,projectAuthCheckVersion:1 as const,warmTaskVersion:1 as const,
|
||||
accountIdentity:"account",credentialIdentity:"revision",pollMs:1,onSessionRetained:retained, githubBroker:{url:"https://core.example.test",token:"first-run-capability"}};
|
||||
accountIdentity:"account",credentialIdentity:"revision",pollMs:1,onSessionRetained:retained, githubCredentialBrokerVersion:1 as const,
|
||||
githubBroker:{url:"https://core.example.test",token:"first-run-capability"}};
|
||||
const owner = createPluginRunnerTaskOwner(input);
|
||||
await prepare(owner, launchRequirements(harness));
|
||||
const registration = await owner.registration({} as never,"run");
|
||||
@@ -765,9 +764,9 @@ it.each(["codex", "claude"])("reuses a warm %s process across run authorities wi
|
||||
const nextRegistration=await owner.registration({} as never,"next-run");
|
||||
const nextIdentity={...requirements.identity,runId:"next-run",turnId:"next-turn",itemId:"next-item"};
|
||||
await owner.driver.prepareLaunch!({...launchRequirements(harness),identity:nextIdentity});
|
||||
expect(nextTask.mock.calls[1][0]).toMatchObject({kind:"prepare",priorIdentity:requirements.identity,accountIdentity:"account",githubBroker:{url:"https://core.example.test",token:"next-run-capability"}});
|
||||
expect(nextTask.mock.calls[0][0]).toMatchObject({kind:"prepare",priorIdentity:requirements.identity,accountIdentity:"account",githubBroker:{url:"https://core.example.test",token:"next-run-capability"}});
|
||||
expect(task.mock.calls.find(([op])=>op.kind === "prepare")?.[0]).toMatchObject({githubBroker:input.githubBroker});
|
||||
expect(nextTask.mock.calls.map(([op])=>op.kind)).toEqual(["describe","prepare","check_project_auth"]);
|
||||
expect(nextTask.mock.calls.map(([op])=>op.kind)).toEqual(["prepare","check_project_auth"]);
|
||||
await registration.release(); nextRegistration.activate(); await nextRegistration.ready();
|
||||
expect(nextTask.mock.calls.some(([op])=>op.kind==="submit" || op.kind==="stop" || op.kind==="complete")).toBe(false);
|
||||
await nextRegistration.release(); await nextRegistration.checkpoint("settled"); await handle.completion;
|
||||
@@ -823,14 +822,10 @@ it.each(["codex", "claude"])("refuses warm %s account, harness, project, or cred
|
||||
|
||||
it("rejects a missing broker capability before handing authority to a plugin", async () => {
|
||||
const {task} = provider();
|
||||
const implementation = task.getMockImplementation()!;
|
||||
task.mockImplementation(async operation => operation.kind === "describe"
|
||||
? {kind:"description",taskId:"task",runner:{version:"fixture",digest:`sha256:${"a".repeat(64)}`}}
|
||||
: implementation(operation));
|
||||
const owner = createPluginRunnerTaskOwner({ taskId: "task",task,projectIds:[],harness:"codex",
|
||||
githubBroker:{url:"https://core.example.test",token:"runtime-capability"}});
|
||||
await expect(prepare(owner)).rejects.toThrow("credential broker v1");
|
||||
expect(task.mock.calls.map(([op])=>op.kind)).toEqual(["describe"]);
|
||||
expect(task).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each((["codex", "claude"] as const).flatMap(harness => ["prepared", "unprepared", "foreign target"].map(mode => [harness, mode] as const)))(
|
||||
|
||||
@@ -53,6 +53,7 @@ export function createPluginRunnerTaskOwner(input: {
|
||||
onResumed?: (identity: DurableRecoveryIdentity) => Promise<void>;
|
||||
onRetired?: (identity: DurableRecoveryIdentity) => Promise<void>;
|
||||
githubBroker?: { url: string; token: string };
|
||||
githubCredentialBrokerVersion?: 1;
|
||||
adoptIdentity?: DurableRecoveryIdentity;
|
||||
}) {
|
||||
const taskId = input.taskId;
|
||||
@@ -132,11 +133,8 @@ export function createPluginRunnerTaskOwner(input: {
|
||||
requirements.identity[key as keyof DurableRecoveryIdentity] !== value))) {
|
||||
throw new Error("Plugin Runner source reconciliation unavailable");
|
||||
}
|
||||
if (input.githubBroker) {
|
||||
const description = await call({ kind: "describe" });
|
||||
if (description.kind !== "description" || description.githubCredentialBrokerVersion !== 1) {
|
||||
throw new Error("Update the environment plugin to support the GitHub credential broker v1");
|
||||
}
|
||||
if (input.githubBroker && input.githubCredentialBrokerVersion !== 1) {
|
||||
throw new Error("Update the environment plugin to support the GitHub credential broker v1");
|
||||
}
|
||||
const result = await call(environmentTaskOperationSchema.parse({ kind: "prepare", requirements,
|
||||
...(input.githubBroker ? { githubBroker: input.githubBroker } : {}),
|
||||
|
||||
Reference in new issue
Block a user