mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
feat: add provider-neutral runner events
This commit is contained in:
1 parent
47b3429a7f
commit
39c4fbe421
87 files changed
+51704
-282
No files matched your search
@@ -0,0 +1,96 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { findServerAdapter } from "../adapters/registry.js";
|
||||
|
||||
const CONFIG = {
|
||||
provider: "claude_managed",
|
||||
managedProfileId: "qualified",
|
||||
anthropicAgentId: "agent_test",
|
||||
agentVersion: "3",
|
||||
anthropicEnvironmentId: "env_test",
|
||||
model: "claude-sonnet-5",
|
||||
maxSessionListCostUsd: 1,
|
||||
managedAgentsRetentionAcknowledged: true,
|
||||
env: { ANTHROPIC_API_KEY: "anthropic-config-secret" },
|
||||
};
|
||||
|
||||
describe("paperclip_runner Claude Agent environment probe", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("ANTHROPIC_API_KEY", "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("uses the resolved adapter secret and performs only read-only qualified-resource probes", async () => {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, init: init ?? {} });
|
||||
if (url.includes("/versions")) {
|
||||
return new Response(JSON.stringify({ data: [{ version: 3 }] }), { status: 200 });
|
||||
}
|
||||
if (url.includes("/environments/")) {
|
||||
return new Response(JSON.stringify({
|
||||
id: "env_test",
|
||||
config: {
|
||||
type: "cloud",
|
||||
networking: {
|
||||
type: "limited",
|
||||
allow_mcp_servers: false,
|
||||
allow_package_managers: false,
|
||||
allowed_hosts: [],
|
||||
},
|
||||
packages: { type: "packages", apt: [], cargo: [], gem: [], go: [], npm: [], pip: [] },
|
||||
},
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({ id: "agent_test" }), { status: 200 });
|
||||
}));
|
||||
|
||||
const result = await findServerAdapter("paperclip_runner")!.testEnvironment!({
|
||||
companyId: "company-1",
|
||||
adapterType: "paperclip_runner",
|
||||
config: CONFIG,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("warn");
|
||||
expect(result.checks.some((check) => check.code === "claude_managed_profile_qualified")).toBe(true);
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls.every((call) => (call.init.method ?? "GET") === "GET")).toBe(true);
|
||||
for (const call of calls) {
|
||||
expect(call.init.headers).toMatchObject({
|
||||
"x-api-key": "anthropic-config-secret",
|
||||
"anthropic-beta": "managed-agents-2026-04-01",
|
||||
});
|
||||
}
|
||||
expect(JSON.stringify(result)).not.toContain("anthropic-config-secret");
|
||||
});
|
||||
|
||||
it("rejects unsafe Environment drift", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/versions")) return new Response(JSON.stringify({ data: [{ version: 3 }] }));
|
||||
if (url.includes("/environments/")) {
|
||||
return new Response(JSON.stringify({
|
||||
id: "env_test",
|
||||
config: {
|
||||
type: "cloud",
|
||||
networking: { type: "unrestricted" },
|
||||
packages: { type: "packages" },
|
||||
},
|
||||
}));
|
||||
}
|
||||
return new Response(JSON.stringify({ id: "agent_test" }));
|
||||
}));
|
||||
const result = await findServerAdapter("paperclip_runner")!.testEnvironment!({
|
||||
companyId: "company-1",
|
||||
adapterType: "paperclip_runner",
|
||||
config: CONFIG,
|
||||
});
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.checks.some((check) => check.code === "claude_managed_probe_failed")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -188,6 +188,155 @@ async function testPaperclipRunnerOpenCodeEnvironment(
|
||||
}
|
||||
}
|
||||
|
||||
async function testPaperclipRunnerClaudeManagedEnvironment(
|
||||
context: Parameters<typeof codexTestEnvironment>[0],
|
||||
) {
|
||||
const testedAt = new Date().toISOString();
|
||||
const required = [
|
||||
["managedProfileId", "Managed profile ID"],
|
||||
["anthropicAgentId", "Anthropic Agent ID"],
|
||||
["agentVersion", "Agent version"],
|
||||
["anthropicEnvironmentId", "Anthropic Environment ID"],
|
||||
["model", "Claude model"],
|
||||
] as const;
|
||||
const missing = required.filter(([key]) =>
|
||||
typeof context.config[key] !== "string" || String(context.config[key]).trim().length === 0
|
||||
);
|
||||
if (missing.length > 0 || context.config.managedAgentsRetentionAcknowledged !== true) {
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt,
|
||||
checks: [{
|
||||
code: "claude_managed_profile_incomplete",
|
||||
level: "error" as const,
|
||||
message: missing.length > 0
|
||||
? `Claude Agent profile is missing: ${missing.map(([, label]) => label).join(", ")}.`
|
||||
: "Claude Agent requires an administrator acknowledgement of beta status and provider retention.",
|
||||
}],
|
||||
};
|
||||
}
|
||||
const cap = Number(context.config.maxSessionListCostUsd);
|
||||
if (!Number.isFinite(cap) || cap <= 0) {
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt,
|
||||
checks: [{ code: "claude_managed_spend_cap_invalid", level: "error" as const, message: "Claude Agent requires a positive session spend ceiling." }],
|
||||
};
|
||||
}
|
||||
const configuredEnv = context.config.env && typeof context.config.env === "object"
|
||||
? context.config.env as Record<string, unknown>
|
||||
: {};
|
||||
const key = typeof configuredEnv.ANTHROPIC_API_KEY === "string"
|
||||
? configuredEnv.ANTHROPIC_API_KEY.trim()
|
||||
: process.env.ANTHROPIC_API_KEY?.trim();
|
||||
if (!key) {
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt,
|
||||
checks: [{
|
||||
code: "claude_managed_auth_missing",
|
||||
level: "error" as const,
|
||||
message: "ANTHROPIC_API_KEY is not available to the Paperclip server. Bind it through the company secret-reference environment configuration.",
|
||||
}],
|
||||
};
|
||||
}
|
||||
const headers = {
|
||||
"x-api-key": key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-beta": "managed-agents-2026-04-01",
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const [agentResponse, environmentResponse, versionsResponse] = await Promise.all([
|
||||
fetch(`https://api.anthropic.com/v1/agents/${encodeURIComponent(String(context.config.anthropicAgentId))}?beta=true`, { headers, signal: controller.signal }),
|
||||
fetch(`https://api.anthropic.com/v1/environments/${encodeURIComponent(String(context.config.anthropicEnvironmentId))}?beta=true`, { headers, signal: controller.signal }),
|
||||
fetch(`https://api.anthropic.com/v1/agents/${encodeURIComponent(String(context.config.anthropicAgentId))}/versions?beta=true`, { headers, signal: controller.signal }),
|
||||
]);
|
||||
if (!agentResponse.ok || !environmentResponse.ok || !versionsResponse.ok) {
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt,
|
||||
checks: [{
|
||||
code: "claude_managed_resource_probe_failed",
|
||||
level: "error" as const,
|
||||
message: `Anthropic resource probe failed (Agent HTTP ${agentResponse.status}; Environment HTTP ${environmentResponse.status}; versions HTTP ${versionsResponse.status}).`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
const agent = await agentResponse.json() as Record<string, unknown>;
|
||||
const environment = await environmentResponse.json() as Record<string, unknown>;
|
||||
const versions = await versionsResponse.json() as Record<string, unknown>;
|
||||
if (agent.id !== context.config.anthropicAgentId || environment.id !== context.config.anthropicEnvironmentId) {
|
||||
throw new Error("Anthropic returned resource identities that do not match the configured profile");
|
||||
}
|
||||
const versionRows = Array.isArray(versions.data) ? versions.data : [];
|
||||
const pinnedVersion = String(context.config.agentVersion);
|
||||
if (!versionRows.some((entry) => {
|
||||
if (!entry || typeof entry !== "object") return false;
|
||||
const value = (entry as Record<string, unknown>).version;
|
||||
return String(value) === pinnedVersion;
|
||||
})) {
|
||||
throw new Error("The configured Anthropic Agent version does not exist");
|
||||
}
|
||||
const environmentConfig = environment.config && typeof environment.config === "object"
|
||||
? environment.config as Record<string, unknown>
|
||||
: {};
|
||||
const networking = environmentConfig.networking && typeof environmentConfig.networking === "object"
|
||||
? environmentConfig.networking as Record<string, unknown>
|
||||
: {};
|
||||
const packages = environmentConfig.packages && typeof environmentConfig.packages === "object"
|
||||
? environmentConfig.packages as Record<string, unknown>
|
||||
: {};
|
||||
const packageEntries = Object.entries(packages)
|
||||
.filter(([key]) => key !== "type")
|
||||
.flatMap(([, value]) => Array.isArray(value) ? value : []);
|
||||
if (
|
||||
environmentConfig.type !== "cloud"
|
||||
|| networking.type !== "limited"
|
||||
|| networking.allow_mcp_servers === true
|
||||
|| networking.allow_package_managers === true
|
||||
|| (Array.isArray(networking.allowed_hosts) && networking.allowed_hosts.length > 0)
|
||||
|| packageEntries.length > 0
|
||||
) {
|
||||
throw new Error("Anthropic Environment drifted from Paperclip's no-network, no-package profile");
|
||||
}
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "warn" as const,
|
||||
testedAt,
|
||||
checks: [{
|
||||
code: "claude_managed_profile_qualified",
|
||||
level: "info" as const,
|
||||
message: `Verified Claude Agent ${String(context.config.anthropicAgentId)} and Environment ${String(context.config.anthropicEnvironmentId)} without creating a session.`,
|
||||
}, {
|
||||
code: "claude_managed_retention_notice",
|
||||
level: "warn" as const,
|
||||
message: "Managed Agents is a stateful beta service and is not eligible for ZDR or HIPAA modes.",
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt,
|
||||
checks: [{
|
||||
code: "claude_managed_probe_failed",
|
||||
level: "error" as const,
|
||||
message: error instanceof Error && error.name === "AbortError"
|
||||
? "Anthropic resource probe timed out."
|
||||
: "Anthropic resource probe failed before the profile could be verified.",
|
||||
}],
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function compareSemver(left: string, right: string): number {
|
||||
const a = left.split(".").map(Number);
|
||||
const b = right.split(".").map(Number);
|
||||
@@ -409,8 +558,12 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
};
|
||||
},
|
||||
async testEnvironment(context) {
|
||||
const provider = context.config.provider === "opencode" ? "opencode" : "codex";
|
||||
const result = provider === "opencode"
|
||||
const provider = context.config.provider === "opencode"
|
||||
? "opencode"
|
||||
: context.config.provider === "claude_managed" ? "claude_managed" : "codex";
|
||||
const result = provider === "claude_managed"
|
||||
? await testPaperclipRunnerClaudeManagedEnvironment(context)
|
||||
: provider === "opencode"
|
||||
? await testPaperclipRunnerOpenCodeEnvironment(context)
|
||||
: await codexTestEnvironment(context);
|
||||
return { ...result, adapterType: "paperclip_runner" };
|
||||
@@ -421,6 +574,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
models: [
|
||||
...codexModels,
|
||||
{ id: "openrouter/deepseek/deepseek-v4-flash-0731", label: "OpenRouter · DeepSeek V4 Flash 0731" },
|
||||
{ id: "claude-sonnet-5", label: "Claude Sonnet 5" },
|
||||
],
|
||||
modelProfiles: codexModelProfiles,
|
||||
listModels: listCodexModels,
|
||||
@@ -429,18 +583,24 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
supportsInstructionsBundle: true,
|
||||
instructionsPathKey: "instructionsFilePath",
|
||||
requiresMaterializedRuntimeSkills: false,
|
||||
getRuntimeCommandSpec: (config) => config.provider === "opencode"
|
||||
getRuntimeCommandSpec: (config) => config.provider === "claude_managed"
|
||||
? { command: "paperclip-runnerd", detectCommand: null, installCommand: null }
|
||||
: config.provider === "opencode"
|
||||
? buildNpmRuntimeCommandSpec(config, "opencode", "opencode-ai@1.18.17")
|
||||
: buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"),
|
||||
agentConfigurationDoc: `# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex or OpenCode through the native Paperclip runner and authenticated PRP control-plane connection. OpenCode requires a provider/model identifier and runtime OPENROUTER_API_KEY.\n`,
|
||||
agentConfigurationDoc: `# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, or a remote Claude Agent through the native Paperclip runner and authenticated PRP control-plane connection. Claude Agent credentials remain in runnerd; Anthropic never receives a Paperclip credential or endpoint.\n`,
|
||||
getConfigSchema: () => ({
|
||||
fields: [{
|
||||
key: "provider",
|
||||
label: "Provider",
|
||||
type: "select",
|
||||
default: "codex",
|
||||
options: [{ value: "codex", label: "Codex" }, { value: "opencode", label: "OpenCode 1.18.17" }],
|
||||
hint: "Select the native provider. OpenCode runs through its authenticated loopback server.",
|
||||
options: [
|
||||
{ value: "codex", label: "Codex" },
|
||||
{ value: "opencode", label: "OpenCode 1.18.17" },
|
||||
{ value: "claude_managed", label: "Claude Agent" },
|
||||
],
|
||||
hint: "Select a local harness or an Anthropic cloud-managed Claude Agent.",
|
||||
}, {
|
||||
key: "lifecycleMode",
|
||||
label: "Runner lifecycle",
|
||||
@@ -459,17 +619,36 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
hint: "Warm sessions suspend resumably after this much inactivity.",
|
||||
}, {
|
||||
key: "model",
|
||||
label: "OpenCode model",
|
||||
label: "Model",
|
||||
type: "text",
|
||||
default: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
placeholder: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
hint: "Required when provider is OpenCode; use provider/model form.",
|
||||
hint: "OpenCode uses provider/model form. Claude Agent defaults to claude-sonnet-5.",
|
||||
}, {
|
||||
key: "command",
|
||||
label: "OpenCode command",
|
||||
type: "text",
|
||||
default: "opencode",
|
||||
hint: "OpenCode executable. Version 1.18.17 is qualified.",
|
||||
}, {
|
||||
key: "managedProfileId", label: "Managed Agent profile", type: "text", required: true,
|
||||
hint: "Company-scoped profile identifier. Required for Claude Agent.",
|
||||
meta: { provider: "claude_managed" },
|
||||
}, {
|
||||
key: "anthropicAgentId", label: "Anthropic Agent ID", type: "text", required: true,
|
||||
meta: { provider: "claude_managed" },
|
||||
}, {
|
||||
key: "agentVersion", label: "Pinned Agent version", type: "text", required: true,
|
||||
meta: { provider: "claude_managed" },
|
||||
}, {
|
||||
key: "anthropicEnvironmentId", label: "Anthropic Environment ID", type: "text", required: true,
|
||||
meta: { provider: "claude_managed" },
|
||||
}, {
|
||||
key: "maxSessionListCostUsd", label: "Session spend ceiling (USD)", type: "number", default: 1,
|
||||
hint: "Required hard ceiling for a managed session.", meta: { provider: "claude_managed" },
|
||||
}, {
|
||||
key: "managedAgentsRetentionAcknowledged", label: "Acknowledge beta retention", type: "toggle", default: false,
|
||||
hint: "Managed Agents is stateful and is not eligible for ZDR or HIPAA modes.", meta: { provider: "claude_managed" },
|
||||
}],
|
||||
}),
|
||||
loginCapability: codexLoginCapability,
|
||||
|
||||
@@ -82,6 +82,7 @@ import { accessRoutes } from "./routes/access.js";
|
||||
import { pluginRoutes } from "./routes/plugins.js";
|
||||
import { mcpGatewayProtocolRoutes, toolGatewayRoutes } from "./routes/tool-gateway.js";
|
||||
import { adapterRoutes } from "./routes/adapters.js";
|
||||
import { managedAgentProfileRoutes } from "./routes/managed-agent-profiles.js";
|
||||
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
|
||||
import { readBrandedStaticIndexHtml } from "./static-index-html.js";
|
||||
import { applyUiBranding } from "./ui-branding.js";
|
||||
@@ -522,6 +523,7 @@ export async function createApp(
|
||||
api.use(boardChatRoutes(db, { deploymentMode: opts.deploymentMode }));
|
||||
api.use(approvalRoutes(db, { pluginWorkerManager: workerManager }));
|
||||
api.use(secretRoutes(db));
|
||||
api.use(managedAgentProfileRoutes(db));
|
||||
const trustedLocalStdioRuntimeHost =
|
||||
process.env.PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST
|
||||
?? process.env.PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST
|
||||
|
||||
@@ -39,3 +39,4 @@ export { llmRoutes } from "./llms.js";
|
||||
export { accessRoutes } from "./access.js";
|
||||
export { instanceSettingsRoutes } from "./instance-settings.js";
|
||||
export { instanceDatabaseBackupRoutes } from "./instance-database-backups.js";
|
||||
export { managedAgentProfileRoutes } from "./managed-agent-profiles.js";
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import { managedAgentProfileService, type ManagedAgentProfileInput } from "../services/managed-agent-profiles.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { logActivity } from "../services/activity-log.js";
|
||||
import { unprocessable } from "../errors.js";
|
||||
|
||||
function profileInput(value: unknown): ManagedAgentProfileInput {
|
||||
if (!value || typeof value !== "object") throw unprocessable("Managed Agent profile body is required");
|
||||
const body = value as Record<string, unknown>;
|
||||
return {
|
||||
profileKey: String(body.profileKey ?? ""),
|
||||
displayName: String(body.displayName ?? ""),
|
||||
anthropicAgentId: String(body.anthropicAgentId ?? ""),
|
||||
agentVersion: String(body.agentVersion ?? ""),
|
||||
environmentId: String(body.environmentId ?? ""),
|
||||
defaultModel: String(body.defaultModel ?? "claude-sonnet-5"),
|
||||
defaultMaxListCostUsd: Number(body.defaultMaxListCostUsd ?? 1),
|
||||
apiKeySecretId: String(body.apiKeySecretId ?? ""),
|
||||
enabled: body.enabled === true,
|
||||
retentionAcknowledged: body.retentionAcknowledged === true,
|
||||
qualification: body.qualification && typeof body.qualification === "object"
|
||||
? body.qualification as Record<string, unknown>
|
||||
: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function managedAgentProfileRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const profiles = managedAgentProfileService(db);
|
||||
|
||||
router.get("/companies/:companyId/managed-agent-profiles", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
res.json(await profiles.list(companyId));
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/managed-agent-profiles", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const profile = await profiles.upsert(companyId, profileInput(req.body));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "managed_agent_profile.qualified",
|
||||
entityType: "managed_agent_profile",
|
||||
entityId: profile.id,
|
||||
details: {
|
||||
profileKey: profile.profileKey,
|
||||
service: profile.service,
|
||||
agentVersion: profile.agentVersion,
|
||||
environmentId: profile.environmentId,
|
||||
model: profile.defaultModel,
|
||||
enabled: profile.enabled,
|
||||
retentionAcknowledged: profile.retentionAcknowledged,
|
||||
},
|
||||
});
|
||||
res.status(201).json(profile);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -85,6 +85,7 @@ export { scrubGitCredentialText };
|
||||
import { publishLiveEvent } from "./live-events.js";
|
||||
import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
|
||||
import {
|
||||
buildNativeProviderEnvironment,
|
||||
buildNativeExecutionInput,
|
||||
cancelNativeSession,
|
||||
dispatchNativeSessionResumptions,
|
||||
@@ -303,6 +304,7 @@ import { evaluateCodexCredentialReadiness } from "@paperclipai/adapter-codex-loc
|
||||
import { environmentService } from "./environments.js";
|
||||
import { parseExecutionPolicyBootstrapEnv } from "./execution-policy-bootstrap.js";
|
||||
import { environmentRuntimeService } from "./environment-runtime.js";
|
||||
import { managedAgentProfileService } from "./managed-agent-profiles.js";
|
||||
import { skillVersionSelectionMap } from "./runtime-skill-selections.js";
|
||||
import { environmentRunOrchestrator } from "./environment-run-orchestrator.js";
|
||||
import { isUnsafeSessionWorkspaceCwd } from "./session-workspace-cwd.js";
|
||||
@@ -14062,6 +14064,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
|
||||
activeRunExecutions.add(run.id);
|
||||
let runScratch: HeartbeatRunScratch | null = null;
|
||||
let nativeSessionResumeScheduled = false;
|
||||
|
||||
try {
|
||||
const agent = await getAgent(run.agentId);
|
||||
@@ -15923,6 +15926,32 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
agentId: agent.id,
|
||||
interactionIds: interactionId ? [interactionId] : [],
|
||||
});
|
||||
const managedProfile = nativeRuntimeResolution.profile.backend === "claude_managed_agents_api"
|
||||
? await managedAgentProfileService(db).requireQualified(
|
||||
agent.companyId,
|
||||
readNonEmptyString(parseObject(runtimeConfig).managedProfileId) ?? "",
|
||||
)
|
||||
: null;
|
||||
if (managedProfile) {
|
||||
const rawApiKeyBinding = parseObject(parseObject(agent.adapterConfig).env).ANTHROPIC_API_KEY;
|
||||
const boundSecretId = typeof rawApiKeyBinding === "object" && rawApiKeyBinding !== null
|
||||
? readNonEmptyString((rawApiKeyBinding as Record<string, unknown>).secretId)
|
||||
: null;
|
||||
if (boundSecretId !== managedProfile.apiKeySecretId) {
|
||||
throw new ConfigurationIncompleteFailure(
|
||||
"configuration incomplete: Claude Agent profile API key is not bound at env.ANTHROPIC_API_KEY",
|
||||
{
|
||||
configurationIncomplete: {
|
||||
reason: "managed_agent_profile_secret_binding_mismatch",
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
profileId: managedProfile.id,
|
||||
requiredEnvKeys: ["ANTHROPIC_API_KEY"],
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
nativeExecution = buildNativeExecutionInput({
|
||||
companyId: agent.companyId,
|
||||
runId: run.id,
|
||||
@@ -15941,11 +15970,28 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
branchName: executionWorkspace.branchName,
|
||||
},
|
||||
normalizedSessionId: nativeSessionId,
|
||||
provider: nativeRuntimeResolution.profile.backend === "opencode_server" ? "opencode" : "codex",
|
||||
model: typeof parseObject(agent.adapterConfig).model === "string"
|
||||
? String(parseObject(agent.adapterConfig).model)
|
||||
: null,
|
||||
lifecyclePolicy: parseObject(agent.adapterConfig).lifecycleMode === "warm"
|
||||
provider: nativeRuntimeResolution.profile.backend === "opencode_server"
|
||||
? "opencode"
|
||||
: nativeRuntimeResolution.profile.backend === "claude_managed_agents_api"
|
||||
? "claude_managed"
|
||||
: "codex",
|
||||
model: typeof parseObject(runtimeConfig).model === "string"
|
||||
? String(parseObject(runtimeConfig).model)
|
||||
: managedProfile?.defaultModel ?? null,
|
||||
...(nativeRuntimeResolution.profile.backend === "claude_managed_agents_api" ? {
|
||||
managedProfile: {
|
||||
profileId: managedProfile!.id,
|
||||
anthropicAgentId: managedProfile!.anthropicAgentId,
|
||||
agentVersion: managedProfile!.agentVersion,
|
||||
environmentId: managedProfile!.environmentId,
|
||||
betaVersion: "managed-agents-2026-04-01" as const,
|
||||
},
|
||||
maxSessionListCostUsd: Number(
|
||||
parseObject(runtimeConfig).maxSessionListCostUsd
|
||||
?? managedProfile!.defaultMaxListCostCents / 100,
|
||||
),
|
||||
} : {}),
|
||||
lifecyclePolicy: parseObject(runtimeConfig).lifecycleMode === "warm"
|
||||
? {
|
||||
mode: "warm",
|
||||
idleTimeoutMs: Number.isSafeInteger(parseObject(agent.adapterConfig).idleTimeoutMs)
|
||||
@@ -16223,6 +16269,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
backend: options.nativeSessionBackendFactory?.(nativeExecution),
|
||||
useRunnerd: agent.adapterType === "paperclip_runner",
|
||||
onLog,
|
||||
// Bootstrap the provider with executable/home discovery while
|
||||
// keeping the agent's configured provider values authoritative.
|
||||
runnerEnvironment: buildNativeProviderEnvironment(adapterEnv),
|
||||
onSpawn: async (meta) => {
|
||||
await persistRunProcessMetadata(run.id, meta);
|
||||
},
|
||||
@@ -16306,7 +16355,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]?.phase === "retryable_failure" && rows[0]?.resultId === null)
|
||||
: false;
|
||||
if (nativeResumeScheduled) throw new NativeSessionResumeScheduledError(adapterErr);
|
||||
if (nativeResumeScheduled) {
|
||||
nativeSessionResumeScheduled = true;
|
||||
throw new NativeSessionResumeScheduledError(adapterErr);
|
||||
}
|
||||
// Adapter (or its restore finally) threw — or the finalize record
|
||||
// write itself threw. Either way the workspace may be in a partial
|
||||
// state. Best-effort record finalize=failed so the dependent readiness
|
||||
@@ -17058,7 +17110,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
// terminal". When the teardown reaches this point with the run still
|
||||
// running or queued, force a terminal status before the lease is
|
||||
// released, so the UI never shows a finished task as "Live".
|
||||
if (latestRun) {
|
||||
if (latestRun && !nativeSessionResumeScheduled) {
|
||||
latestRun = await terminalizeRunOnLeaseRelease(latestRun).catch((terminalizeErr) => {
|
||||
logger.error(
|
||||
{ err: terminalizeErr, runId: run.id },
|
||||
@@ -17067,14 +17119,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
return latestRun;
|
||||
});
|
||||
}
|
||||
await releaseEnvironmentLeasesForRun({
|
||||
runId: run.id,
|
||||
companyId: run.companyId,
|
||||
agentId: run.agentId,
|
||||
status: latestRun?.status,
|
||||
failureReason: latestRun?.error ?? undefined,
|
||||
});
|
||||
await releaseRuntimeServicesForRun(run.id).catch(() => undefined);
|
||||
if (!nativeSessionResumeScheduled) {
|
||||
await releaseEnvironmentLeasesForRun({
|
||||
runId: run.id,
|
||||
companyId: run.companyId,
|
||||
agentId: run.agentId,
|
||||
status: latestRun?.status,
|
||||
failureReason: latestRun?.error ?? undefined,
|
||||
});
|
||||
await releaseRuntimeServicesForRun(run.id).catch(() => undefined);
|
||||
}
|
||||
if (runScratch && latestRun && isHeartbeatRunTerminalStatus(latestRun.status)) {
|
||||
const scratchForCleanup = runScratch;
|
||||
let scratchCleanup: Awaited<ReturnType<typeof cleanupHeartbeatRunScratch>> | null = null;
|
||||
@@ -17128,7 +17182,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
}
|
||||
}
|
||||
activeRunExecutions.delete(run.id);
|
||||
await startNextQueuedRunForAgent(run.agentId);
|
||||
if (!nativeSessionResumeScheduled) {
|
||||
await startNextQueuedRunForAgent(run.agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -192,3 +192,8 @@ export {
|
||||
} from "./codex-auth-reconciliation.js";
|
||||
export { reconcilePersistedRuntimeServicesOnStartup, restartDesiredRuntimeServicesOnStartup } from "./workspace-runtime.js";
|
||||
export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js";
|
||||
export {
|
||||
managedAgentProfileService,
|
||||
CLAUDE_MANAGED_BETA_VERSION,
|
||||
type ManagedAgentProfileInput,
|
||||
} from "./managed-agent-profiles.js";
|
||||
@@ -0,0 +1,109 @@
|
||||
import { and, asc, eq, ne } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companySecrets, managedAgentProfiles } from "@paperclipai/db";
|
||||
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
|
||||
export const CLAUDE_MANAGED_BETA_VERSION = "managed-agents-2026-04-01" as const;
|
||||
|
||||
export interface ManagedAgentProfileInput {
|
||||
profileKey: string;
|
||||
displayName: string;
|
||||
anthropicAgentId: string;
|
||||
agentVersion: string;
|
||||
environmentId: string;
|
||||
defaultModel: string;
|
||||
defaultMaxListCostUsd: number;
|
||||
apiKeySecretId: string;
|
||||
enabled: boolean;
|
||||
retentionAcknowledged: boolean;
|
||||
qualification?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function required(value: string, label: string): string {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) throw unprocessable(`${label} is required`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function toCents(value: number): number {
|
||||
const cents = Math.round(value * 100);
|
||||
if (!Number.isSafeInteger(cents) || cents <= 0) {
|
||||
throw unprocessable("Managed Agent default spend ceiling must be positive");
|
||||
}
|
||||
return cents;
|
||||
}
|
||||
|
||||
export function managedAgentProfileService(db: Db) {
|
||||
async function list(companyId: string) {
|
||||
return db.select().from(managedAgentProfiles)
|
||||
.where(eq(managedAgentProfiles.companyId, companyId))
|
||||
.orderBy(asc(managedAgentProfiles.displayName));
|
||||
}
|
||||
|
||||
async function get(companyId: string, profileIdOrKey: string) {
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(profileIdOrKey);
|
||||
const rows = await db.select().from(managedAgentProfiles).where(and(
|
||||
eq(managedAgentProfiles.companyId, companyId),
|
||||
isUuid
|
||||
? eq(managedAgentProfiles.id, profileIdOrKey)
|
||||
: eq(managedAgentProfiles.profileKey, profileIdOrKey),
|
||||
)).limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function requireQualified(companyId: string, profileIdOrKey: string) {
|
||||
const profile = await get(companyId, profileIdOrKey);
|
||||
if (!profile) throw notFound("Managed Agent profile not found");
|
||||
if (!profile.enabled || !profile.retentionAcknowledged || !profile.qualifiedAt) {
|
||||
throw conflict("Managed Agent profile is not enabled and qualified");
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function upsert(companyId: string, input: ManagedAgentProfileInput) {
|
||||
const secret = await db.select({ id: companySecrets.id })
|
||||
.from(companySecrets)
|
||||
.where(and(
|
||||
eq(companySecrets.companyId, companyId),
|
||||
eq(companySecrets.id, input.apiKeySecretId),
|
||||
eq(companySecrets.scope, "company"),
|
||||
ne(companySecrets.status, "deleted"),
|
||||
))
|
||||
.limit(1);
|
||||
if (!secret[0]) throw unprocessable("Managed Agent API-key secret reference is invalid");
|
||||
if (input.enabled && !input.retentionAcknowledged) {
|
||||
throw unprocessable("Enabling Managed Agents requires the retention acknowledgement");
|
||||
}
|
||||
|
||||
const profileKey = required(input.profileKey, "Profile key");
|
||||
const values = {
|
||||
companyId,
|
||||
profileKey,
|
||||
displayName: required(input.displayName, "Display name"),
|
||||
service: "anthropic_managed_agents",
|
||||
anthropicAgentId: required(input.anthropicAgentId, "Anthropic Agent ID"),
|
||||
agentVersion: required(input.agentVersion, "Agent version"),
|
||||
environmentId: required(input.environmentId, "Anthropic Environment ID"),
|
||||
betaVersion: CLAUDE_MANAGED_BETA_VERSION,
|
||||
defaultModel: required(input.defaultModel, "Default model"),
|
||||
defaultMaxListCostCents: toCents(input.defaultMaxListCostUsd),
|
||||
apiKeySecretId: input.apiKeySecretId,
|
||||
enabled: input.enabled,
|
||||
retentionAcknowledged: input.retentionAcknowledged,
|
||||
qualification: input.qualification ?? {},
|
||||
qualifiedAt: input.enabled ? new Date() : null,
|
||||
updatedAt: new Date(),
|
||||
} as const;
|
||||
const [row] = await db.insert(managedAgentProfiles)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: [managedAgentProfiles.companyId, managedAgentProfiles.profileKey],
|
||||
set: values,
|
||||
})
|
||||
.returning();
|
||||
return row!;
|
||||
}
|
||||
|
||||
return { list, get, requireQualified, upsert };
|
||||
}
|
||||
@@ -26,8 +26,16 @@ export function buildNativeExecutionInput(input: {
|
||||
branchName: string | null;
|
||||
};
|
||||
normalizedSessionId: string | null;
|
||||
provider?: "codex" | "opencode";
|
||||
provider?: "codex" | "opencode" | "claude_managed";
|
||||
model?: string | null;
|
||||
managedProfile?: {
|
||||
profileId: string;
|
||||
anthropicAgentId: string;
|
||||
agentVersion: string;
|
||||
environmentId: string;
|
||||
betaVersion: "managed-agents-2026-04-01";
|
||||
};
|
||||
maxSessionListCostUsd?: number;
|
||||
lifecyclePolicy?: NativeExecutionInputV1["session"]["lifecyclePolicy"];
|
||||
interactionResponses?: NativeInteractionResponseEnvelope[];
|
||||
completionContract: {
|
||||
@@ -64,11 +72,20 @@ export function buildNativeExecutionInput(input: {
|
||||
},
|
||||
session: {
|
||||
normalizedSessionId: input.normalizedSessionId,
|
||||
driverKind: input.provider === "opencode" ? "opencode_server" : "codex_app_server",
|
||||
driverKind: input.provider === "opencode"
|
||||
? "opencode_server"
|
||||
: input.provider === "claude_managed"
|
||||
? "claude_managed_agents_api"
|
||||
: "codex_app_server",
|
||||
protocolVersion: 1,
|
||||
lifecyclePolicy: input.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
|
||||
},
|
||||
provider: {
|
||||
provider: input.provider === "claude_managed" ? {
|
||||
kind: "claude_managed",
|
||||
model: input.model,
|
||||
managedProfile: input.managedProfile,
|
||||
maxSessionListCostUsd: input.maxSessionListCostUsd,
|
||||
} : {
|
||||
kind: input.provider ?? "codex",
|
||||
model: input.model ?? null,
|
||||
},
|
||||
|
||||
@@ -28,12 +28,43 @@ vi.mock("@paperclipai/paperclip-runner", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
buildNativeProviderEnvironment,
|
||||
cancelNativeSession,
|
||||
executePaperclipNativeSession,
|
||||
nativeSessionFailureDisposition,
|
||||
nativeSessionFailureSourceCode,
|
||||
providerPlanMarkdown,
|
||||
} from "./native-session-executor.js";
|
||||
|
||||
describe("native provider bootstrap environment", () => {
|
||||
it("inherits the host executable and credential-home context", () => {
|
||||
expect(buildNativeProviderEnvironment({}, {
|
||||
PATH: "/opt/homebrew/bin:/usr/bin",
|
||||
HOME: "/Users/runner",
|
||||
CODEX_HOME: "/Users/runner/.codex",
|
||||
PAPERCLIP_INTERNAL_SECRET: "must-not-leak",
|
||||
})).toEqual({
|
||||
PATH: "/opt/homebrew/bin:/usr/bin",
|
||||
HOME: "/Users/runner",
|
||||
CODEX_HOME: "/Users/runner/.codex",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets explicitly configured agent env override host defaults", () => {
|
||||
expect(buildNativeProviderEnvironment({
|
||||
PATH: "/agent/bin",
|
||||
OPENAI_API_KEY: "configured-provider-key",
|
||||
}, {
|
||||
PATH: "/host/bin",
|
||||
HOME: "/Users/runner",
|
||||
})).toEqual({
|
||||
PATH: "/agent/bin",
|
||||
HOME: "/Users/runner",
|
||||
OPENAI_API_KEY: "configured-provider-key",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const execution = {
|
||||
provider: { kind: "codex", model: null },
|
||||
binding: {
|
||||
@@ -45,6 +76,30 @@ const execution = {
|
||||
completionContract: { id: "contract", sha256: "sha" },
|
||||
} as NativeExecutionInputV1;
|
||||
|
||||
describe("provider plan synchronization", () => {
|
||||
it("renders a bounded Markdown checklist without embedding provenance", () => {
|
||||
const markdown = providerPlanMarkdown({
|
||||
explanation: "Release safely",
|
||||
steps: [
|
||||
{ body: "Prepare", status: "completed" },
|
||||
{ body: "Deploy", status: "in_progress" },
|
||||
{ body: "Verify", status: "blocked" },
|
||||
],
|
||||
runId: "must-not-appear",
|
||||
providerThreadId: "native-secret",
|
||||
});
|
||||
expect(markdown).toBe([
|
||||
"Release safely",
|
||||
"",
|
||||
"- [x] Prepare",
|
||||
"- [ ] Deploy _(in progress)_",
|
||||
"- [ ] Verify _(blocked)_",
|
||||
].join("\n"));
|
||||
expect(markdown).not.toContain("must-not-appear");
|
||||
expect(markdown).not.toContain("native-secret");
|
||||
});
|
||||
});
|
||||
|
||||
function leaseDb(boundExecution: NativeExecutionInputV1 = execution): Db {
|
||||
const coordinator = {
|
||||
runId: boundExecution.binding.runId,
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
executeNativeSession,
|
||||
} from "../../vendor/paperclip-runner/index.js";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { heartbeatRuns, issues, nativeRunFinalizations } from "@paperclipai/db";
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { documentRevisions, heartbeatRuns, issueDocuments, issues, nativeRunFinalizations } from "@paperclipai/db";
|
||||
import { PaperclipControlPlanePort } from "./paperclip-control-plane-port.js";
|
||||
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
||||
import { registerRunnerPrpAuthority } from "../../realtime/runner-prp-ws.js";
|
||||
@@ -25,11 +25,13 @@ import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
||||
import { persistActivity, publishActivity } from "../activity-log.js";
|
||||
import { commitNativeStatusDecision } from "./status-decision-committer.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
|
||||
import { documentService } from "../documents.js";
|
||||
import {
|
||||
NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
type NativeAuthoritativeIssueStatus,
|
||||
type NativeStatusDecision,
|
||||
} from "./status-arbiter.js";
|
||||
import { HttpError } from "../../errors.js";
|
||||
|
||||
type ActiveNativeSession = {
|
||||
session: NativeSession;
|
||||
@@ -48,6 +50,109 @@ type WarmNativeSession = {
|
||||
|
||||
const warmNativeSessions = new Map<string, WarmNativeSession>();
|
||||
|
||||
const NATIVE_PROVIDER_HOST_ENV_KEYS = [
|
||||
"PATH",
|
||||
"HOME",
|
||||
"USER",
|
||||
"LOGNAME",
|
||||
"SHELL",
|
||||
"TMPDIR",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"CODEX_HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"SystemRoot",
|
||||
"PATHEXT",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Provider bootstrap needs a small amount of host process context even when
|
||||
* the agent has no configured env. In particular, an empty environment makes
|
||||
* a bare `codex` command unresolvable. Agent-configured values remain
|
||||
* authoritative and may intentionally override the host defaults.
|
||||
*/
|
||||
export function buildNativeProviderEnvironment(
|
||||
configured: NodeJS.ProcessEnv,
|
||||
host: NodeJS.ProcessEnv = process.env,
|
||||
): NodeJS.ProcessEnv {
|
||||
const inherited = Object.fromEntries(
|
||||
NATIVE_PROVIDER_HOST_ENV_KEYS.flatMap((key) => {
|
||||
const value = host[key];
|
||||
return typeof value === "string" && value.length > 0 ? [[key, value]] : [];
|
||||
}),
|
||||
);
|
||||
return { ...inherited, ...configured };
|
||||
}
|
||||
|
||||
type PlanSynchronization = {
|
||||
eventId: string;
|
||||
planId: string;
|
||||
providerRevision: number;
|
||||
status: "synchronized" | "already_synchronized" | "conflict" | "invalid";
|
||||
documentRevision: number | null;
|
||||
currentRevisionId: string | null;
|
||||
};
|
||||
|
||||
export function providerPlanMarkdown(payload: Record<string, unknown>): string {
|
||||
const explanation = typeof payload.explanation === "string" ? payload.explanation.trim() : "";
|
||||
const steps = Array.isArray(payload.steps) ? payload.steps : [];
|
||||
const lines = steps.slice(0, 256).flatMap((value) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
||||
const step = value as Record<string, unknown>;
|
||||
const body = typeof step.body === "string" ? step.body.trim().slice(0, 4_000) : "";
|
||||
if (!body) return [];
|
||||
const status = step.status === "completed" ? "x" : " ";
|
||||
const suffix = step.status === "blocked" ? " _(blocked)_" : step.status === "in_progress" ? " _(in progress)_" : "";
|
||||
return [`- [${status}] ${body}${suffix}`];
|
||||
});
|
||||
return [explanation, lines.join("\n")].filter(Boolean).join("\n\n").slice(0, 256_000);
|
||||
}
|
||||
|
||||
async function synchronizeCompletedProviderPlan(input: {
|
||||
db: Db;
|
||||
execution: NativeExecutionInputV1;
|
||||
event: { sourceEventId: string; turnId?: string; eventType: string; payload: Record<string, unknown> };
|
||||
}): Promise<PlanSynchronization | null> {
|
||||
if (input.event.eventType !== "plan.updated" || input.event.payload.complete !== true) return null;
|
||||
const planId = typeof input.event.payload.planId === "string" ? input.event.payload.planId : "";
|
||||
const providerRevision = Number.isSafeInteger(input.event.payload.revision) ? Number(input.event.payload.revision) : 0;
|
||||
const body = providerPlanMarkdown(input.event.payload);
|
||||
if (!planId || providerRevision < 1 || !body) {
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "invalid", documentRevision: null, currentRevisionId: null };
|
||||
}
|
||||
const provenance = `runner-plan-sync:v1 run=${input.execution.binding.runId} turn=${input.event.turnId ?? "unknown"} provider=${input.execution.provider.kind} plan=${planId} revision=${providerRevision}`;
|
||||
const existingRevision = await input.db.select({ revisionNumber: documentRevisions.revisionNumber, id: documentRevisions.id })
|
||||
.from(documentRevisions)
|
||||
.innerJoin(issueDocuments, eq(issueDocuments.documentId, documentRevisions.documentId))
|
||||
.where(and(eq(issueDocuments.issueId, input.execution.binding.issueId), eq(issueDocuments.key, "plan"), eq(documentRevisions.changeSummary, provenance)))
|
||||
.orderBy(desc(documentRevisions.revisionNumber)).limit(1).then((rows) => rows[0] ?? null);
|
||||
if (existingRevision) {
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "already_synchronized", documentRevision: existingRevision.revisionNumber, currentRevisionId: existingRevision.id };
|
||||
}
|
||||
const documents = documentService(input.db);
|
||||
const current = await documents.getIssueDocumentByKey(input.execution.binding.issueId, "plan");
|
||||
try {
|
||||
const write = await documents.upsertIssueDocument({
|
||||
issueId: input.execution.binding.issueId,
|
||||
key: "plan",
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body,
|
||||
baseRevisionId: current?.latestRevisionId ?? null,
|
||||
changeSummary: provenance,
|
||||
createdByAgentId: input.execution.binding.agentId,
|
||||
createdByRunId: input.execution.binding.runId,
|
||||
});
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "synchronized", documentRevision: write.document.latestRevisionNumber, currentRevisionId: write.document.latestRevisionId };
|
||||
} catch (error) {
|
||||
if (!(error instanceof HttpError) || error.status !== 409) throw error;
|
||||
const latest = await documents.getIssueDocumentByKey(input.execution.binding.issueId, "plan");
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "conflict", documentRevision: latest?.latestRevisionNumber ?? null, currentRevisionId: latest?.latestRevisionId ?? null };
|
||||
}
|
||||
}
|
||||
|
||||
class SessionToolAuthorityRouter {
|
||||
#authority: PaperclipRunnerToolAuthority;
|
||||
|
||||
@@ -75,12 +180,17 @@ function nativeSessionKey(execution: NativeExecutionInputV1): string {
|
||||
}
|
||||
|
||||
function nativeSessionConfigDigest(execution: NativeExecutionInputV1): string {
|
||||
const executionLocation = execution.provider.kind === "claude_managed"
|
||||
? { executionKind: "remote_service", workspace: null }
|
||||
: {
|
||||
executionKind: "local_process",
|
||||
workspaceId: execution.binding.executionWorkspaceId,
|
||||
cwd: execution.workspace.cwd,
|
||||
};
|
||||
return `sha256:${createHash("sha256").update(JSON.stringify({
|
||||
companyId: execution.binding.companyId,
|
||||
issueId: execution.binding.issueId,
|
||||
agentId: execution.binding.agentId,
|
||||
workspaceId: execution.binding.executionWorkspaceId,
|
||||
cwd: execution.workspace.cwd,
|
||||
normalizedSessionId: nativeSessionKey(execution),
|
||||
executionLocation,
|
||||
provider: execution.provider,
|
||||
driverKind: execution.session.driverKind,
|
||||
lifecyclePolicy: execution.session.lifecyclePolicy,
|
||||
@@ -397,6 +507,8 @@ export async function executePaperclipNativeSession(input: {
|
||||
backend?: NativeSessionBackend;
|
||||
useRunnerd?: boolean;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
/** Resolved adapter env; the runner transport applies a provider allowlist before spawn. */
|
||||
runnerEnvironment?: NodeJS.ProcessEnv;
|
||||
}): Promise<AdapterExecutionResult> {
|
||||
const leaseOwner = input.leaseOwner ?? `${input.runnerInstanceId}:${randomUUID()}`;
|
||||
const leaseNow = new Date();
|
||||
@@ -434,6 +546,7 @@ export async function executePaperclipNativeSession(input: {
|
||||
return coordinator.attempt + 1;
|
||||
});
|
||||
const controlPlaneInstanceId = `${input.runnerInstanceId}:control`;
|
||||
const planSynchronizations: PlanSynchronization[] = [];
|
||||
const controlPlane = new PaperclipControlPlanePort(input.db, {
|
||||
companyId: input.execution.binding.companyId,
|
||||
issueId: input.execution.binding.issueId,
|
||||
@@ -444,9 +557,18 @@ export async function executePaperclipNativeSession(input: {
|
||||
sourceInstanceId: input.runnerInstanceId,
|
||||
controlPlaneSourceInstanceId: controlPlaneInstanceId,
|
||||
}, {
|
||||
onCommittedEvent: input.onLog
|
||||
? async (event) => input.onLog!("stdout", `${JSON.stringify({ type: "paperclip.prp.event", event })}\n`)
|
||||
: undefined,
|
||||
onCommittedEvent: async (event) => {
|
||||
if (input.onLog) await input.onLog("stdout", `${JSON.stringify({ type: "paperclip.prp.event", event })}\n`);
|
||||
const synchronization = await synchronizeCompletedProviderPlan({
|
||||
db: input.db,
|
||||
execution: input.execution,
|
||||
event: event as { sourceEventId: string; turnId?: string; eventType: string; payload: Record<string, unknown> },
|
||||
});
|
||||
if (synchronization) {
|
||||
planSynchronizations.push(synchronization);
|
||||
if (input.onLog) await input.onLog("stdout", `${JSON.stringify({ type: "paperclip.plan.synchronization", synchronization })}\n`);
|
||||
}
|
||||
},
|
||||
});
|
||||
let native: Awaited<ReturnType<typeof executeNativeSession>>;
|
||||
const lifecyclePolicy = input.execution.session?.lifecyclePolicy
|
||||
@@ -479,7 +601,7 @@ export async function executePaperclipNativeSession(input: {
|
||||
?? createNativeSessionBackend(input.execution, {
|
||||
runnerInstanceId: input.runnerInstanceId,
|
||||
onSpawn: input.onSpawn,
|
||||
environment: process.env,
|
||||
environment: input.runnerEnvironment ?? process.env,
|
||||
opencodeRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "opencode"),
|
||||
}),
|
||||
controlPlane,
|
||||
@@ -617,16 +739,19 @@ export async function executePaperclipNativeSession(input: {
|
||||
resultJson: {
|
||||
nativeResult: native.result as unknown as Record<string, unknown>,
|
||||
nativeTerminal: native.terminal as unknown as Record<string, unknown>,
|
||||
planSynchronizations,
|
||||
},
|
||||
summary: native.result.summary,
|
||||
sessionId: native.normalizedSessionId,
|
||||
sessionDisplayId: native.providerSessionId ?? native.normalizedSessionId,
|
||||
provider: input.execution.provider.kind === "opencode"
|
||||
provider: input.execution.provider.kind === "claude_managed"
|
||||
? "anthropic"
|
||||
: input.execution.provider.kind === "opencode"
|
||||
? input.execution.provider.model?.split("/", 1)[0] ?? "opencode"
|
||||
: "openai",
|
||||
model: input.execution.provider.model,
|
||||
usage: normalizeNativeUsage(native.usage),
|
||||
costUsd: numericUsageField(native.usage, ["costUsd", "cost"]),
|
||||
costUsd: numericUsageField(native.usage, ["providerCostUsd", "costUsd", "cost"]),
|
||||
usageBasis: "per_run",
|
||||
nativeFinalization: finalization,
|
||||
};
|
||||
@@ -661,6 +786,7 @@ function createRunnerdBackend(input: {
|
||||
execution: NativeExecutionInputV1;
|
||||
runnerInstanceId: string;
|
||||
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
|
||||
runnerEnvironment?: NodeJS.ProcessEnv;
|
||||
}): NativeSessionBackend {
|
||||
const authority = new PaperclipRunnerToolAuthority(input.db, {
|
||||
companyId: input.execution.binding.companyId,
|
||||
@@ -683,11 +809,19 @@ function createRunnerdBackend(input: {
|
||||
onSpawn: input.onSpawn,
|
||||
dynamicTools: authorityRouter.definitions(),
|
||||
dynamicToolHandler: (call) => authorityRouter.execute(call),
|
||||
environment: process.env,
|
||||
environment: input.runnerEnvironment ?? process.env,
|
||||
opencodeRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "opencode"),
|
||||
codexTransportFactory: () => createRunnerdCodexTransport({
|
||||
provider: input.execution.provider.kind,
|
||||
...(input.execution.provider.kind === "claude_managed" ? {
|
||||
managedProfile: {
|
||||
...input.execution.provider.managedProfile,
|
||||
maxSessionListCostUsd: input.execution.provider.maxSessionListCostUsd,
|
||||
model: input.execution.provider.model,
|
||||
},
|
||||
} : {}),
|
||||
stateDirectory: root,
|
||||
environment: process.env,
|
||||
environment: input.runnerEnvironment ?? process.env,
|
||||
lifecyclePolicy: input.execution.session.lifecyclePolicy,
|
||||
resumeDynamicTools: authorityRouter.definitions(),
|
||||
prpIdentity: {
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("resolveNativeRuntimeMode", () => {
|
||||
runtimeConfig: {},
|
||||
adapterConfig: { provider: "claude" },
|
||||
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
|
||||
})).toThrow(/provider must be codex or opencode/);
|
||||
})).toThrow(/provider must be codex, opencode, or claude_managed/);
|
||||
});
|
||||
|
||||
it("selects OpenCode only with a provider/model value", () => {
|
||||
|
||||
@@ -17,7 +17,11 @@ export type NativeRuntimeResolution =
|
||||
kind: "native";
|
||||
resolverVersion: typeof NATIVE_RUNTIME_RESOLVER_VERSION;
|
||||
reason: "eligible_opt_in";
|
||||
profile: { mode: "native"; backend: "codex_app_server" | "opencode_server"; protocolVersion: 1 };
|
||||
profile: {
|
||||
mode: "native";
|
||||
backend: "codex_app_server" | "opencode_server" | "claude_managed_agents_api";
|
||||
protocolVersion: 1;
|
||||
};
|
||||
authorityDecision: NativeStatusDecision;
|
||||
};
|
||||
|
||||
@@ -47,8 +51,8 @@ export function resolveNativeRuntimeMode(input: {
|
||||
const nativeRunner = record(record(input.runtimeConfig).nativeRunner);
|
||||
const runnerAdapterSelected = input.agent.adapterType === "paperclip_runner";
|
||||
const runnerProvider = record(input.adapterConfig).provider ?? "codex";
|
||||
if (runnerAdapterSelected && runnerProvider !== "codex" && runnerProvider !== "opencode") {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner provider must be codex or opencode");
|
||||
if (runnerAdapterSelected && !["codex", "opencode", "claude_managed"].includes(String(runnerProvider))) {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner provider must be codex, opencode, or claude_managed");
|
||||
}
|
||||
if (runnerAdapterSelected && runnerProvider === "opencode") {
|
||||
const model = record(input.adapterConfig).model;
|
||||
@@ -56,6 +60,21 @@ export function resolveNativeRuntimeMode(input: {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner OpenCode provider requires model in provider/model form");
|
||||
}
|
||||
}
|
||||
if (runnerAdapterSelected && runnerProvider === "claude_managed") {
|
||||
const config = record(input.adapterConfig);
|
||||
for (const key of ["managedProfileId", "anthropicAgentId", "agentVersion", "anthropicEnvironmentId", "model"]) {
|
||||
if (typeof config[key] !== "string" || String(config[key]).trim().length === 0) {
|
||||
throw new NativeRuntimeEligibilityError(`paperclip_runner Claude Agent provider requires ${key}`);
|
||||
}
|
||||
}
|
||||
if (config.managedAgentsRetentionAcknowledged !== true) {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner Claude Agent provider requires retention acknowledgement");
|
||||
}
|
||||
const cap = Number(config.maxSessionListCostUsd);
|
||||
if (!Number.isFinite(cap) || cap <= 0) {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner Claude Agent provider requires a positive spend ceiling");
|
||||
}
|
||||
}
|
||||
const mode = runnerAdapterSelected ? "native" : nativeRunner.mode;
|
||||
if (!runnerAdapterSelected && !input.enabled) {
|
||||
const compatibility = resolveNativeCompatibilityStatus({
|
||||
@@ -90,7 +109,7 @@ export function resolveNativeRuntimeMode(input: {
|
||||
throw new NativeRuntimeEligibilityError("run must be bound to a standard issue");
|
||||
}
|
||||
if (
|
||||
(!input.workspaceId && !runnerAdapterSelected)
|
||||
(!input.workspaceId && !runnerAdapterSelected && runnerProvider !== "claude_managed")
|
||||
|| input.target?.kind && input.target.kind !== "local"
|
||||
) {
|
||||
throw new NativeRuntimeEligibilityError("a realized local workspace is required");
|
||||
@@ -109,7 +128,11 @@ export function resolveNativeRuntimeMode(input: {
|
||||
reason: "eligible_opt_in",
|
||||
profile: {
|
||||
mode: "native",
|
||||
backend: runnerProvider === "opencode" ? "opencode_server" : "codex_app_server",
|
||||
backend: runnerProvider === "opencode"
|
||||
? "opencode_server"
|
||||
: runnerProvider === "claude_managed"
|
||||
? "claude_managed_agents_api"
|
||||
: "codex_app_server",
|
||||
protocolVersion: 1,
|
||||
},
|
||||
authorityDecision: rollout,
|
||||
@@ -144,7 +167,11 @@ export function resolveHeartbeatNativeRuntimeMode(input: {
|
||||
reason: "eligible_opt_in",
|
||||
profile: {
|
||||
mode: "native",
|
||||
backend: input.persisted.driverKind === "opencode_server" ? "opencode_server" : "codex_app_server",
|
||||
backend: input.persisted.driverKind === "opencode_server"
|
||||
? "opencode_server"
|
||||
: input.persisted.driverKind === "claude_managed_agents_api"
|
||||
? "claude_managed_agents_api"
|
||||
: "codex_app_server",
|
||||
protocolVersion: 1,
|
||||
},
|
||||
authorityDecision: resolveNativeMigrationStatus({
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldRouteRecoveryToOriginalAgent } from "./service.js";
|
||||
|
||||
describe("native runner recovery routing", () => {
|
||||
it.each([
|
||||
"native_session_interrupted",
|
||||
"native_runner_process_exited",
|
||||
"provider_transport_failed",
|
||||
"provider_frame_too_large",
|
||||
] as const)("keeps %s with the original runner", (cause) => {
|
||||
expect(shouldRouteRecoveryToOriginalAgent(cause)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not change ordinary stranded-issue manager routing", () => {
|
||||
expect(shouldRouteRecoveryToOriginalAgent("stranded_assigned_issue")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -162,16 +162,34 @@ type LatestIssueRun = Pick<
|
||||
} | null;
|
||||
type SuccessfulLatestIssueRun = NonNullable<LatestIssueRun> & { status: "succeeded" };
|
||||
|
||||
type StrandedRecoveryCause =
|
||||
export type StrandedRecoveryCause =
|
||||
| "stranded_assigned_issue"
|
||||
| "process_lost"
|
||||
| "provider_quota"
|
||||
| "codex_output_inactivity_monitor"
|
||||
| "workspace_validation_failed"
|
||||
| "configuration_incomplete"
|
||||
| "native_session_interrupted"
|
||||
| "native_runner_process_exited"
|
||||
| "provider_transport_failed"
|
||||
| "provider_frame_too_large"
|
||||
| "execution_review_participant_recovery"
|
||||
| typeof SUCCESSFUL_RUN_MISSING_STATE_REASON;
|
||||
|
||||
const NATIVE_RUNNER_RECOVERY_CAUSES = new Set<StrandedRecoveryCause>([
|
||||
"native_session_interrupted",
|
||||
"native_runner_process_exited",
|
||||
"provider_transport_failed",
|
||||
"provider_frame_too_large",
|
||||
]);
|
||||
|
||||
export function shouldRouteRecoveryToOriginalAgent(cause: StrandedRecoveryCause): boolean {
|
||||
return cause === "process_lost"
|
||||
|| cause === SUCCESSFUL_RUN_MISSING_STATE_REASON
|
||||
|| cause === "codex_output_inactivity_monitor"
|
||||
|| NATIVE_RUNNER_RECOVERY_CAUSES.has(cause);
|
||||
}
|
||||
|
||||
type StrandedPreviousStatus = "todo" | "in_progress" | "in_review";
|
||||
|
||||
type SuccessfulRunHandoffRecoveryEvidence = {
|
||||
@@ -274,6 +292,9 @@ function resolveStrandedRecoveryCause(
|
||||
if (latestRun?.errorCode === "codex_output_inactivity_monitor") {
|
||||
return "codex_output_inactivity_monitor";
|
||||
}
|
||||
if (NATIVE_RUNNER_RECOVERY_CAUSES.has(latestRun?.errorCode as StrandedRecoveryCause)) {
|
||||
return latestRun!.errorCode as StrandedRecoveryCause;
|
||||
}
|
||||
return "stranded_assigned_issue";
|
||||
}
|
||||
|
||||
@@ -2578,9 +2599,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
}) {
|
||||
const originalAgentId = input.latestRun?.agentId ?? input.issue.assigneeAgentId;
|
||||
const returnOwnerAgentId = input.issue.assigneeAgentId ?? originalAgentId;
|
||||
const routeToOriginal = input.recoveryCause === "process_lost" ||
|
||||
input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON ||
|
||||
input.recoveryCause === "codex_output_inactivity_monitor";
|
||||
const routeToOriginal = shouldRouteRecoveryToOriginalAgent(input.recoveryCause);
|
||||
if (input.recoveryCause === "provider_quota") {
|
||||
const retryAgentId = await resolveInvokableRecoveryAgentId(input.issue, originalAgentId);
|
||||
if (!retryAgentId) {
|
||||
|
||||
Reference in new issue
Block a user