feat(runner): add native Hermes ACP qualification candidate

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-08 12:07:09 -05:00
1 parent 75abb6a774
commit 9e07748e91
97 files changed
+2866 -197

No files matched your search

+4 -3
View File
@@ -28,12 +28,13 @@ export function poolMemberRuntimeConfig(member: AiConnectionPoolMember, adapterT
const notes: string[] = [];
const provider = member.binding.provider;
const profile = member.profile;
const expected = provider === "openai" ? profile.provider === "codex"
const hermes = profile.provider === "acpx" && profile.acpxAgent === "hermes";
const expected = hermes || (provider === "openai" ? profile.provider === "codex"
: provider === "anthropic" ? profile.provider === "acpx" && profile.acpxAgent === "claude"
: provider === "xai" ? profile.provider === "acpx" && profile.acpxAgent === "grok"
: profile.provider === "opencode";
: profile.provider === "opencode");
if (!expected) throw unprocessable("Pool member harness does not match its account");
const supportsModel = (value: string) => provider === "openai" ? isCodexLocalKnownModel(value)
const supportsModel = (value: string) => hermes ? Boolean(value.trim()) : provider === "openai" ? isCodexLocalKnownModel(value)
: provider === "anthropic" ? claudeModels.some((m) => m.id === value)
: provider === "xai" ? grokModels.some((m) => m.id === value) : value.startsWith("openrouter/");
const config: Record<string, unknown> = { provider: profile.provider, ...(profile.acpxAgent ? { acpxAgent: profile.acpxAgent } : {}), model: profile.model };
+27 -3
View File
@@ -18,6 +18,7 @@ import { WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE } from "@paperclipai/adapter-utils/
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { runAdapterExecutionTargetProcess } from "@paperclipai/adapter-utils/execution-target";
import { decideGrokAuthMerge } from "@paperclipai/adapter-grok-local/server";
import { projectHermesConnection, restoreHermesCredential } from "./native-runtime/hermes-connection.js";
export function isAiConnectionBusy(error: unknown): error is HttpError {
return error instanceof HttpError && error.status === 422 &&
@@ -26,6 +27,7 @@ export function isAiConnectionBusy(error: unknown): error is HttpError {
// Blank values intentionally override inherited credentials in CLI child environments.
export const AI_AUTH_ENV_KEYS = [
"PAPERCLIP_HERMES_AUTH_JSON_SECRET", "PAPERCLIP_HERMES_CONFIG_JSON", "PAPERCLIP_HERMES_CONNECTION_FINGERPRINT",
"PAPERCLIP_AI_PROVIDER_KEY", "PAPERCLIP_AI_PROVIDER_URL", "PAPERCLIP_CODEX_PROVIDERS",
"GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GEMINI_BASE_URL", "GOOGLE_GENAI_USE_VERTEXAI",
"HERMES_HOME", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_BEARER_TOKEN_BEDROCK", "AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_CONFIG_FILE", "AWS_WEB_IDENTITY_TOKEN_FILE", "AWS_ROLE_ARN", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_CONTAINER_CREDENTIALS_FULL_URI",
@@ -200,7 +202,7 @@ export function managedAiSessionFingerprintConfig(
}
const managed = config.managedAiConnection as Record<string, unknown> | undefined;
if (managed?.sessionIdentity) {
for (const key of ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY", "OPENROUTER_API_KEY", "XAI_API_KEY", "GROK_API_KEY", "OPENCODE_AUTH_JSON", "OPENCODE_CONFIG_CONTENT"]) {
for (const key of ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY", "OPENROUTER_API_KEY", "XAI_API_KEY", "GROK_API_KEY", "GEMINI_API_KEY", "AWS_BEARER_TOKEN_BEDROCK", "PAPERCLIP_HERMES_AUTH_JSON_SECRET", "OPENCODE_AUTH_JSON", "OPENCODE_CONFIG_CONTENT"]) {
if (env[key]) env[key] = "<managed-ai-credential>";
}
}
@@ -226,6 +228,7 @@ export async function prepareManagedAiRuntime(
? (input.config.env as Record<string, unknown>)
: {};
for (const key of [
"PAPERCLIP_HERMES_CONFIG_JSON", "PAPERCLIP_HERMES_AUTH_JSON_SECRET", "PAPERCLIP_HERMES_CONNECTION_FINGERPRINT",
"ANTHROPIC_BASE_URL",
"OPENAI_BASE_URL",
"XAI_BASE_URL",
@@ -269,7 +272,8 @@ export async function prepareManagedAiRuntime(
"The selected default changed. Retry this execution.",
);
const credentialRef = selection.grant.credentialSecretRefs.find((ref) => ref.configPath === "ai.credential");
const routing = aiConnectionMetadataSchema.parse(selection.connection.config.ai).routing;
const metadata = aiConnectionMetadataSchema.parse(selection.connection.config.ai);
const routing = metadata.routing;
const noAuth = routing?.auth === "none";
if (!noAuth && !credentialRef) throw unprocessable("The selected AI credential is unavailable");
const readFreshness = async () => {
@@ -348,7 +352,19 @@ export async function prepareManagedAiRuntime(
security: { auth: { selectedType: "gemini-api-key" } },
}), { mode: 0o600 });
}
const projected = routing ? managedProviderRouting(routing, harness, value, typeof input.config.model === "string" ? input.config.model : "") : undefined;
const hermes = harness === "hermes_runner" ? projectHermesConnection(metadata, typeof input.config.model === "string" ? input.config.model : "", value) : undefined;
if (hermes) {
// Remove the legacy provider projection; Hermes receives only this account.
for (const key of AI_AUTH_ENV_KEYS) if (!(key in managedAiHomeEnvironment(home))) env[key] = "";
Object.assign(env, hermes.env);
env.PAPERCLIP_HERMES_CONFIG_JSON = JSON.stringify(hermes.config);
if (hermes.auth) {
const nativeAuth = JSON.stringify(hermes.auth);
env.PAPERCLIP_HERMES_AUTH_JSON_SECRET = nativeAuth;
await writeFile(path.join(providerHome, "hermes-auth.json"), nativeAuth, { mode: 0o600 });
}
}
const projected = routing && !hermes ? managedProviderRouting(routing, harness, value, typeof input.config.model === "string" ? input.config.model : "") : undefined;
if (projected) {
Object.assign(env, projected.env);
if (projected.hermesConfig) await writeFile(path.join(providerHome, "config.yaml"), projected.hermesConfig, { mode: 0o600 });
@@ -360,11 +376,14 @@ export async function prepareManagedAiRuntime(
.slice(0, 16);
const identity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${generation}`;
const sessionIdentity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${noAuth ? "no-auth" : `${credentialRef!.secretId}:${freshness!.epoch}`}`;
const hermesConnectionFingerprint = hermes ? createHash("sha256").update(JSON.stringify({ sessionIdentity, config: hermes.config })).digest("hex") : undefined;
if (hermesConnectionFingerprint) env.PAPERCLIP_HERMES_CONNECTION_FINGERPRINT = hermesConnectionFingerprint;
return {
sessionIdentity,
config: {
...input.config,
...projected?.config,
...(hermesConnectionFingerprint ? { hermesConnectionFingerprint } : {}),
...(routing ? { managedAiRouting: routing } : {}),
env,
managedAiConnection: { ...selection.attribution, identity, sessionIdentity },
@@ -376,6 +395,11 @@ export async function prepareManagedAiRuntime(
home,
cleanup: async () => {
if (subscriptionFile) {
if (hermes?.auth) {
if (metadata.provider !== "openai" && metadata.provider !== "xai") throw new Error("Unsupported Hermes subscription refresh");
const refreshedNative = await readFile(path.join(providerHome, "hermes-auth.json"), "utf8");
await writeFile(authFile, restoreHermesCredential(metadata.provider, value, refreshedNative), { mode: 0o600 });
}
const refreshed = await readFile(authFile, "utf8");
if (refreshed !== value) {
// A quota exchange can hold this company lock for 60 seconds.
+2 -1
View File
@@ -24503,6 +24503,7 @@ export function heartbeatService(
getNativeFreshSessionHandoff = nativeReviewRequest ? undefined : getFreshSessionHandoff;
const buildExecution = ({ normalizedSessionId, resumedSession }: { normalizedSessionId: string; resumedSession: boolean }) =>
buildNativeExecutionInput({
hermesConnectionFingerprint: readNonEmptyString(resolvedConfig.hermesConnectionFingerprint) ?? undefined,
agentKeyId: agentIdentity?.keyId,
companyId: agent.companyId,
runId: run.id,
@@ -24585,7 +24586,7 @@ export function heartbeatService(
issueAssigneeOverrides?.adapterConfig,
managedAiRuntime ? readNonEmptyString(resolvedConfig.model) ?? undefined : undefined,
)
: agent.adapterConfig,
: resolvedConfig.acpxAgent === "hermes" ? resolvedConfig : agent.adapterConfig,
managedProfile,
agentCoreProfile,
dotBinding,
@@ -2,25 +2,25 @@ import type { NativeExecutionInput } from "../../vendor/paperclip-runner/index.j
/** Operator-only admission for an exact candidate/model during qualification. */
export const ACPX_QUALIFICATION_ENV = "PAPERCLIP_RUNNER_ACPX_QUALIFICATION";
export type AcpxQualificationCandidate = "cursor" | "copilot" | "pi";
export type AcpxQualificationCandidate = "cursor" | "copilot" | "pi" | "hermes";
export function resolveAcpxQualification(
provider: NativeExecutionInput["provider"] | { kind: "acpx"; agent: string; model: string },
hostEnvironment: NodeJS.ProcessEnv,
): AcpxQualificationCandidate | undefined {
if (provider.kind !== "acpx" || !["copilot", "pi"].includes(provider.agent)) return undefined;
if (provider.kind !== "acpx" || !["copilot", "pi", "hermes"].includes(provider.agent)) return undefined;
const encoded = hostEnvironment[ACPX_QUALIFICATION_ENV];
if (!encoded) return undefined;
let entries: unknown;
try { entries = JSON.parse(encoded); } catch { throw new Error("Invalid ACPX qualification authorization"); }
if (!Array.isArray(entries) || entries.length < 1 || entries.length > 3) {
if (!Array.isArray(entries) || entries.length < 1 || entries.length > 4) {
throw new Error("Invalid ACPX qualification authorization");
}
const agents = new Set<string>();
for (const entry of entries) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)
|| Object.keys(entry).sort().join(",") !== "agent,model"
|| !["cursor", "copilot", "pi"].includes(entry.agent)
|| !["cursor", "copilot", "pi", "hermes"].includes(entry.agent)
|| typeof entry.model !== "string" || !entry.model.trim() || entry.model !== entry.model.trim()
|| agents.has(entry.agent)) throw new Error("Invalid ACPX qualification authorization");
agents.add(entry.agent);
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { projectHermesConnection, restoreHermesCredential } from "./hermes-connection.js";
import type { AiConnectionMetadata } from "@paperclipai/shared";
describe("Hermes Connections projection", () => {
it.each([
["anthropic", "ANTHROPIC_API_KEY", "anthropic"], ["openai", "OPENAI_API_KEY", "openai"],
["openrouter", "OPENROUTER_API_KEY", "openrouter"], ["xai", "XAI_API_KEY", "xai"], ["google", "GEMINI_API_KEY", "gemini"],
] as const)("uses the selected %s credential and exact model", (provider, key, native) => {
const projected = projectHermesConnection({ provider, method: "api_key" }, "provider/exact-model", "fixture-secret");
expect(projected.env).toEqual({ [key]: "fixture-secret" });
expect(projected.config).toEqual({ model: { provider: native, default: "provider/exact-model" } });
expect(JSON.stringify(projected.config)).not.toContain("fixture-secret");
});
it("projects a Claude subscription without reading another harness home", () => {
expect(projectHermesConnection({ provider: "anthropic", method: "subscription" }, "claude-model", "fixture-oauth").env).toEqual({ CLAUDE_CODE_OAUTH_TOKEN: "fixture-oauth" });
});
it("preserves OpenAI account metadata on refresh and rejects account changes", () => {
const original = JSON.stringify({ tokens: { access_token: "old", refresh_token: "old-refresh", account_id: "account" }, unrelated: "keep" });
const projected = projectHermesConnection({ provider: "openai", method: "subscription" }, "model", original);
expect(restoreHermesCredential("openai", original, JSON.stringify(projected.auth))).toBe(original);
const native = { version: 1, providers: { "openai-codex": { tokens: { access_token: "new", refresh_token: "new-refresh", account_id: "account" }, last_refresh: "2026-10-07T00:00:00Z" } } };
expect(JSON.parse(restoreHermesCredential("openai", original, JSON.stringify(native)))).toMatchObject({ unrelated: "keep", tokens: { account_id: "account", access_token: "new" } });
native.providers["openai-codex"].tokens.account_id = "other";
expect(() => restoreHermesCredential("openai", original, JSON.stringify(native))).toThrow("changed accounts");
});
it("does not forward an API key for an unauthenticated custom endpoint", () => {
const connection = { provider: "openai", method: "api_key", routing: { kind: "local", protocol: "chat", baseUrl: "http://localhost:11434/v1", auth: "none", models: [] } } satisfies AiConnectionMetadata;
const projected = projectHermesConnection(connection, "local-model", "");
expect(projected.env).toEqual({});
expect(projected.config).toMatchObject({ paperclip_auth: { protocol: "chat", style: "none" }, providers: { paperclip: { transport: "chat_completions" } } });
});
it.each([
["chat", "bearer", "chat_completions", "OPENAI_API_KEY"],
["responses", "bearer", "codex_responses", "OPENAI_API_KEY"],
["messages", "api_key", "anthropic_messages", "ANTHROPIC_API_KEY"],
["messages", "bearer", "anthropic_messages", "ANTHROPIC_AUTH_TOKEN"],
] as const)("preserves %s with %s authentication on a custom route", (protocol, auth, transport, key) => {
const connection = { provider: "openai", method: "api_key", routing: {
kind: "gateway", protocol, auth, baseUrl: "https://models.example.test/v1", models: [],
} } satisfies AiConnectionMetadata;
const result = projectHermesConnection(connection, "tenant/exact-model", "fixture-secret");
expect(result.env).toEqual({ [key]: "fixture-secret" });
expect(result.config).toMatchObject({ paperclip_auth: { protocol, style: auth }, providers: {
paperclip: { transport, key_env: key, default_model: "tenant/exact-model" },
} });
expect(JSON.stringify(result.config)).not.toContain("fixture-secret");
});
it("pins the managed Bedrock region without falling back to instance credentials", () => {
const result = projectHermesConnection({ provider: "anthropic", method: "api_key", routing: {
kind: "bedrock", protocol: "bedrock", auth: "bearer", region: "us-west-2", models: [],
} }, "us.anthropic.exact-model", "fixture-bedrock-key");
expect(result.env).toEqual({ AWS_BEARER_TOKEN_BEDROCK: "fixture-bedrock-key", AWS_REGION: "us-west-2", AWS_DEFAULT_REGION: "us-west-2", AWS_EC2_METADATA_DISABLED: "true" });
expect(result.config).toEqual({ model: { provider: "bedrock", default: "us.anthropic.exact-model" }, bedrock: { region: "us-west-2" } });
});
it("roundtrips Grok refreshes while binding the original JWT issuer and subject", () => {
const jwt = (sub: string, exp: number) => `header.${Buffer.from(JSON.stringify({ iss: "https://auth.example.test", sub, exp })).toString("base64url")}.signature`;
const original = JSON.stringify({ account: { key: jwt("same", 100), refresh_token: "refresh-old", expires_at: 100, metadata: "keep" } });
const result = projectHermesConnection({ provider: "xai", method: "subscription" }, "grok-model", original);
expect(restoreHermesCredential("xai", original, JSON.stringify(result.auth))).toBe(original);
const refreshed = { version: 1, providers: { "xai-oauth": { tokens: { access_token: jwt("same", 200), refresh_token: "refresh-new" } } } };
expect(JSON.parse(restoreHermesCredential("xai", original, JSON.stringify(refreshed)))).toMatchObject({ account: { refresh_token: "refresh-new", expires_at: 200, metadata: "keep" } });
refreshed.providers["xai-oauth"].tokens.access_token = jwt("other", 300);
expect(() => restoreHermesCredential("xai", original, JSON.stringify(refreshed))).toThrow("account identity");
});
});
@@ -0,0 +1,95 @@
import { aiRoutingBaseUrl, type AiConnectionMetadata } from "@paperclipai/shared";
/** Runtime-only projection of an already authorized Connections selection. */
export function projectHermesConnection(connection: AiConnectionMetadata, model: string, credential: string) {
if (!model.trim() || model !== model.trim()) throw new Error("Hermes requires an explicit model ID");
const env: Record<string, string> = {};
const route = connection.routing;
const provider = route?.kind === "bedrock" ? "bedrock"
: route && (route.kind !== "openrouter" || route.protocol !== "chat") ? "custom:paperclip"
: connection.method === "subscription" && connection.provider === "openai" ? "openai-codex"
: connection.method === "subscription" && connection.provider === "xai" ? "xai-oauth"
: connection.provider === "google" ? "gemini" : connection.provider;
const nativeModel = model;
const config: Record<string, unknown> = { model: { provider, default: nativeModel } };
let auth: Record<string, unknown> | undefined;
if (route?.kind === "bedrock") {
env.AWS_BEARER_TOKEN_BEDROCK = credential;
env.AWS_REGION = route.region!;
env.AWS_DEFAULT_REGION = route.region!;
env.AWS_EC2_METADATA_DISABLED = "true";
config.bedrock = { region: route.region };
} else if (route && provider === "custom:paperclip") {
const apiMode = { chat: "chat_completions", responses: "codex_responses", messages: "anthropic_messages", bedrock: "bedrock_converse" }[route.protocol];
const key = route.protocol === "messages"
? route.auth === "api_key" ? "ANTHROPIC_API_KEY" : "ANTHROPIC_AUTH_TOKEN" : "OPENAI_API_KEY";
if (route.auth !== "none") env[key] = credential;
config.providers = { paperclip: {
base_url: aiRoutingBaseUrl(route, route.protocol === "messages" ? "claude_local" : "hermes_runner"), transport: apiMode,
default_model: nativeModel,
...(route.auth === "none" ? { api_key: "no-key-required" } : { key_env: key }),
} };
// Native Hermes guesses Messages header style from the URL. The pinned
// bridge uses this value-free declaration to preserve the connection.
config.paperclip_auth = { protocol: route.protocol, style: route.auth };
} else if (connection.method === "subscription" && connection.provider !== "anthropic") {
const original = parseRecord(credential);
if (connection.provider === "openai") {
const tokens = record(original.tokens);
requireTokens(tokens);
auth = { version: 1, providers: { "openai-codex": { tokens, last_refresh: original.last_refresh } } };
} else if (connection.provider === "xai") {
const entries = Object.entries(original);
if (entries.length !== 1) throw new Error("The selected Grok connection has an invalid account identity");
const entry = record(entries[0]![1]);
const tokens = { access_token: entry.key, refresh_token: entry.refresh_token, token_type: "Bearer" };
requireTokens(tokens);
auth = { version: 1, providers: { "xai-oauth": { tokens, auth_mode: "oauth_pkce" } } };
} else throw new Error("This subscription does not support Hermes");
} else {
const key = connection.method === "subscription" ? "CLAUDE_CODE_OAUTH_TOKEN"
: { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", openrouter: "OPENROUTER_API_KEY", xai: "XAI_API_KEY", google: "GEMINI_API_KEY" }[connection.provider];
env[key] = credential;
}
return { config, env, auth, provider, nativeModel };
}
/** Convert native refresh output back before the existing provider merge/CAS. */
export function restoreHermesCredential(provider: "openai" | "xai", originalValue: string, nativeValue: string): string {
const original = parseRecord(originalValue);
const state = record(record(parseRecord(nativeValue).providers)[provider === "openai" ? "openai-codex" : "xai-oauth"]);
const tokens = record(state.tokens);
requireTokens(tokens);
if (provider === "openai") {
const old = record(original.tokens);
if (tokens.access_token === old.access_token && tokens.refresh_token === old.refresh_token) return originalValue;
if (old.account_id && tokens.account_id && old.account_id !== tokens.account_id) throw new Error("Hermes credential refresh changed accounts");
return JSON.stringify({ ...original, tokens: { ...old, ...tokens }, last_refresh: state.last_refresh });
}
const entries = Object.entries(original);
if (entries.length !== 1) throw new Error("Invalid managed Grok credential");
const [identity, raw] = entries[0]!;
const old = record(raw);
if (tokens.access_token === old.key && tokens.refresh_token === old.refresh_token) return originalValue;
// The existing Grok merge rejects missing/unverifiable freshness.
const claims = jwtClaims(String(tokens.access_token));
const oldClaims = jwtClaims(String(old.key));
if (!oldClaims.iss || !oldClaims.sub || claims.iss !== oldClaims.iss || claims.sub !== oldClaims.sub) {
throw new Error("Hermes credential refresh changed or lost the Grok account identity");
}
const expiry = typeof claims.exp === "number" ? claims.exp : undefined;
return JSON.stringify({ [identity]: { ...old, key: tokens.access_token, refresh_token: tokens.refresh_token, expires_at: expiry } });
}
function record(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid managed Hermes credential representation");
return value as Record<string, unknown>;
}
function parseRecord(value: string) { return record(JSON.parse(value)); }
function requireTokens(value: Record<string, unknown>) {
if (![value.access_token, value.refresh_token].every(token => typeof token === "string" && token.trim())) throw new Error("The selected subscription is missing usable credentials");
}
function jwtClaims(value: string): Record<string, unknown> {
try { return parseRecord(Buffer.from(value.split(".")[1] ?? "", "base64url").toString("utf8")); }
catch { return {}; }
}
@@ -8,6 +8,7 @@ import type {
NativeCodexApprovalPolicy,
NativeExecutionInputV5,
NativeExecutionInputV6,
NativeExecutionInputV7,
DotBindingSnapshot,
NativeCompletionSource,
NativeInteractionResponseEnvelope,
@@ -72,6 +73,7 @@ export interface BuildNativeExecutionInput {
provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx" | "openai_dot";
dotBinding?: DotBindingSnapshot;
acpxAgent?: NativeAcpxAgent;
hermesConnectionFingerprint?: string;
codexApprovalPolicy?: NativeCodexApprovalPolicy;
codexReasoningEffort?: string;
opencodePermissionMode?: NativeOpenCodePermissionMode;
@@ -106,9 +108,10 @@ export interface BuildNativeExecutionInput {
runtimeContext: NativeRuntimeContextSnapshot;
}
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider: "openai_dot"; dotBinding: DotBindingSnapshot }): NativeExecutionInputV6;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider: "acpx"; acpxAgent: "hermes" }): NativeExecutionInputV7;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude<BuildNativeExecutionInput["provider"], "openai_dot"> }): NativeExecutionInputV5;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 {
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 {
if (input.issue.workMode !== "standard" && input.issue.workMode !== "planning" && input.issue.workMode !== "ask") {
throw new Error("native_execution_input_invalid: issue work mode must be standard, planning, or ask");
}
@@ -198,7 +201,7 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
? verifiedCompletionSources(input.turnContext, input.completionContract.sources ?? [])
: [];
const prepared = {
schema: "paperclip.native-execution-input.v5",
schema: input.acpxAgent === "hermes" ? "paperclip.native-execution-input.v7" : "paperclip.native-execution-input.v5",
...((input.initialCommunicationGuidance || input.freshSessionHandoff) ? {
initialCommunicationGuidance: [input.initialCommunicationGuidance, input.freshSessionHandoff].filter(Boolean).join("\n\n"),
} : {}),
@@ -272,6 +275,7 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
: input.provider === "acpx"
? {
kind: "acpx",
...(input.acpxAgent === "hermes" ? { connectionFingerprint: input.hermesConnectionFingerprint } : {}),
agent: acpxProfile!.agent,
model: input.model,
permissionMode: input.acpxPermissionMode ?? "approve-all",
@@ -320,7 +324,7 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
schema: "paperclip.native-execution-input.v6", provider: { kind: "openai_dot", model: null, binding: input.dotBinding },
workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null },
session: { ...prepared.session, driverKind: "openai_dot_mcp", lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }
} : prepared) as NativeExecutionInputV5 | NativeExecutionInputV6;
} : prepared) as NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7;
}
@@ -1,4 +1,5 @@
import { createHash, randomUUID } from "node:crypto";
import type { NativeUserAttachment } from "../../vendor/paperclip-runner/index.js";
import { execFile } from "node:child_process";
import { constants } from "node:fs";
import type { Stats } from "node:fs";
@@ -89,6 +90,7 @@ export interface NativeRunnerStagedAttachment {
export interface NativeRunnerAttachmentStage {
readonly attachments: readonly NativeRunnerStagedAttachment[];
readonly promptAttachments?: NativeUserAttachment[];
cleanup(): Promise<void>;
}
@@ -812,6 +814,7 @@ export async function stageNativeRunnerWakeAttachments(input: {
| "executionTargetKind"
>;
readonly storage?: StorageService;
readonly nativePromptContent?: boolean;
}): Promise<NativeRunnerAttachmentStage> {
const [run] = await input.db
.select({
@@ -962,6 +965,27 @@ export async function stageNativeRunnerWakeAttachments(input: {
)
.map((row) => [row.id, row] as const),
);
if (input.nativePromptContent) {
try {
const promptAttachments: NativeUserAttachment[] = [];
let total = 0;
if (selections.length > 8) throw new Error("Hermes supports at most eight prompt attachments");
for (const selection of selections) {
const row = rowById.get(selection.id);
if (!row) throw new Error("Hermes attachment is unavailable or no longer authorized for this task");
if (!["image/png", "image/jpeg", "image/webp", "image/gif", "text/plain", "text/markdown"].includes(row.contentType)) throw new Error(`Hermes does not support ${row.contentType} as prompt content; use an image or text document`);
total += row.byteSize;
if (row.byteSize > 2 * 1024 * 1024 || total > 4 * 1024 * 1024) throw new Error("Hermes prompt attachments are limited to 2 MiB per file and 4 MiB per turn");
const body = await readBoundedStorageObject({ storage: input.storage ?? getStorageService(), companyId: input.binding.companyId,
objectKey: row.objectKey, expectedByteSize: row.byteSize, expectedSha256: row.sha256 });
const base = { schema: "paperclip.user_attachment.v1" as const, name: row.filename?.trim() || "attachment" };
promptAttachments.push(row.contentType.startsWith("image/")
? { ...base, kind: "image", mediaType: row.contentType as "image/png", data: body.toString("base64") }
: { ...base, kind: "text", mediaType: row.contentType as "text/plain", text: new TextDecoder("utf-8", { fatal: true }).decode(body) });
}
return { attachments: [], promptAttachments, cleanup: async () => { releaseActiveStage(); } };
} catch (error) { releaseActiveStage(); throw error; }
}
if (input.binding.executionTargetKind === "remote") {
return {
attachments: selections.map((selection) => {
@@ -13,6 +13,7 @@ import { resolveAcpxQualification } from "./acpx-qualification.js";
import { readLocalAiCredentialFile } from "../local-ai-credential-file.js";
import { prepareGrokRunnerCredentials } from "./grok-runner-credentials.js";
import { copyBackGrokAuth } from "@paperclipai/adapter-grok-local/server";
import { writeFile } from "node:fs/promises";
import { inferOpenAiCompatibleBiller, type AdapterUsageCheckpoint } from "@paperclipai/adapter-utils";
import { createAgentIdentityRedactor } from "../agent-identity-redaction.js";
@@ -687,7 +688,7 @@ export function resolveNativeProviderEnvironment(
host: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
if (configured !== undefined) return configured;
if (provider.kind === "acpx" && ["pi", "cursor", "copilot"].includes(provider.agent)) {
if (provider.kind === "acpx" && ["pi", "cursor", "copilot", "hermes"].includes(provider.agent)) {
return buildNativeProviderEnvironment({}, host);
}
return host;
@@ -5430,7 +5431,10 @@ export function resolveNativeHarnessPersistenceProfile(
// are also re-materialized in the replacement sandbox. Grok
// diagnostics can contain auth fields and are not session state.
excludeEntries:
execution.provider.agent === "grok"
execution.provider.agent === "hermes"
? ["auth.json", "auth.json.corrupt", "auth.lock", "auth-refresh.json", "auth-refresh.json.tmp", "auth.json.lock", "config.yaml", ".env", "logs"].map((entry) =>
`acpx/${acpxRuntimeSessionDirectoryName(nativeSessionKey(execution))}/hermes-home/${entry}`)
: execution.provider.agent === "grok"
? ["auth.json", "auth-refresh.json", "auth-refresh.json.tmp", "config.toml", "logs"].map((entry) =>
`acpx/${acpxRuntimeSessionDirectoryName(nativeSessionKey(execution))}/grok-home/${entry}`)
: execution.provider.agent === "codex"
@@ -7473,6 +7477,7 @@ export async function executePaperclipNativeSession(input: {
cleanupStagedAttachments = () => chatAttachmentReadScope.close();
const attachmentStage = await stageNativeRunnerWakeAttachments({
db: input.db,
nativePromptContent: input.execution.provider.kind === "acpx" && input.execution.provider.agent === "hermes",
binding: {
companyId: input.execution.binding.companyId,
issueId: input.execution.binding.issueId,
@@ -7494,6 +7499,11 @@ export async function executePaperclipNativeSession(input: {
const stagedPrompt = renderNativeRunnerStagedAttachmentPrompt(
attachmentStage.attachments,
);
if (attachmentStage.promptAttachments?.length) {
preparedInput = { ...preparedInput, execution: parseNativeExecutionInput({
...input.execution, attachments: attachmentStage.promptAttachments,
}) };
}
if (stagedPrompt) {
preparedInput = {
...preparedInput,
@@ -7569,7 +7579,7 @@ async function executePaperclipNativeSessionWithinScope(
}
if (
input.execution.provider.kind === "acpx" &&
["pi", "copilot"].includes(input.execution.provider.agent) &&
["pi", "copilot", "hermes"].includes(input.execution.provider.agent) &&
!resolveAcpxQualification(input.execution.provider, process.env)
) {
throw new Error(
@@ -9556,6 +9566,7 @@ function nativeUsageMeasurement(usage: Record<string, unknown>) {
export function nativeUsageBiller(provider: NativeExecutionInput["provider"]): string {
if (provider.kind === "acpx") {
if (provider.agent === "hermes") return "unknown";
if (provider.agent === "cursor") return "cursor";
if (provider.agent === "copilot") return "github";
if (provider.agent === "pi") return "openrouter";
@@ -9638,7 +9649,7 @@ export function nativeUsageCostUsd(
// The ACP normalization contract fills absent per-turn cost with zero and
// reports actual cost cumulatively. Until it carries an authoritative run
// delta with provenance, neither value is a candidate's billed USD receipt.
if (provider?.kind === "acpx" && ["cursor", "copilot", "pi"].includes(provider.agent)) return undefined;
if (provider?.kind === "acpx" && ["cursor", "copilot", "pi", "hermes"].includes(provider.agent)) return undefined;
if (!usage) return undefined;
const measurement = nativeUsageMeasurement(usage);
const hasRunDelta = Object.hasOwn(usage, "runDelta") || Object.hasOwn(record(usage.usage), "runDelta");
@@ -9811,7 +9822,7 @@ type RemoteProviderPackManifest = {
version: string; profileDigest: string; closureDigest: string; qualification: "qualified" | "pending";
path: string; sha256: string;
}>>;
candidateProviders?: Partial<Record<"cursor" | "copilot" | "pi", {
candidateProviders?: Partial<Record<"cursor" | "copilot" | "pi" | "hermes", {
version: string; profileDigest: string; closureDigest: string; qualification: "qualified" | "pending";
path: string; sha256: string;
}>>;
@@ -9996,7 +10007,7 @@ function readRemoteProviderPackIdentity(packRoot: string, verifyControllerFiles:
}
for (const [provider, candidate] of Object.entries(candidates)) {
const expectedPath = `provider-assets/${provider}/${payload.target.platform}-${payload.target.architecture}`;
if (!(inventory === "providers" ? ["cursor"] : ["cursor", "copilot", "pi"]).includes(provider) || !candidate
if (!(inventory === "providers" ? ["cursor"] : ["cursor", "copilot", "pi", "hermes"]).includes(provider) || !candidate
|| Object.keys(candidate).some(key => !["version", "profileDigest", "closureDigest", "qualification", "path", "sha256"].includes(key))
|| candidate.qualification !== (provider === "cursor" ? "qualified" : "pending") || candidate.path !== expectedPath
|| typeof candidate.version !== "string" || !candidate.version || candidate.version.length > 120
@@ -12785,6 +12796,9 @@ async function createRunnerdBackendWithinSessionClaim(
}
: input.execution;
const isGrok = input.execution.provider.kind === "acpx" && input.execution.provider.agent === "grok";
const isHermes = input.execution.provider.kind === "acpx" && input.execution.provider.agent === "hermes";
if (isHermes && !input.managedAiCredentialHome) throw new Error("Select a managed AI connection before running Hermes");
if (isHermes && input.execution.session.lifecyclePolicy.mode !== "per_turn") throw new Error("Hermes requires the per-turn lifecycle until warm persistence is qualified");
let effectiveRunnerEnvironmentBase: NodeJS.ProcessEnv = {
...(isGrok
? input.runnerEnvironment ?? {}
@@ -12949,7 +12963,8 @@ async function createRunnerdBackendWithinSessionClaim(
acpxMode: input.execution.provider.mode,
acpxPermissionModePinned:
input.execution.schema === "paperclip.native-execution-input.v4" ||
input.execution.schema === "paperclip.native-execution-input.v5",
input.execution.schema === "paperclip.native-execution-input.v5" ||
input.execution.schema === "paperclip.native-execution-input.v6",
acpxRuntimeDirectory: remoteRunnerFilesystemRoot
? posix.join(remoteRunnerFilesystemRoot, "acpx")
: resolve(
@@ -13379,11 +13394,11 @@ async function createRunnerdBackendWithinSessionClaim(
});
const boundManagedSessions = new WeakSet<NativeSession>();
const wrapManagedSession = (session: NativeSession): NativeSession => {
if (isGrok && grokCredential?.home && !boundManagedSessions.has(session)) {
if (((isGrok && grokCredential?.home) || (isHermes && input.managedAiCredentialHome && effectiveRunnerEnvironmentBase.PAPERCLIP_HERMES_AUTH_JSON_SECRET)) && !boundManagedSessions.has(session)) {
boundManagedSessions.add(session);
// The launch runtime directory already ends in "acpx"; ACPX adds its
// own namespace beneath it in resolveAcpxRuntimeRoot.
const relativeHome = `acpx/acpx/${acpxRuntimeSessionDirectoryName(nativeSessionKey(input.execution))}/grok-home`;
const relativeHome = `acpx/acpx/${acpxRuntimeSessionDirectoryName(nativeSessionKey(input.execution))}/${isHermes ? "hermes" : "grok"}-home`;
const localHome = resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", relativeHome);
const remoteHome = remoteRunnerFilesystemRoot ? posix.join(remoteRunnerFilesystemRoot, relativeHome) : null;
const readAuth = async (name: string): Promise<Buffer> => {
@@ -13395,7 +13410,19 @@ async function createRunnerdBackendWithinSessionClaim(
};
return bindManagedNativeCredentialTurn(session, {
copyBack: async () => {
await copyBackGrokAuth({ hostHomeDir: grokCredential.home!, log: () => {},
if (isHermes) {
const bytes = await readAuth("auth-refresh.json");
try {
const value = JSON.parse(bytes.toString("utf8"));
if (value?.version !== 1 || !value.providers) throw new Error("Hermes refresh handoff is invalid");
// This controller-created home is private to the run. The existing
// Connections cleanup translates and applies its locked freshness merge.
await writeFile(join(input.managedAiCredentialHome!, "hermes-auth.json"), bytes, { mode: 0o600 });
} finally { bytes.fill(0); }
return;
}
if (!grokCredential?.home) throw new Error("Managed Grok credential home is unavailable");
await copyBackGrokAuth({ hostHomeDir: grokCredential.home, log: () => {},
readSandboxAuth: () => readAuth("auth.json").catch((error) => {
if (error.code !== "ENOENT") throw error;
return readAuth("auth-refresh.json");
+2
View File
@@ -35,9 +35,11 @@ export type {
NativeExecutionInputV4,
NativeExecutionInputV5,
NativeExecutionInputV6,
NativeExecutionInputV7,
DotBindingSnapshot,
ExternalProviderPort,
ExternalProviderOperation,
NativeUserAttachment,
NativeCompletionSource,
NativeCompletionSources,
NativeInteractionResponseEnvelope,