diff --git a/doc/plugins/AGENT_RUNTIME_SYNC.md b/doc/plugins/AGENT_RUNTIME_SYNC.md index 1a6e28e5007..a7f6247ec26 100644 --- a/doc/plugins/AGENT_RUNTIME_SYNC.md +++ b/doc/plugins/AGENT_RUNTIME_SYNC.md @@ -45,6 +45,15 @@ The instruction root identifies host files that the trusted worker can package through its existing filesystem access. This API grants no arbitrary path reads. Never expose instance secrets through UI data, logs, plugin state, or agent files. +Use `ctx.agents.getRuntimeIdentity(agentId, companyId)` for freshness checks. +It returns only the revision, agent ID, company ID, status, lifecycle state and +version, and this plugin's current instance secrets. It uses the same ownership, +capability, and company checks as `getRuntime`. Each call reads current state; +it does not cache grants or secrets. It does not resolve instruction files, +company reporting relationships, skills, or connection assignments. Read the full +runtime once to obtain synchronization inputs, then use the identity reader to +fence effects against changes. + Re-read current state before external effects. A queued event can be older than the returned revision. Fence stale revisions at the destination and use its conditional writes. Current configuration is desired state, not a frozen run diff --git a/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md b/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md index c0bbca61a58..c2ccb0461c8 100644 --- a/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md +++ b/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md @@ -91,6 +91,17 @@ 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. +A trusted provider can instead return `projectAuthCheckAtSubmit: true` in its +`prepared` result. Core then skips the separate check. This flag delegates the +check to submission; it does not claim that preparation checked authentication. +The provider must validate the effective configuration and current project grants +inside `submit`, before it installs credentials on the execution host or starts +the task. It must reject conflicts and changed preparation before those effects. +Core pins the configuration, provider, and project set on the lease and checks +them again at dispatch. A new preparation invalidates that scope. Providers that +omit the flag keep the separate check. The flag requires a Core version that +supports it; older strict result schemas reject it. + The plugin must check the effective configuration for that prepared launch. Reject project settings, parent settings, helper commands, or environment routing that can replace the selected account or redirect its credential. diff --git a/doc/plugins/PLUGIN_RUNNER_EXECUTION.md b/doc/plugins/PLUGIN_RUNNER_EXECUTION.md index 8b537369074..0be326e6821 100644 --- a/doc/plugins/PLUGIN_RUNNER_EXECUTION.md +++ b/doc/plugins/PLUGIN_RUNNER_EXECUTION.md @@ -69,10 +69,12 @@ skills=/home/agent/.agents/skills//SKILL.md These are execution-host locations. Core does not copy the home for each run. The plugin must declare `projectAuthCheckVersion: 1` for all native tasks. -Core requires `check_project_auth` to allow the exact prepared configuration -before it sends credentials. The plugin must reject conflicting project or -parent authentication settings. It must keep the checked configuration fixed -until submission. See [managed account validation](PLUGIN_ENVIRONMENT_TASKS.md#managed-account-validation). +Core requires an allowed `check_project_auth` result, or a prepared result with +`projectAuthCheckAtSubmit: true`. The latter delegates the check to the trusted +plugin's submit handler before credential installation or task start. The plugin +must reject conflicting project or parent authentication settings. It must keep +the checked configuration fixed until submission. See +[launch authentication validation](PLUGIN_ENVIRONMENT_TASKS.md#launch-authentication-validation). Core sends authorized project IDs, the PRP identity, launch limits, and the bootstrap ticket through `submit`. It does not send a process command. diff --git a/packages/plugins/sdk/src/environment-tasks.ts b/packages/plugins/sdk/src/environment-tasks.ts index fd400a1ef86..59b05730b48 100644 --- a/packages/plugins/sdk/src/environment-tasks.ts +++ b/packages/plugins/sdk/src/environment-tasks.ts @@ -115,7 +115,7 @@ export const environmentTaskResultSchema = z.discriminatedUnion("kind", [ sha256: z.string().regex(/^[a-f0-9]{64}$/), }).strict(), z.object({ kind: z.literal("state"), taskId: providerId, state: z.record(z.string(), z.unknown()) }).strict(), - z.object({ kind: z.literal("prepared"), taskId: providerId, launch: preparedLaunch }).strict(), + z.object({ kind: z.literal("prepared"), taskId: providerId, launch: preparedLaunch, projectAuthCheckAtSubmit: z.literal(true).optional() }).strict(), z.object({ kind: z.literal("credentials"), taskId: providerId, /** Null proves that cancellation fenced admission before the task could start. It must not mean an unavailable export. */ credential: environmentTaskCredentialSchema.nullable() }).strict(), z.object({ kind: z.literal("description"), taskId: providerId, diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index 35ed9bd4998..34156211080 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -274,6 +274,7 @@ export interface HostServices { /** Provides `agents.list`, `agents.get`, `agents.pause`, `agents.resume`, `agents.invoke`. */ agents: { list(params: WorkerToHostMethods["agents.list"][0]): Promise; + getRuntimeIdentity?(params: WorkerToHostMethods["agents.getRuntimeIdentity"][0]): Promise; getRuntime?(params: WorkerToHostMethods["agents.getRuntime"][0]): Promise; get(params: WorkerToHostMethods["agents.get"][0]): Promise; pause(params: WorkerToHostMethods["agents.pause"][0]): Promise; @@ -495,6 +496,7 @@ const METHOD_CAPABILITY_MAP: Record { return services.agents.list(params); }), + "agents.getRuntimeIdentity": gated("agents.getRuntimeIdentity", async (params) => { + if (!services.agents.getRuntimeIdentity) throw new Error("Agent runtime identity is unavailable"); + return services.agents.getRuntimeIdentity(params); + }), "agents.getRuntime": gated("agents.getRuntime", async (params) => { if (!services.agents.getRuntime) throw new Error("Agent runtime state is unavailable"); return services.agents.getRuntime(params); diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 0f349fe08f6..4cfb3ff1dc3 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -273,6 +273,7 @@ export type { PluginIssueSummariesClient, PluginAgentsClient, PluginAgentRuntimeState, + PluginAgentRuntimeIdentity, PluginAgentConnectionAssignments, PluginAgentRuntimeSkill, PluginAccessClient, diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index a6d8d0b526e..c3c721b6dff 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -1,6 +1,6 @@ import type { PluginWorkspaceParams, PluginWorkspaceResult } from "./workspace-provider.js"; import type { PluginEnvironmentTaskParams, PluginEnvironmentTaskResult } from "./environment-tasks.js"; -import type { PluginAgentRuntimeState } from "./types.js"; +import type { PluginAgentRuntimeState, PluginAgentRuntimeIdentity } from "./types.js"; import type { AgentLifecycleRequest, AgentLifecycleResult } from "@paperclipai/shared"; import type { AiConnectionRouterRequest, AiConnectionRouterResult } from "@paperclipai/shared"; /** @@ -2146,6 +2146,10 @@ export interface WorkerToHostMethods { params: { companyId: string; status?: string; limit?: number; offset?: number }, result: Agent[], ]; + "agents.getRuntimeIdentity": [ + params: { agentId: string; companyId: string }, + result: PluginAgentRuntimeIdentity | null, + ]; "agents.getRuntime": [ params: { agentId: string; companyId: string }, result: PluginAgentRuntimeState | null, diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 1f268522cff..7617fa05e23 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -2097,6 +2097,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { }, }, agents: { + async getRuntimeIdentity() { + requireCapability(manifest, capabilitySet, "agents.lifecycle.manage"); + throw new Error("Agent runtime identity requires a host service fixture"); + }, async getRuntime() { requireCapability(manifest, capabilitySet, "agents.lifecycle.manage"); throw new Error("Agent runtime state requires a host service fixture"); diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 7fd9ce0cbbc..641da2864b1 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -1725,7 +1725,12 @@ export interface PluginAgentRuntimeState { instanceSecrets: Record; } +export type PluginAgentRuntimeIdentity = Pick & { + agent: Pick; +}; + export interface PluginAgentsClient { + getRuntimeIdentity(agentId: string, companyId: string): Promise; getRuntime(agentId: string, companyId: string): Promise; list(input: { companyId: string; status?: Agent["status"]; limit?: number; offset?: number }): Promise; get(agentId: string, companyId: string): Promise; diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index 5db822712e4..9a4472ea918 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -1163,6 +1163,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }, agents: { + async getRuntimeIdentity(agentId, companyId) { + return callHost("agents.getRuntimeIdentity", { agentId, companyId }); + }, async getRuntime(agentId, companyId) { return callHost("agents.getRuntime", { agentId, companyId }); }, diff --git a/packages/plugins/sdk/tests/host-client-factory.test.ts b/packages/plugins/sdk/tests/host-client-factory.test.ts index cb42df7327e..f892a120c9c 100644 --- a/packages/plugins/sdk/tests/host-client-factory.test.ts +++ b/packages/plugins/sdk/tests/host-client-factory.test.ts @@ -421,17 +421,17 @@ describe("createHostClientHandlers capability gating for LOOA-641 methods", () = describe("agent runtime reader", () => { - it("requires lifecycle capability and the host invocation company before accessing runtime inputs", async () => { + it.each(["getRuntime", "getRuntimeIdentity"] as const)("requires lifecycle capability and invocation company for %s", async method => { const getRuntime = vi.fn(async () => null); - const services = { agents: { getRuntime } } as unknown as HostServices; + const services = { agents: { [method]: getRuntime } } as unknown as HostServices; const params = { companyId: "company-a", agentId: "agent-a" }; const context = { invocationScope: { companyId: "company-a" } }; const denied = createHostClientHandlers({ pluginId: "runtime", capabilities: ["agents.read"], services }); - await expect(denied["agents.getRuntime"](params, context)).rejects.toBeInstanceOf(CapabilityDeniedError); + await expect(denied[`agents.${method}`](params, context)).rejects.toBeInstanceOf(CapabilityDeniedError); const allowed = createHostClientHandlers({ pluginId: "runtime", capabilities: ["agents.lifecycle.manage"], services }); - await expect(allowed["agents.getRuntime"](params, { invocationScope: { companyId: "other" } })).rejects.toBeInstanceOf(InvocationScopeDeniedError); + await expect(allowed[`agents.${method}`](params, { invocationScope: { companyId: "other" } })).rejects.toBeInstanceOf(InvocationScopeDeniedError); expect(getRuntime).not.toHaveBeenCalled(); - await expect(allowed["agents.getRuntime"](params, context)).resolves.toBeNull(); + await expect(allowed[`agents.${method}`](params, context)).resolves.toBeNull(); expect(getRuntime).toHaveBeenCalledOnce(); }); }); diff --git a/server/src/__tests__/agent-instance-secrets.test.ts b/server/src/__tests__/agent-instance-secrets.test.ts index 65478eeabd9..2ccadbac7d2 100644 --- a/server/src/__tests__/agent-instance-secrets.test.ts +++ b/server/src/__tests__/agent-instance-secrets.test.ts @@ -4,6 +4,7 @@ import { eq, sql } from "drizzle-orm"; import { agents, companies, companySecrets, companySecretVersions, activityLog, plugins, toolApplications, toolConnections, createDb, type Db } from "@paperclipai/db"; import type { AgentLifecycleRequest } from "@paperclipai/shared"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { pluginAgentRuntimeIdentity } from "../services/plugin-agent-runtime-state.js"; import { agentInstanceSecretService } from "../services/agent-instance-secrets.js"; import { secretService } from "../services/secrets.js"; import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js"; @@ -29,7 +30,7 @@ const support = await getEmbeddedPostgresTestSupport(); companyId = randomUUID(); pluginId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Secret fixture", issuePrefix: "SCR" }); await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "fixture-lifecycle", version: "1.0.0", status: "ready", - manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage"] } as never }); + manifestJson: { agentLifecycle: true, resourceLifecycle: ["agent_runtime"], capabilities: ["agents.lifecycle.manage"] } as never }); }, 90_000); afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); }); async function fixture(ownerPluginId = pluginId, agentId = randomUUID()) { @@ -56,11 +57,13 @@ const support = await getEmbeddedPostgresTestSupport(); expect(JSON.stringify(versions)).not.toContain("e".repeat(64)); expect(JSON.stringify(versions)).not.toContain("runtime.example.test"); expect(await agentInstanceSecretService(createDb(database.connectionString)).read(f.scope)).toEqual({ runtime }); + expect((await pluginAgentRuntimeIdentity(db, pluginId, companyId, f.agent.id))!.instanceSecrets).toEqual({ runtime }); for (const scope of [{ ...f.scope, companyId: randomUUID() }, { ...f.scope, agentId: randomUUID() }, { ...f.scope, pluginId: randomUUID() }]) { expect(await f.service.read(scope)).toEqual({}); } const replacement = runtime.replace("e".repeat(64), "f".repeat(64)); await f.service.apply(f.request, pluginId, { runtime: replacement }); + expect((await pluginAgentRuntimeIdentity(db, pluginId, companyId, f.agent.id))!.instanceSecrets).toEqual({ runtime: replacement }); expect(await f.service.read(f.scope)).toEqual({ runtime: replacement }); expect((await secretService(db).getById(stored.id))!.latestVersion).toBe(2); expect(JSON.stringify(await db.select().from(activityLog).where(eq(activityLog.entityId, f.agent.id)))).not.toContain(replacement); diff --git a/server/src/__tests__/agent-runtime-hooks.test.ts b/server/src/__tests__/agent-runtime-hooks.test.ts index 69fb20f5df8..b36afba82d5 100644 --- a/server/src/__tests__/agent-runtime-hooks.test.ts +++ b/server/src/__tests__/agent-runtime-hooks.test.ts @@ -8,7 +8,8 @@ import { and, eq, sql } from "drizzle-orm"; import { activityLog, agents, companies, createDb, pluginCompanySettings, pluginConfig, plugins, resourceLifecycleEvents, type Db } from "@paperclipai/db"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; -import { pluginAgentRuntimeState } from "../services/plugin-agent-runtime-state.js"; +import * as instructionModule from "../services/agent-instructions.js"; +import { pluginAgentRuntimeState, pluginAgentRuntimeIdentity } from "../services/plugin-agent-runtime-state.js"; import { pluginLifecycleInbox } from "../services/plugin-lifecycle-inbox.js"; import { registerPluginLifecycleDelivery } from "../services/plugin-lifecycle-delivery.js"; import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js"; @@ -173,6 +174,31 @@ describeDatabase("agent runtime synchronization hooks", () => { expect(read.mock.calls.filter(([file]) => String(file).startsWith(`${root}${path.sep}`))).toEqual([]); }); + it("reads fresh runtime identity without building instructions, skills or connection assignments", async () => { + const hydrate = vi.spyOn(instructionModule, "agentInstructionsService").mockImplementation(() => { throw new Error("full runtime hydration must not run"); }); + const first = await pluginAgentRuntimeIdentity(db, pluginId, companyId, agentId); + expect(first).toEqual({ revision: String((await events()).at(-1)!.id), instanceSecrets: {}, + agent: expect.objectContaining({ id: agentId, companyId }) }); + expect(Object.keys(first!.agent).sort()).toEqual(["companyId", "id", "lifecycleState", "lifecycleVersion", "status"]); + await db.update(agents).set({ status: "paused", lifecycleState: "pausing", lifecycleVersion: 2 }).where(eq(agents.id, agentId)); + const paused = await pluginAgentRuntimeIdentity(db, pluginId, companyId, agentId); + expect(paused!.agent).toMatchObject({ status: "paused", lifecycleState: "pausing", lifecycleVersion: 2 }); + expect(paused!.revision).toBe(first!.revision); + await db.update(agents).set({ name: "Updated runtime" }).where(eq(agents.id, agentId)); + expect((await pluginAgentRuntimeIdentity(db, pluginId, companyId, agentId))!.revision).not.toBe(first!.revision); + expect(hydrate).not.toHaveBeenCalled(); + }); + + it("keeps compact runtime reads scoped to the enabled owning plugin", async () => { + expect(await pluginAgentRuntimeIdentity(db, pluginId, randomUUID(), agentId)).toBeNull(); + await expect(pluginAgentRuntimeIdentity(db, randomUUID(), companyId, agentId)).rejects.toMatchObject({ status: 403 }); + await db.insert(pluginCompanySettings).values({ companyId, pluginId, enabled: false }); + await expect(pluginAgentRuntimeIdentity(db, pluginId, companyId, agentId)).rejects.toMatchObject({ status: 403 }); + await db.delete(pluginCompanySettings).where(eq(pluginCompanySettings.pluginId, pluginId)); + await db.update(agents).set({ lifecycleRequiredPluginIds: [] }).where(eq(agents.id, agentId)); + await expect(pluginAgentRuntimeIdentity(db, pluginId, companyId, agentId)).rejects.toMatchObject({ status: 403 }); + }); + it("uses the company reporting graph for runtime health data", async () => { const [manager] = await db.insert(agents).values({ companyId, name: "Manager" }).returning(); await db.update(agents).set({ reportsTo: manager.id }).where(eq(agents.id, agentId)); diff --git a/server/src/__tests__/environment-task-runtime.test.ts b/server/src/__tests__/environment-task-runtime.test.ts index 003dbf85add..3d5b0a7c1df 100644 --- a/server/src/__tests__/environment-task-runtime.test.ts +++ b/server/src/__tests__/environment-task-runtime.test.ts @@ -166,7 +166,7 @@ describe("plugin-provided Runner execution", () => { await expect(executeEnvironmentTask(database(row(), []).db, denied as never, { companyId: "company", leaseId, operation })).rejects.toThrow("project unavailable"); expect(denied.call).not.toHaveBeenCalled(); }); - it("prepares configuration under the same active company, run, and project authority", async () => { + it.each([false, true])("prepares scoped configuration with submit-time auth %s", async atSubmit => { const operation = { kind: "prepare" as const, projectIds: [projectId], requirements: { schema: "paperclip.runner.launch-requirements.v1" as const, identity: { runnerInstanceId: "runner", environmentLeaseId: leaseId, runId: "run", @@ -175,14 +175,18 @@ describe("plugin-provided Runner execution", () => { settings: {}, approvalPolicy: "on-request" as const, readOnly: false, preparedConversation: false, includeCollaborationModeInstructions: true, } }; - const result = { kind: "prepared", taskId: "attempt-1", launch: { configurationId: "configuration-1", + const result = { kind: "prepared", taskId: "attempt-1", ...(atSubmit ? {projectAuthCheckAtSubmit: true} : {}), launch: { configurationId: "configuration-1", requirementsDigest: `sha256:${"a".repeat(64)}`, provider: { kind: "codex" } } }; - const workers = worker(result); - await expect(executeEnvironmentTask(database().db, workers as never, { companyId: "company", leaseId, operation })) + const workers = worker(result), store = database(); + await expect(executeEnvironmentTask(store.db, workers as never, { companyId: "company", leaseId, operation })) .resolves.toEqual(result); expect(workers.call).toHaveBeenCalledWith("original", "environmentTask", expect.objectContaining({ companyId: "company", runId: "run", agentId: "agent", projectIds: [projectId], operation, }), 15_000); + expect(store.sets).toHaveLength(atSubmit ? 2 : 1); + if (atSubmit) expect(new PgDialect().sqlToQuery(store.sets[1].metadata as never).params).toContain(JSON.stringify({ + runnerTaskProjectAuthValidation: { configurationId: "configuration-1", provider: "openai", projectIds: [projectId], atSubmit: true }, + })); const denied = worker(result); const inactive = row(); inactive.lease.status = "released"; for (const value of [null, inactive]) { diff --git a/server/src/services/environment-task-runtime.ts b/server/src/services/environment-task-runtime.ts index bd08207c867..937822cfb95 100644 --- a/server/src/services/environment-task-runtime.ts +++ b/server/src/services/environment-task-runtime.ts @@ -193,6 +193,16 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage } if (freshWorkspace && parsed.kind === "description" && parsed.freshWorkspaceVersion !== 1) throw new Error("Plugin-provided fresh workspace preparation unavailable"); + if (operation.kind === "prepare" && parsed.kind === "prepared" && parsed.projectAuthCheckAtSubmit) { + const harness = operation.requirements.provider === "acpx" ? operation.requirements.settings.acpxAgent : operation.requirements.provider; + if (!harness) throw new Error("Plugin Runner prepared provider unavailable"); + const provider = harness === "codex" ? "openai" : harness === "claude" ? "anthropic" : harness; + const saved = await db.update(environmentLeases).set({ metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || ${JSON.stringify({ + runnerTaskProjectAuthValidation: { configurationId: parsed.launch.configurationId, provider, projectIds, atSubmit: true }, + })}::jsonb` }).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, input.companyId), + eq(environmentLeases.status, "active"), sql`coalesce(${environmentLeases.metadata}->>'runnerTaskRetiring', 'false') != 'true'`)).returning({ id: environmentLeases.id }); + if (saved.length !== 1) throw new Error("Plugin Runner project validation lease expired"); + } if (operation.kind === "check_project_auth" && parsed.kind === "project_auth") { if (parsed.configurationId !== operation.configurationId || parsed.provider !== operation.provider) throw new Error("Plugin Runner project validation identity mismatch"); if (parsed.allowed) { diff --git a/server/src/services/execution-preparation-timing.ts b/server/src/services/execution-preparation-timing.ts index 214838d5d38..863ae7b6e5b 100644 --- a/server/src/services/execution-preparation-timing.ts +++ b/server/src/services/execution-preparation-timing.ts @@ -95,6 +95,7 @@ export type ExecutionPhase = | "execution.runtime" | "execution.workspace_ownership" | "execution.workspace_finalize" | "execution.workspace_restore" | "execution.workspace_commit" | "execution.workspace_ownership_wait" | "execution.realtime" | "execution.usage" | "execution.native_finalize" + | "execution.plugin_prepare" | "execution.plugin_project_auth" | "execution.plugin_submit" | "execution.plugin_connection" | "execution.usage_startup" | "execution.mcp_handoff" | "execution.dispatch_admission" | "execution.budget_reservation" | "completion.run" | "completion.output" | "completion.terminal_write" | "completion.cost" | "completion.liveness" | "completion.summary" | "response.transaction" | "response.transaction.entry" | "response.transaction.commit" | "response.transaction.rollback" | "response.row_lock" | "response.write" 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 d039355884d..4ee51bc072e 100644 --- a/server/src/services/native-runtime/plugin-runner-task.test.ts +++ b/server/src/services/native-runtime/plugin-runner-task.test.ts @@ -64,6 +64,25 @@ describe("plugin native Runner ownership", () => { await registration.release(); await registration.checkpoint("unsettled"); await handle.completion; expect(task.mock.calls.find(([op]) => op.kind === "submit")?.[0]).toMatchObject({ preparedLaunchId: "configuration" }); }); + it("omits duplicate project validation only when the provider enforces it at submission", async () => { + const { task } = provider(); + const perform = task.getMockImplementation()!; + task.mockImplementation(async op => op.kind === "prepare" + ? { ...await perform(op) as object, projectAuthCheckAtSubmit: true } : perform(op)); + const owner = createPluginRunnerTaskOwner({ projectAuthCheckVersion: 1, + task, projectIds: [], harness: "codex", pollMs: 1 }); + await prepare(owner); + expect(task.mock.calls.map(([op]) => op.kind)).toEqual(["prepare"]); + ingress.connect.mockReturnValue({ ready: Promise.resolve(), close: async () => {} }); + const registration = await owner.registration({} as never, "run"); + const handle = owner.launcher(launchSpec()); + registration.activate(); await registration.ready(); + await registration.release(); await registration.checkpoint("unsettled"); await handle.completion; + expect(task.mock.calls.filter(([op]) => op.kind === "submit")).toHaveLength(1); + const unsupported = createPluginRunnerTaskOwner({ task, projectIds: [], harness: "codex" }); + await expect(prepare(unsupported)).rejects.toThrow("validation unavailable"); + }); + it("does not accept a late preparation after task release", async () => { let finish!: () => void; const gate = new Promise(resolve => { finish = resolve; }); diff --git a/server/src/services/native-runtime/plugin-runner-task.ts b/server/src/services/native-runtime/plugin-runner-task.ts index cbe5008cfc1..19aa8f8aea1 100644 --- a/server/src/services/native-runtime/plugin-runner-task.ts +++ b/server/src/services/native-runtime/plugin-runner-task.ts @@ -1,3 +1,4 @@ +import { measureRunPhase } from "../execution-preparation-timing.js"; import { createHash } from "node:crypto"; import { setTimeout as delay } from "node:timers/promises"; import { environmentTaskOperationSchema, environmentTaskResultSchema, type PluginEnvironmentTaskOperation, type PluginEnvironmentTaskResult, type PluginEnvironmentTaskCredential } from "@paperclipai/plugin-sdk"; @@ -44,7 +45,10 @@ export function createPluginRunnerTaskOwner(input: { const running = new Promise((resolve, reject) => { markReady = resolve; failReady = reject; }); void running.catch(() => undefined); const call = async (operation: PluginEnvironmentTaskOperation) => { - const result = environmentTaskResultSchema.parse(await input.task(operation)); + const phase = ({ prepare: "execution.plugin_prepare", check_project_auth: "execution.plugin_project_auth", + submit: "execution.plugin_submit", connection: "execution.plugin_connection" } as const)[operation.kind as "prepare" | "check_project_auth" | "submit" | "connection"]; + const result = environmentTaskResultSchema.parse(await (phase + ? measureRunPhase(phase, () => input.task(operation)) : input.task(operation))); if (result.kind !== (operation.kind === "read_file" ? "file" : operation.kind === "describe" ? "description" : operation.kind === "prepare" ? "prepared" : operation.kind === "check_project_auth" ? "project_auth" : ["read_state", "archive_state"].includes(operation.kind) ? "state" : ["status", "connection", "credentials"].includes(operation.kind) ? operation.kind : "accepted")) { throw new Error("Plugin Runner returned an invalid operation result"); } @@ -58,9 +62,9 @@ export function createPluginRunnerTaskOwner(input: { })); if (released || stopRequested) throw new Error("Plugin Runner preparation cancelled"); if (result.kind !== "prepared") throw new Error("Plugin Runner preparation unavailable"); - { + if (input.projectAuthCheckVersion !== 1) throw new Error("Plugin Runner managed project authentication validation unavailable"); + if (!result.projectAuthCheckAtSubmit) { const provider = input.harness === "codex" ? "openai" : input.harness === "claude" ? "anthropic" : input.harness; - if (input.projectAuthCheckVersion !== 1) throw new Error("Plugin Runner managed project authentication validation unavailable"); const validation = await call({ kind: "check_project_auth", configurationId: result.launch.configurationId, provider, projectIds: [...input.projectIds] }); if (released || stopRequested) throw new Error("Plugin Runner preparation cancelled"); diff --git a/server/src/services/plugin-agent-runtime-state.ts b/server/src/services/plugin-agent-runtime-state.ts index e8e64c54d3f..a1a4ce22ca7 100644 --- a/server/src/services/plugin-agent-runtime-state.ts +++ b/server/src/services/plugin-agent-runtime-state.ts @@ -1,6 +1,6 @@ import { and, desc, eq } from "drizzle-orm"; import { agents, plugins, pluginCompanySettings, resourceLifecycleEvents, type Db } from "@paperclipai/db"; -import type { PluginAgentRuntimeState } from "@paperclipai/plugin-sdk"; +import type { PluginAgentRuntimeState, PluginAgentRuntimeIdentity } from "@paperclipai/plugin-sdk"; import { agentRecordQueries } from "../lib/agent-records.js"; import { conflict, forbidden } from "../errors.js"; import { agentInstructionsService } from "./agent-instructions.js"; @@ -12,7 +12,9 @@ import { skillVersionSelectionMap } from "./runtime-skill-selections.js"; import { instanceSettingsService } from "./instance-settings.js"; import { agentInstanceSecretService } from "./agent-instance-secrets.js"; -async function readState(db: Db, pluginId: string, companyId: string, agentId: string): Promise | null> { +async function readState(db: Db, pluginId: string, companyId: string, agentId: string, identityOnly: true): Promise; +async function readState(db: Db, pluginId: string, companyId: string, agentId: string, identityOnly?: false): Promise | null>; +async function readState(db: Db, pluginId: string, companyId: string, agentId: string, identityOnly = false): Promise | null> { return db.transaction(async tx => { const [agent] = await tx.select().from(agents) .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))).for("share"); @@ -26,6 +28,12 @@ async function readState(db: Db, pluginId: string, companyId: string, agentId: s const [latest] = await tx.select({ id: resourceLifecycleEvents.id }).from(resourceLifecycleEvents) .where(and(eq(resourceLifecycleEvents.companyId, companyId), eq(resourceLifecycleEvents.resourceType, "agent_runtime"), eq(resourceLifecycleEvents.resourceId, agentId))) .orderBy(desc(resourceLifecycleEvents.id)).limit(1); + if (identityOnly) return { + revision: String(latest?.id ?? 0), + agent: { id: agent.id, companyId: agent.companyId, status: agent.status as PluginAgentRuntimeIdentity["agent"]["status"], + lifecycleState: agent.lifecycleState, lifecycleVersion: agent.lifecycleVersion }, + instanceSecrets: await agentInstanceSecretService(tx as unknown as Db).read({ companyId, agentId, pluginId }), + }; const instructions = await agentInstructionsService().getBundleLocation(agent); const records = agentRecordQueries(tx as unknown as Db); const companyAgents = await records.listCompanyAgentRows(companyId); @@ -38,6 +46,10 @@ async function readState(db: Db, pluginId: string, companyId: string, agentId: s } +export async function pluginAgentRuntimeIdentity(db: Db, pluginId: string, companyId: string, agentId: string): Promise { + return readState(db, pluginId, companyId, agentId, true); +} + export async function pluginAgentRuntimeState(db: Db, pluginId: string, companyId: string, agentId: string): Promise { for (let attempt = 0; attempt < 3; attempt++) { const state = await readState(db, pluginId, companyId, agentId); diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index b3d7065ac5b..bab31883a7a 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -1,5 +1,5 @@ import { projectInstanceSecretService } from "./project-instance-secrets.js"; -import { pluginAgentRuntimeState } from "./plugin-agent-runtime-state.js"; +import { pluginAgentRuntimeState, pluginAgentRuntimeIdentity } from "./plugin-agent-runtime-state.js"; import { createAgentLifecycle } from "./agent-lifecycle.js"; import type { Db } from "@paperclipai/db"; import { @@ -3002,6 +3002,11 @@ export function buildHostServices( }, agents: { + async getRuntimeIdentity(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return pluginAgentRuntimeIdentity(db, pluginId, companyId, params.agentId); + }, async getRuntime(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId);