mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
feat(runner): add native Hermes ACP qualification candidate
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
75abb6a774
commit
9e07748e91
97 files changed
+2866
-197
No files matched your search
@@ -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 };
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -35,9 +35,11 @@ export type {
|
||||
NativeExecutionInputV4,
|
||||
NativeExecutionInputV5,
|
||||
NativeExecutionInputV6,
|
||||
NativeExecutionInputV7,
|
||||
DotBindingSnapshot,
|
||||
ExternalProviderPort,
|
||||
ExternalProviderOperation,
|
||||
NativeUserAttachment,
|
||||
NativeCompletionSource,
|
||||
NativeCompletionSources,
|
||||
NativeInteractionResponseEnvelope,
|
||||
|
||||
Reference in new issue
Block a user