Reduce repeated work during plugin Runner admission (#17377)

## Thinking Path

> - Paperclip manages agents and their execution authority.
> - A plugin can host Runner tasks on a persistent execution host.
> - Admission checks must observe current lifecycle state and
credentials.
> - The current runtime reader also resolves files, skills, and
connections for each check.
> - Some providers also repeat the same project authentication check
during submit.
> - This pull request adds a compact fresh reader and an explicit
submit-time validation contract.
> - Providers can remove repeated work while Core keeps lease and scope
checks.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Plugin Runner startup before task submission.

**Current behavior**

One measured startup made 30 full runtime reads during bootstrap. These
reads took 5.759 seconds. Each guard can resolve instructions, skills,
and connection assignments even when it only needs current identity and
lifecycle state. Providers that already enforce project authentication
in submit must also perform a separate Core preflight.

**Proposed behavior**

Use a lifecycle-owned runtime identity reader for guards. Keep the full
runtime reader for synchronization inputs. A trusted provider can
declare `projectAuthCheckAtSubmit: true` in its prepared result. Core
pins the exact configuration, provider, and project set on the lease.
The provider must reject authentication conflicts before credential
installation or task start. Providers that omit the flag retain the
existing preflight.

**Reason and benefit**

Estimated savings with a paired consumer: 4–5 seconds from compact
runtime checks, plus roughly 0.5–0.9 seconds from removing the duplicate
authentication operation. These estimates overlap and are not a measured
deployed result. Refs #17367 for the separate run-log HEAD optimization;
this PR does not duplicate it.

**Breaking changes**

No migration. The SDK adds a host method and an optional prepared-result
field. Consumers that use them require this Core host version. Older
providers retain their existing flow.

## What Changed

- Add `agents.getRuntimeIdentity` across SDK, RPC, host service, and
test fixtures.
- Reuse the existing company, plugin ownership, readiness, and
capability checks. Read lifecycle state and protected instance secrets
fresh on every call.
- Skip instruction, organization, skill, and connection hydration in the
compact reader.
- Allow explicit submit-time authentication enforcement, with prepared
scope pinned and checked again at dispatch.
- Emit fixed preparation, project-auth, submit, and connection timing
phases.

## Verification

- 72 targeted SDK capability, plugin task-owner, and environment-task
mediator tests passed.
- 33 real embedded-Postgres lifecycle and secret tests passed, including
pause, content revision, company isolation, plugin disablement, and
secret rotation.
- SDK and server TypeScript checks passed. Local Runner/eval TypeScript
outputs were built to satisfy server type resolution.
- Local Postgres required missing library aliases in ignored
dependencies; no product source was changed for that repair.
- Full repository suites are delegated to GitHub CI. No live rollout or
speedup claim.

## Risks

The submit-time flag delegates enforcement to a trusted provider. It is
not proof that prepare already checked authentication. The provider must
check before VM credential installation or task admission. Core still
checks exact prepared scope, active lease, run identity, credential
type, and dispatch fencing. Existing providers keep the separate check.
Roll back a paired consumer before rolling back this host.

## Model Used

OpenAI GPT-6 (Codex), with code execution and repository tools. Exact
deployment build and context-window size are not exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-10-23 14:54:25 -07:00
1 parent 3c4050e3ad
commit a3e8fa568a
20 files changed
+152 -23

No files matched your search

+9
View File
@@ -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
+11
View File
@@ -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.
+6 -4
View File
@@ -69,10 +69,12 @@ skills=/home/agent/.agents/skills/<name>/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.
@@ -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,
@@ -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<WorkerToHostMethods["agents.list"][1]>;
getRuntimeIdentity?(params: WorkerToHostMethods["agents.getRuntimeIdentity"][0]): Promise<WorkerToHostMethods["agents.getRuntimeIdentity"][1]>;
getRuntime?(params: WorkerToHostMethods["agents.getRuntime"][0]): Promise<WorkerToHostMethods["agents.getRuntime"][1]>;
get(params: WorkerToHostMethods["agents.get"][0]): Promise<WorkerToHostMethods["agents.get"][1]>;
pause(params: WorkerToHostMethods["agents.pause"][0]): Promise<WorkerToHostMethods["agents.pause"][1]>;
@@ -495,6 +496,7 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
"agents.list": "agents.read",
"agents.get": "agents.read",
"agents.getRuntime": "agents.lifecycle.manage",
"agents.getRuntimeIdentity": "agents.lifecycle.manage",
"agents.pause": "agents.pause",
"agents.resume": "agents.resume",
"agents.invoke": "agents.invoke",
@@ -987,6 +989,10 @@ export function createHostClientHandlers(
"agents.list": gated("agents.list", async (params) => {
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);
+1
View File
@@ -273,6 +273,7 @@ export type {
PluginIssueSummariesClient,
PluginAgentsClient,
PluginAgentRuntimeState,
PluginAgentRuntimeIdentity,
PluginAgentConnectionAssignments,
PluginAgentRuntimeSkill,
PluginAccessClient,
+5 -1
View File
@@ -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,
+4
View File
@@ -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");
+5
View File
@@ -1725,7 +1725,12 @@ export interface PluginAgentRuntimeState {
instanceSecrets: Record<string, string>;
}
export type PluginAgentRuntimeIdentity = Pick<PluginAgentRuntimeState, "revision" | "instanceSecrets"> & {
agent: Pick<Agent, "id" | "companyId" | "status" | "lifecycleState" | "lifecycleVersion">;
};
export interface PluginAgentsClient {
getRuntimeIdentity(agentId: string, companyId: string): Promise<PluginAgentRuntimeIdentity | null>;
getRuntime(agentId: string, companyId: string): Promise<PluginAgentRuntimeState | null>;
list(input: { companyId: string; status?: Agent["status"]; limit?: number; offset?: number }): Promise<Agent[]>;
get(agentId: string, companyId: string): Promise<Agent | null>;
@@ -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 });
},
@@ -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();
});
});
@@ -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);
@@ -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));
@@ -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]) {
@@ -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) {
@@ -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"
@@ -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<void>(resolve => { finish = resolve; });
@@ -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<void>((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");
@@ -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<Omit<PluginAgentRuntimeState, "skills" | "connectionAssignments"> | null> {
async function readState(db: Db, pluginId: string, companyId: string, agentId: string, identityOnly: true): Promise<PluginAgentRuntimeIdentity | null>;
async function readState(db: Db, pluginId: string, companyId: string, agentId: string, identityOnly?: false): Promise<Omit<PluginAgentRuntimeState, "skills" | "connectionAssignments"> | null>;
async function readState(db: Db, pluginId: string, companyId: string, agentId: string, identityOnly = false): Promise<PluginAgentRuntimeIdentity | Omit<PluginAgentRuntimeState, "skills" | "connectionAssignments"> | 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<PluginAgentRuntimeIdentity | null> {
return readState(db, pluginId, companyId, agentId, true);
}
export async function pluginAgentRuntimeState(db: Db, pluginId: string, companyId: string, agentId: string): Promise<PluginAgentRuntimeState | null> {
for (let attempt = 0; attempt < 3; attempt++) {
const state = await readState(db, pluginId, companyId, agentId);
+6 -1
View File
@@ -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);