From 4d141cdf64394b5466eedacc6397187c4cd32f1b Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 11 Nov 2026 00:49:03 -0800 Subject: [PATCH] Carry plugin task refusals and batch project reads (#20762) --- doc/plugins/PLUGIN_AUTHORING_GUIDE.md | 11 ++++ packages/plugins/sdk/src/environment-tasks.ts | 5 +- .../plugins/sdk/src/host-client-factory.ts | 30 ++++++++- packages/plugins/sdk/src/index.ts | 1 + packages/plugins/sdk/src/protocol.ts | 24 ++++--- packages/plugins/sdk/src/testing.ts | 16 +++++ packages/plugins/sdk/src/types.ts | 31 ++++----- packages/plugins/sdk/src/worker-rpc-host.ts | 8 +++ packages/shared/src/types/plugin.ts | 2 + .../environment-task-runtime.test.ts | 27 ++++++++ .../plugin-project-snapshots.test.ts | 66 +++++++++++++++++++ .../src/__tests__/plugin-sdk-testing.test.ts | 21 ++++++ .../__tests__/plugin-tenant-isolation.test.ts | 8 +++ .../src/services/environment-task-runtime.ts | 20 +++++- .../native-session-executor.test.ts | 10 +++ .../native-runtime/native-session-executor.ts | 12 ++++ .../native-runtime/plugin-runner-task.test.ts | 17 +++++ .../native-runtime/plugin-runner-task.ts | 18 +++-- server/src/services/plugin-host-services.ts | 2 +- server/src/services/plugin-registry.ts | 2 + .../runner-e2e/source-sdk-public-inputs.json | 16 ++--- 21 files changed, 303 insertions(+), 44 deletions(-) create mode 100644 server/src/__tests__/plugin-project-snapshots.test.ts diff --git a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md index 71c79e37742..cb25c2cb365 100644 --- a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md +++ b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md @@ -719,6 +719,17 @@ filesystem — there is no such capability. The browser still goes through the worker via `getData` / `performAction`, and the worker only exposes paths it chose to expose. +### Reading project snapshots + +Workers with `projects.read`, `project.workspaces.read`, and +`projects.instance-secrets` can call +`ctx.projects.readSnapshots(projectIds, companyId, entityType)` to read up to +64 distinct projects in one call. Each snapshot includes workspaces, the +worker's authorized instance secrets, and its own company-scoped entity of the +given type keyed by the project ID. A missing, archived, inaccessible, or +changing project fails the whole request. Older hosts return `null` through +the SDK. Keep instance secrets inside the trusted worker. + ### When to keep worker-mediated project workspace browsing When the data lives inside an existing project workspace, keep the browsing diff --git a/packages/plugins/sdk/src/environment-tasks.ts b/packages/plugins/sdk/src/environment-tasks.ts index 3dd691d4423..98e5f69a00b 100644 --- a/packages/plugins/sdk/src/environment-tasks.ts +++ b/packages/plugins/sdk/src/environment-tasks.ts @@ -121,6 +121,8 @@ export const environmentTaskOperationSchema = z.discriminatedUnion("kind", [ export const environmentTaskResultSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("accepted"), taskId: providerId }).strict(), + z.object({ kind: z.literal("admission_rejected"), taskId: providerId, + reason: z.enum(["session_state_unavailable", "account_binding_mismatch"]) }).strict(), z.object({ kind: z.literal("released"), taskId: providerId }).strict(), z.object({ kind: z.literal("resume"), taskId: providerId, outcome: z.enum(["resumed", "unavailable"]) }).strict(), z.object({ kind: z.literal("copilot_metadata"), taskId: providerId, metadata: z.record(z.string(), z.unknown()), @@ -210,6 +212,7 @@ export interface PluginEnvironmentTaskParams extends Omit { + const companyId = resolveRequiredCompanyId("projects.readSnapshots", params, context); + requireCapability("projects.get"); + requireCapability("projects.listWorkspaces"); + const { projectIds, entityType } = params; + validateProjectSnapshotRequest(projectIds, entityType); + const snapshots: WorkerToHostMethods["projects.readSnapshots"][1] = []; + for (const projectId of projectIds) { + const scope = { companyId, projectId }; + const project = await services.projects.get(scope); + if (project?.id !== projectId || project.companyId !== companyId || project.archivedAt) { + throw new Error("Project snapshot unavailable"); + } + const query = { entityType, scopeKind: "company" as const, scopeId: companyId, externalId: projectId, limit: 1 }; + const [entity] = await services.entities.list(query); + const instanceSecrets = await services.projects.getInstanceSecrets(scope); + const workspaces = await services.projects.listWorkspaces(scope); + const current = await services.projects.get(scope); + const [latest] = await services.entities.list(query); + if (!isDeepStrictEqual(current, project) || !isDeepStrictEqual(latest, entity)) { + throw new Error("Project snapshot changed during read"); + } + snapshots.push({ projectId, workspaces, entity: entity ?? null, instanceSecrets }); + } + return snapshots; + }), "projects.get": gated("projects.get", async (params) => { return services.projects.get(params); }), diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 20e52d9e4ad..4e573c18119 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -329,6 +329,7 @@ export type { PluginEntityRecord, PluginEntityQuery, PluginWorkspace, + PluginProjectSnapshot, PluginExecutionWorkspaceMetadata, Company, Project, diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 215258cc886..95e8af999da 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -26,6 +26,7 @@ import type { PluginLauncherRenderContextSnapshot, PluginLauncherRenderEnvironment, PluginStateScopeKind, + PluginEntityQuery, Company, Project, Issue, @@ -70,6 +71,7 @@ import type { PluginJobContext, PluginExecutionWorkspaceMetadata, PluginWorkspace, + PluginProjectSnapshot, ToolRunContext, ToolResult, PluginLocalFolderListing, @@ -310,6 +312,15 @@ export interface PluginInvocationContext { traceparent?: string; } +export function validateProjectSnapshotRequest(projectIds: unknown, entityType: unknown): void { + if (!Array.isArray(projectIds) || !projectIds.length || projectIds.length > 64 || + new Set(projectIds).size !== projectIds.length || + projectIds.some(id => typeof id !== "string" || !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(id)) || + typeof entityType !== "string" || !/^[a-zA-Z0-9._-]{1,128}$/.test(entityType)) { + throw new Error("Invalid project snapshot scope"); + } +} + /** * Context provided to host-side worker→host handlers after the worker echoes a * host-issued invocation id. @@ -1734,14 +1745,7 @@ export interface WorkerToHostMethods { }, ]; "entities.list": [ - params: { - entityType?: string; - scopeKind?: PluginStateScopeKind; - scopeId?: string; - externalId?: string; - limit?: number; - offset?: number; - }, + params: PluginEntityQuery, result: Array<{ id: string; entityType: string; @@ -1855,6 +1859,10 @@ export interface WorkerToHostMethods { params: { projectId: string; companyId: string }, result: Record, ]; + "projects.readSnapshots": [ + params: { projectIds: string[]; companyId: string; entityType: string }, + result: PluginProjectSnapshot[], + ]; "projects.get": [ params: { projectId: string; companyId: string }, result: Project | null, diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index eb496adf22c..fa99065efa8 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -75,6 +75,7 @@ import type { PluginPerformActionActorContext, PluginPerformActionContext, } from "./protocol.js"; +import { validateProjectSnapshotRequest } from "./protocol.js"; export interface TestHarnessOptions { /** Plugin manifest used to seed capability checks and metadata. */ @@ -1044,6 +1045,21 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { if (!isInCompany(project, companyId) || project.archivedAt) throw new Error("Project instance secrets unavailable"); return structuredClone(projectInstanceSecrets.get(JSON.stringify([companyId, projectId])) ?? {}); }, + async readSnapshots(projectIds, companyId, entityType) { + requireCapability(manifest, capabilitySet, "projects.read"); + requireCapability(manifest, capabilitySet, "project.workspaces.read"); + requireCapability(manifest, capabilitySet, "projects.instance-secrets"); + requireCompanyId(companyId); + validateProjectSnapshotRequest(projectIds, entityType); + return Promise.all(projectIds.map(async projectId => { + const project = await ctx.projects.get(projectId, companyId); + if (!project || project.archivedAt) throw new Error("Project snapshot unavailable"); + const [entity] = await ctx.entities.list({ entityType, scopeKind: "company", scopeId: companyId, externalId: projectId, limit: 1 }); + const instanceSecrets = await ctx.projects.getInstanceSecrets(projectId, companyId); + const workspaces = await ctx.projects.listWorkspaces(projectId, companyId); + return { projectId, workspaces, entity: entity ?? null, instanceSecrets }; + })); + }, async list(input) { requireCapability(manifest, capabilitySet, "projects.read"); const companyId = requireCompanyId(input?.companyId); diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 9555522abfd..80375e46504 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -13,6 +13,7 @@ import type { PaperclipPluginManifestV1, PluginStateScopeKind, PluginEventType, + PluginEntityQuery, PluginToolDeclaration, PluginLauncherDeclaration, Company, @@ -64,6 +65,7 @@ import type { PluginPerformActionContext } from "./protocol.js"; export type { PaperclipPluginManifestV1, + PluginEntityQuery, PluginJobDeclaration, PluginWebhookDeclaration, PluginToolDeclaration, @@ -344,24 +346,6 @@ export interface PluginEntityRecord { updatedAt: string; } -/** - * Query parameters for `ctx.entities.list()`. - */ -export interface PluginEntityQuery { - /** Filter by entity type. */ - entityType?: string; - /** Filter by scope kind. */ - scopeKind?: PluginStateScopeKind; - /** Filter by scope ID. */ - scopeId?: string; - /** Filter by external ID. */ - externalId?: string; - /** Maximum number of results to return. */ - limit?: number; - /** Number of results to skip (for pagination). */ - offset?: number; -} - // --------------------------------------------------------------------------- // Project workspace metadata (read-only via ctx.projects) // --------------------------------------------------------------------------- @@ -876,6 +860,13 @@ export interface PluginEntitiesClient { * * @see PLUGIN_SPEC.md §7 — Project Workspaces */ +export interface PluginProjectSnapshot { + projectId: string; + workspaces: PluginWorkspace[]; + entity: PluginEntityRecord | null; + instanceSecrets: Record; +} + export interface PluginProjectsClient { /** Transient GitHub credential for a stored repository in the current host-issued * lifecycle/workspace invocation. Requires projects.git-credentials. Never persist or log. */ @@ -883,6 +874,10 @@ export interface PluginProjectsClient { /** Trusted worker access to this plugin’s native project credentials. * Requires projects.instance-secrets; never expose via UI/data handlers. */ getInstanceSecrets(projectId: string, companyId: string): Promise>; + /** Read authorized project workspaces, instance secrets and one plugin-owned entity per project. + * Missing or changing projects reject the batch. Null means the host does not support this method. + * Keep the result in the trusted worker. */ + readSnapshots(projectIds: string[], companyId: string, entityType: string): Promise; /** * List projects visible to the plugin. diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index f6b801fc085..72f844508f6 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -761,6 +761,14 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost async getInstanceSecrets(projectId, companyId) { return callHost("projects.getInstanceSecrets", { projectId, companyId }); }, + async readSnapshots(projectIds, companyId, entityType) { + try { + return await callHost("projects.readSnapshots", { projectIds, companyId, entityType }); + } catch (error) { + if (error instanceof JsonRpcCallError && error.code === JSONRPC_ERROR_CODES.METHOD_NOT_FOUND) return null; + throw error; + } + }, async listWorkspaces(projectId: string, companyId: string) { return callHost("projects.listWorkspaces", { projectId, companyId }); diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 31fe2fb3346..36e0f50d97f 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -923,6 +923,8 @@ export interface PluginCompanySettings { * Query filter for `ctx.entities.list`. */ export interface PluginEntityQuery { + scopeKind?: PluginStateScopeKind; + scopeId?: string; /** Optional filter by entity type (e.g. 'project', 'issue'). */ entityType?: string; /** Optional filter by external system identifier. */ diff --git a/server/src/__tests__/environment-task-runtime.test.ts b/server/src/__tests__/environment-task-runtime.test.ts index 349c335aaca..0638ca050ad 100644 --- a/server/src/__tests__/environment-task-runtime.test.ts +++ b/server/src/__tests__/environment-task-runtime.test.ts @@ -61,6 +61,28 @@ describe("stopped Runner checkpoint settlement", () => { }); describe("plugin-provided Runner execution", () => { + it("records a confirmed admission refusal so lease cleanup never looks for a Runner", async () => { + const value = row(); + const fixture = database(value); + const refusal = { kind: "admission_rejected", taskId: "attempt-1", reason: "session_state_unavailable" }; + const workers = worker(refusal); + await expect(executeEnvironmentTask(fixture.db, workers as never, { + companyId: "company", leaseId, operation: submit, + })).resolves.toEqual(refusal); + const update = new PgDialect().sqlToQuery(fixture.sets.at(-1)!.metadata as never); + expect(update.sql).toContain("- 'runnerTaskIdentity'"); + const durable = JSON.parse(String(update.params[0])); + expect(durable).toEqual({ + runnerTaskAdmissionRejection: { taskId: "attempt-1", reason: "session_state_unavailable" }, + runnerTaskDispatched: false, runnerTaskExecutionStopped: true, runnerTaskSessionRetained: false, + }); + Object.assign(value.lease.metadata, durable); + await expect(settleDispatchedEnvironmentTask(database(value).db, workers as never, { + companyId: "company", id: leaseId, + })).resolves.toBe("stopped"); + expect(workers.call.mock.calls).toHaveLength(1); + }); + it.each([false, true])("retires an undispatched warm task only with required preparation proof (%s)", async preparationStopped => { const value = row(); Object.assign(value.lease.metadata, { runnerTaskDispatched: false, @@ -615,5 +637,10 @@ describe("plugin-provided Runner execution", () => { expect(environmentTaskOperationSchema.safeParse({ ...submit, runner: { ...submit.runner, unknownField: true } }).success).toBe(false); expect(() => parseEnvironmentTaskResult({ kind: "status" }, "attempt-1", { kind: "accepted", taskId: "attempt-1" })).toThrow(); expect(parseEnvironmentTaskResult(submit, "attempt-1", { kind: "accepted", taskId: "attempt-1" }).kind).toBe("accepted"); + const rejected = { kind: "admission_rejected", taskId: "attempt-1", reason: "account_binding_mismatch" }; + expect(parseEnvironmentTaskResult(submit, "attempt-1", rejected)).toEqual(rejected); + expect(() => parseEnvironmentTaskResult(submit, "attempt-2", rejected)).toThrow(); + expect(() => parseEnvironmentTaskResult(submit, "attempt-1", { ...rejected, reason: "unknown" })).toThrow(); + expect(() => parseEnvironmentTaskResult({ kind: "stop" }, "attempt-1", rejected)).toThrow(); }); }); diff --git a/server/src/__tests__/plugin-project-snapshots.test.ts b/server/src/__tests__/plugin-project-snapshots.test.ts new file mode 100644 index 00000000000..0a08aaa279d --- /dev/null +++ b/server/src/__tests__/plugin-project-snapshots.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import { createHostClientHandlers, type HostServices } from "../../../packages/plugins/sdk/src/host-client-factory.js"; +import type { PluginCapability } from "@paperclipai/shared"; + +const companyId = "00000000-0000-4000-8000-000000000001"; +const otherCompany = "00000000-0000-4000-8000-000000000002"; +const first = "00000000-0000-4000-8000-000000000003"; +const second = "00000000-0000-4000-8000-000000000004"; +const capabilities: PluginCapability[] = ["projects.read", "project.workspaces.read", "projects.instance-secrets"]; +const entityType = "project-settings"; + +function fixture(granted = capabilities) { + const projects = new Map([first, second].map(id => [id, { id, companyId, archivedAt: null as Date | null }])); + const entities = new Map([first, second].map(id => [id, { id, status: "ready" }])); + const services = { + projects: { + get: vi.fn(async ({ projectId }: { projectId: string }) => structuredClone(projects.get(projectId) ?? null)), + listWorkspaces: vi.fn(async ({ projectId }: { projectId: string }) => [{ projectId }]), + getInstanceSecrets: vi.fn(async ({ projectId }: { projectId: string }) => ({ fixture: projectId })), + }, + entities: { list: vi.fn(async ({ externalId }: { externalId: string }) => entities.has(externalId) ? [{ ...entities.get(externalId) }] : []) }, + }; + const handlers = createHostClientHandlers({ pluginId: "project-plugin", capabilities: granted, services: services as unknown as HostServices }); + return { services, projects, entities, read: (projectIds: string[], requestedCompany = companyId) => handlers["projects.readSnapshots"]( + { projectIds, companyId: requestedCompany, entityType }, { invocationScope: { companyId } }, + ) }; +} + +describe("plugin project snapshots", () => { + it("returns the requested projects in order with only the plugin's company-scoped entity", async () => { + const f = fixture(); + const result = await f.read([second, first]); + expect(result.map(row => [row.projectId, row.workspaces[0].projectId, row.entity?.id, row.instanceSecrets.fixture])) + .toEqual([[second, second, second, second], [first, first, first, first]]); + expect(f.services.entities.list.mock.calls.map(([query]) => query)).toEqual([second, second, first, first].map(externalId => ({ + entityType, scopeKind: "company", scopeId: companyId, externalId, limit: 1, + }))); + }); + + it("requires each underlying capability and the invocation's company", async () => { + for (const omitted of capabilities) { + const f = fixture(capabilities.filter(capability => capability !== omitted)); + await expect(f.read([first])).rejects.toThrow("missing required capability"); + expect(f.services.projects.get).not.toHaveBeenCalled(); + } + const f = fixture(); + await expect(f.read([first], otherCompany)).rejects.toThrow(/invocation.*company/i); + await expect(f.read([first, first])).rejects.toThrow("Invalid project snapshot scope"); + expect(f.services.projects.get).not.toHaveBeenCalled(); + }); + + it.each(["wrong company", "archived", "missing", "changed entity"])("rejects the whole batch for a %s project", async failure => { + const f = fixture(); + if (failure === "wrong company") f.projects.set(second, { id: second, companyId: otherCompany, archivedAt: null }); + else if (failure === "archived") f.projects.get(second)!.archivedAt = new Date(); + else if (failure === "missing") f.projects.delete(second); + else f.services.projects.listWorkspaces.mockImplementation(async ({ projectId }: { projectId: string }) => { + if (projectId === second) f.entities.get(second)!.status = "changed"; + return [{ projectId }]; + }); + await expect(f.read([first, second])).rejects.toThrow(/Project snapshot (unavailable|changed during read)/); + if (failure !== "changed entity") { + expect(f.services.projects.getInstanceSecrets).not.toHaveBeenCalledWith({ companyId, projectId: second }); + } + }); +}); diff --git a/server/src/__tests__/plugin-sdk-testing.test.ts b/server/src/__tests__/plugin-sdk-testing.test.ts index 6c2edd9e0bc..4d1a4f09338 100644 --- a/server/src/__tests__/plugin-sdk-testing.test.ts +++ b/server/src/__tests__/plugin-sdk-testing.test.ts @@ -42,6 +42,27 @@ function seededIssue(input: Partial & Pick { + it("rejects project snapshot requests that the host would deny", async () => { + const manifest: PaperclipPluginManifestV1 = { + id: "paperclip.test-project-snapshots", + apiVersion: 1, + version: "0.1.0", + displayName: "Project Snapshots", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: ["projects.read", "project.workspaces.read", "projects.instance-secrets"], + entrypoints: { worker: "./dist/worker.js" }, + }; + const harness = createTestHarness({ manifest }); + const projectId = "10000000-0000-4000-8000-000000000001"; + await expect(harness.ctx.projects.readSnapshots([], "company", "settings")).rejects.toThrow("Invalid project snapshot scope"); + await expect(harness.ctx.projects.readSnapshots([projectId, projectId], "company", "settings")).rejects.toThrow("Invalid project snapshot scope"); + await expect(harness.ctx.projects.readSnapshots([projectId], "company", "bad type")).rejects.toThrow("Invalid project snapshot scope"); + const denied = createTestHarness({ manifest: { ...manifest, capabilities: [] } }); + await expect(denied.ctx.projects.readSnapshots([], "company", "settings")).rejects.toThrow("missing required capability"); + }); + it("returns scoped execution workspace metadata with the read capability", async () => { const manifest: PaperclipPluginManifestV1 = { id: "paperclip.test-execution-workspace-metadata", diff --git a/server/src/__tests__/plugin-tenant-isolation.test.ts b/server/src/__tests__/plugin-tenant-isolation.test.ts index dc5776d10de..646653c1cb2 100644 --- a/server/src/__tests__/plugin-tenant-isolation.test.ts +++ b/server/src/__tests__/plugin-tenant-isolation.test.ts @@ -372,6 +372,14 @@ describeEmbeddedPostgres("plugin tenant isolation (company_id FK)", () => { expect(fromInstance?.companyId).toBeNull(); expect(fromInstance?.title).toBe("instance"); + const services = buildHostServices(db, pluginId, "tenant-isolation-test", createEventBusStub()); + await expect(services.entities.list({ + entityType: "issue", scopeKind: "company", scopeId: companyB, externalId: "ext-shared", limit: 1, + })).resolves.toEqual([expect.objectContaining({ title: "B" })]); + await expect(services.entities.list({ + entityType: "issue", scopeKind: "company", scopeId: randomUUID(), externalId: "ext-shared", limit: 1, + })).resolves.toEqual([]); + // Unknown tenant returns null, not another tenant's row. const unknown = await registry.getEntityByExternalId( pluginId, diff --git a/server/src/services/environment-task-runtime.ts b/server/src/services/environment-task-runtime.ts index 7aa54e50c3e..7c8a787a953 100644 --- a/server/src/services/environment-task-runtime.ts +++ b/server/src/services/environment-task-runtime.ts @@ -258,7 +258,7 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage if (operation.kind === "submit" || operation.kind === "copilot_metadata") { // No ticket, headers, environment values, or launch paths belong in metadata. const marked = await db.update(environmentLeases).set({ - metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || ${JSON.stringify({ runnerTaskLease: true, runnerTaskDispatched: true, runnerTaskExecutionStopped: false, runnerTaskSessionRetained: false, ...(operation.kind === "submit" ? { runnerTaskIdentity: operation.runner, runnerTaskResumeFrom: operation.resumeFrom ?? null } : {}) })}::jsonb`, + metadata: sql`(coalesce(${environmentLeases.metadata}, '{}'::jsonb) - 'runnerTaskAdmissionRejection') || ${JSON.stringify({ runnerTaskLease: true, runnerTaskDispatched: true, runnerTaskExecutionStopped: false, runnerTaskSessionRetained: false, ...(operation.kind === "submit" ? { runnerTaskIdentity: operation.runner, runnerTaskResumeFrom: operation.resumeFrom ?? null } : {}) })}::jsonb`, }).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, input.companyId), eq(environmentLeases.status, "active"), sql`coalesce(${environmentLeases.metadata}->>'runnerTaskRetiring', 'false') != 'true'`, requiredProjectAuthProof ? sql`${environmentLeases.metadata}->'runnerTaskProjectAuthValidation' = ${JSON.stringify(requiredProjectAuthProof)}::jsonb` : undefined, @@ -280,7 +280,9 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage if (marked.length !== 1) throw new Error("Plugin warm task attachment lost authority"); } try { - const stoppedWithoutSession = !!run && metadata.runnerTaskExecutionStopped === true && metadata.runnerTaskSessionRetained !== true; + const admissionRejection = metadata.runnerTaskAdmissionRejection as { taskId?: unknown } | undefined; + const stoppedWithoutSession = !!run && metadata.runnerTaskExecutionStopped === true && + metadata.runnerTaskSessionRetained !== true && admissionRejection?.taskId !== taskId; if (operation.kind === "describe" && (metadata.runnerTaskReused === true || stoppedWithoutSession)) { const state = parseEnvironmentTaskResult({kind:"status"}, taskId, await callWorker("environmentTask", { driverKey: metadata.driverKey, companyId: input.companyId, environmentId: environment?.id ?? null, @@ -408,6 +410,20 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage const result = await callWorker("environmentTask", params, timeout); if (executionContext) context!.assertOpen(); const parsed = parseEnvironmentTaskResult(operation, taskId, result); + if (operation.kind === "submit" && parsed.kind === "admission_rejected") { + const recorded = await db.update(environmentLeases).set({ + metadata: sql`(coalesce(${environmentLeases.metadata}, '{}'::jsonb) - 'runnerTaskIdentity') || ${JSON.stringify({ + runnerTaskAdmissionRejection: { taskId, reason: parsed.reason }, + runnerTaskDispatched: false, runnerTaskExecutionStopped: true, runnerTaskSessionRetained: false, + })}::jsonb`, + }).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, input.companyId), + eq(environmentLeases.providerLeaseId, taskId), + sql`${environmentLeases.metadata}->>'runnerTaskDispatched' = 'true'`, + sql`${environmentLeases.metadata}->'runnerTaskIdentity' = ${JSON.stringify(operation.runner)}::jsonb`, + )).returning({ id: environmentLeases.id }); + if (recorded.length !== 1) throw new Error("Plugin Runner admission receipt is stale"); + return parsed; + } if (stoppedSourceCheckpoint && parsed.kind === "state" && isSettledReadyRunnerState(parsed.state)) { parsed.state = { ...parsed.state, lifecycle: "suspended" }; } 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 65aa6b99f5a..09fff02dfce 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -13989,6 +13989,16 @@ describe("native session bounded recovery", () => { }, ); + it("stops same-run retries when the execution provider proves task admission was rejected", () => { + for (const prefix of ["", "runner_ingress_unavailable: runnerd exited unexpectedly with code 1: "]) { + const code = nativeSessionFailureSourceCode(new Error(`${prefix}runner_task_admission_rejected: session_state_unavailable`)); + expect(code).toBe("runner_task_admission_rejected"); + expect(nativeSessionFailureDisposition(1, new Date(), code)).toEqual({ + phase: "terminal_failure", failureCode: code, nextAttemptAt: null, + }); + } + }); + it("stops same-run retries when the runner proves the saved provider session incompatible", () => { const failure = new NativeProviderStateIncompatibleError( "Error: PRP command run.attach failed: ACPX durable launch profile digest does not match runner startup", diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 3f50677c75a..3b37d6c3145 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -8426,6 +8426,7 @@ export function nativeSessionFailureDisposition( sourceFailureCode?: ReturnType, ) { const permanentFailure = + sourceFailureCode === "runner_task_admission_rejected" || sourceFailureCode === "runner_ingress_access_rejected" || sourceFailureCode === "native_provider_runtime_missing" || sourceFailureCode === "native_provider_model_rejected" || @@ -8543,6 +8544,7 @@ export function nativeSessionFailureSourceCode( | typeof NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ | "provider_session_open_rejected" | "provider_session_open_timeout" + | "runner_task_admission_rejected" | "runner_ingress_access_rejected" | "runner_ingress_acquisition_failed" | "local_runtime_api_listener_unavailable" @@ -8583,6 +8585,9 @@ export function nativeSessionFailureSourceCode( } if (runnerClosedTurnStartSession(error)) return NATIVE_PROVIDER_SESSION_CLOSED; const message = error instanceof Error ? error.message : String(error); + if (/^(?:runner_ingress_unavailable: runnerd exited unexpectedly with code 1: )?runner_task_admission_rejected: (session_state_unavailable|account_binding_mismatch)$/.test(message)) { + return "runner_task_admission_rejected"; + } if (/classification=runtime_package_missing\)/.test(message)) return "native_provider_runtime_missing"; if (/native_provider_model_rejected/i.test(message)) @@ -8679,6 +8684,9 @@ export function nativeSessionFailureSourceCode( const NATIVE_PROVIDER_SIDE_EFFECT_PENDING_ACTION = "The saved provider session has an unfinished provider turn, or a tool call whose result never reached the runner, so Paperclip did not replace the session or retry it. Reconcile the action: check whether it ran and record the outcome. The task then continues in a fresh provider session in the same workspace. The saved session and workspace are unchanged."; +const RUNNER_TASK_ADMISSION_REJECTED_ACTION = + "The execution provider refused to start this task. Review the admission reason before retrying. Automatic retries are stopped."; + const NATIVE_PROVIDER_STATE_INCOMPATIBLE_ACTION = "The current runner cannot restore this task's saved provider session, so retrying this run cannot succeed. Retry the task to continue in a fresh provider session with the same workspace. The earlier provider conversation is not carried over."; @@ -12425,6 +12433,8 @@ async function executePaperclipNativeSessionAttempt( ? nativeCleanupRefusalAction(completedTurnCleanup) : sourceFailureCode === "runner_remote_provider_verification_unavailable" ? "Provider files could not be verified after two read attempts. Inspect the retained workspace and command transport before retrying the original run." + : sourceFailureCode === "runner_task_admission_rejected" + ? RUNNER_TASK_ADMISSION_REJECTED_ACTION : sourceFailureCode === "runner_ingress_access_rejected" ? "The computer provider rejected access. Restore the environment's credentials or permissions before retrying. Automatic retries are stopped." : sourceFailureCode === "native_provider_terminal_failed" @@ -12694,6 +12704,8 @@ async function executePaperclipNativeSessionAttempt( ? NATIVE_PROVIDER_SESSION_CLOSED_ACTION : sourceFailureCode === "native_session_cleanup_quarantined" ? nativeCleanupRefusalAction(completedTurnCleanup) + : sourceFailureCode === "runner_task_admission_rejected" + ? RUNNER_TASK_ADMISSION_REJECTED_ACTION : sourceFailureCode === "runner_ingress_access_rejected" ? "The computer provider rejected access. Restore the environment's credentials or permissions before retrying. Automatic retries are stopped." : sourceFailureCode === "native_provider_terminal_failed" diff --git a/server/src/services/native-runtime/plugin-runner-task.test.ts b/server/src/services/native-runtime/plugin-runner-task.test.ts index d7089dfaf35..004a1e8ab60 100644 --- a/server/src/services/native-runtime/plugin-runner-task.test.ts +++ b/server/src/services/native-runtime/plugin-runner-task.test.ts @@ -440,6 +440,23 @@ describe("plugin native Runner ownership", () => { expect(task.mock.calls.find(([op]) => op.kind === "stop")).toBeUndefined(); await registration.release(); await registration.checkpoint("settled"); await handle.completion; }); + it("surfaces a proven admission rejection without polling or stopping the task", async () => { + const { task } = provider(); + const perform = task.getMockImplementation()!; + task.mockImplementation(async op => op.kind === "submit" + ? { kind: "admission_rejected", taskId: "task", reason: "session_state_unavailable" } : perform(op)); + const owner = createPluginRunnerTaskOwner({ taskId: "task", projectAuthCheckVersion: 1, task, projectIds: [], harness: "codex" }); + await prepare(owner); + const registration = await owner.registration({} as never, "run"); + const process = owner.launcher(launchSpec()); + registration.activate(); + await expect(registration.ready()).rejects.toThrow("runner_task_admission_rejected: session_state_unavailable"); + await expect(registration.failure).rejects.toThrow("runner_task_admission_rejected: session_state_unavailable"); + await expect(process.completion).resolves.toMatchObject({ code: 1 }); + await registration.checkpoint("unsettled"); + expect(task.mock.calls.map(([op]) => op.kind)).toEqual(["prepare", "check_project_auth", "submit"]); + }); + it("reconciles a lost submit reply without launching another attempt", async () => { const { task } = provider(); const perform = task.getMockImplementation()!; diff --git a/server/src/services/native-runtime/plugin-runner-task.ts b/server/src/services/native-runtime/plugin-runner-task.ts index 2b5855fa2ce..72893bff48c 100644 --- a/server/src/services/native-runtime/plugin-runner-task.ts +++ b/server/src/services/native-runtime/plugin-runner-task.ts @@ -71,7 +71,7 @@ export function createPluginRunnerTaskOwner(input: { let adoptionVerified = false; let markReady!: () => void; let failReady!: (error: unknown) => void; - let confirmStopped: (code: number) => void = () => {}; + let confirmStopped: (code: number, error?: Error) => void = () => {}; let stoppedState: Extract | undefined; let completed = false; let retiredBeforeClaim = false; @@ -225,11 +225,11 @@ export function createPluginRunnerTaskOwner(input: { return true; } }; const completion = new Promise>(resolve => { - confirmStopped = code => { + confirmStopped = (code, error) => { if (child.exitCode !== null) return; child.exitCode = code; - failReady(new Error("Plugin Runner stopped before connection")); - resolve({ code, signal: null, stdout: "", stderr: "" }); + failReady(error ?? new Error("Plugin Runner stopped before connection")); + resolve({ code, signal: null, stdout: "", stderr: error?.message ?? "" }); }; }); void (async () => { @@ -248,9 +248,17 @@ export function createPluginRunnerTaskOwner(input: { return; } submitted = true; - try { await call(operation); } catch { + const result = await call(operation).catch(async () => { // Submission can have succeeded remotely. Inspect the same saved task. await readStatus(); + }); + if (result?.kind === "admission_rejected") { + submitted = false; + removeStartupAbort(); + const error = new Error(`runner_task_admission_rejected: ${result.reason}`); + rejectFailure(error); + confirmStopped(1, error); + return; } let stopSent = false; while (child.exitCode === null) { diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index ee41f2c1e33..caf0a02bb67 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -1754,7 +1754,7 @@ export function buildHostServices( return registry.upsertEntity(pluginId, params as any) as any; }, async list(params) { - return registry.listEntities(pluginId, params as any) as any; + return registry.listEntities(pluginId, params) as any; }, }, diff --git a/server/src/services/plugin-registry.ts b/server/src/services/plugin-registry.ts index 68d15d5d9de..5f5c84470d3 100644 --- a/server/src/services/plugin-registry.ts +++ b/server/src/services/plugin-registry.ts @@ -541,6 +541,8 @@ export function pluginRegistryService(db: Db) { const conditions = [eq(pluginEntities.pluginId, pluginId)]; if (query?.entityType) conditions.push(eq(pluginEntities.entityType, query.entityType)); if (query?.externalId) conditions.push(eq(pluginEntities.externalId, query.externalId)); + if (query?.scopeKind) conditions.push(eq(pluginEntities.scopeKind, query.scopeKind)); + if (query?.scopeId) conditions.push(eq(pluginEntities.scopeId, query.scopeId)); return db .select() diff --git a/tests/runner-e2e/source-sdk-public-inputs.json b/tests/runner-e2e/source-sdk-public-inputs.json index 6eccbfd0a56..016c2082ccd 100644 --- a/tests/runner-e2e/source-sdk-public-inputs.json +++ b/tests/runner-e2e/source-sdk-public-inputs.json @@ -13,23 +13,23 @@ "packages/plugins/sdk/src/environment-stop-error.ts": "b0e03f8aa2b2170ba095da5c7138050fced3f3cb", "packages/plugins/sdk/src/environment-sync-error.ts": "7d6a51a95da49a7da4525aebbab6382dc5c44ae8", "packages/plugins/sdk/src/environment-task-error.ts": "98aae8a06743b38884b68cb9a3ec44c57b34c0e4", - "packages/plugins/sdk/src/environment-tasks.ts": "3dd691d4423402ab7c0d3249a03bf6b22eeb78f2", - "packages/plugins/sdk/src/host-client-factory.ts": "f1bba9f1830701510f2c94bc623465cb2d93413b", + "packages/plugins/sdk/src/environment-tasks.ts": "98e5f69a00b81eb6e8c25bdb5fcdc22e5485ef18", + "packages/plugins/sdk/src/host-client-factory.ts": "e0301c390ea13f2340b669383f07af2455e48c8a", "packages/plugins/sdk/src/idle-drain.test.ts": "cc59f7881e7d46c89395a1d93d74f6c6b9b0a64a", "packages/plugins/sdk/src/idle-drain.ts": "ae6e4c6be4657a3d541396922b3bc551b8db557f", - "packages/plugins/sdk/src/index.ts": "20e52d9e4ad3d5015cfbc604da49756e568e34ba", + "packages/plugins/sdk/src/index.ts": "4e573c181198ea98442321cc136341741e856ff7", "packages/plugins/sdk/src/invocation-tracer.ts": "b28ac4aa4bc3e82658abac4a8081ed4508008696", "packages/plugins/sdk/src/protocol.duplex-channel.test.ts": "18255eb3d63038dc459240c111cbcb29fcba25cc", - "packages/plugins/sdk/src/protocol.ts": "215258cc8862b342acdbd6446fb49d00cc578fd8", - "packages/plugins/sdk/src/testing.ts": "eb496adf22c62b6d68ef467c25465afd0973d236", - "packages/plugins/sdk/src/types.ts": "9555522abfd796c59b1439dd433be356ec344bb5", + "packages/plugins/sdk/src/protocol.ts": "95e8af999dabf93b1b0e531edfec4c80daf52b61", + "packages/plugins/sdk/src/testing.ts": "fa99065efa867d8f8a9372cb4a448b8f9d687883", + "packages/plugins/sdk/src/types.ts": "80375e46504f96c7c0c1f156319dfb81275d16cc", "packages/plugins/sdk/src/ui/clipboard.ts": "1f42069c97d731e636a6ad5481dad22d20c0ed63", "packages/plugins/sdk/src/ui/components.ts": "3f0594408a1bb5ac3a8d00e709f91877dd260359", "packages/plugins/sdk/src/ui/hooks.ts": "686d02cf4b35f1cddb79a83a4c4d81fd52f0100e", "packages/plugins/sdk/src/ui/index.ts": "f7d1cee64a8f0c924bd10a1bd20745a52fe2060e", "packages/plugins/sdk/src/ui/runtime.ts": "998428e9701eba3b762d3417236d32c2d0d5763d", "packages/plugins/sdk/src/ui/types.ts": "5a5644acf6554d2e5e1d000bfd03b97d57001eb2", - "packages/plugins/sdk/src/worker-rpc-host.ts": "f6b801fc08596a8ab6344f12ac0ee80f256b2066", + "packages/plugins/sdk/src/worker-rpc-host.ts": "72f844508f695737a44d34b01b3949ec3be10aec", "packages/plugins/sdk/src/workspace-operation-error.ts": "2cd247d002d83b163239229e631bca4e519e5075", "packages/plugins/sdk/src/workspace-provider.ts": "c212c3baffd4151010060c9ec7393bd989a9e833", "packages/plugins/sdk/tsconfig.json": "d62ebdff94bc4b75dabf1fcad187e1e7a90fc794", @@ -405,7 +405,7 @@ "packages/shared/src/types/issue.ts": "4598caecc50e60450a1204de832a4fcd7991096d", "packages/shared/src/types/live.ts": "b90e55e4de8600c6fc17d7fa8d89079d4ae655ec", "packages/shared/src/types/native-finalization.ts": "c026a73003ae232fea4f7624d561493b383df358", - "packages/shared/src/types/plugin.ts": "31fe2fb3346dabbf83aa7e37193bb0dd229ea8a7", + "packages/shared/src/types/plugin.ts": "36e0f50d97f96532cb1fbdf3223368d92a7e9b1e", "packages/shared/src/types/project.ts": "2a20b4d8772d5f0fa5015828818b025abc7cedcf", "packages/shared/src/types/quota.ts": "a0686a9afb3111a4de093f66200dc0c9152e883e", "packages/shared/src/types/resource-memberships.ts": "9eab6fad50facf3e489f8c40e5bedd92e1d1faf8",