Finish Copilot production setup on current master

Preserve the reviewed candidate source and mainline provider behavior.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-09 10:54:11 -05:00
1 parent cc1670aa56
commit aaa65bbfbf
74 files changed
+57873 -251

No files matched your search

@@ -10,6 +10,7 @@ import {
// Only keys read by the child's provider express a child auth override.
// A config copied from another provider can retain unrelated keys.
const PROVIDER_AUTH_ENV_KEYS: Record<AiProvider, readonly string[]> = {
github: ["COPILOT_GITHUB_TOKEN"],
google: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GEMINI_BASE_URL"],
anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CONFIG_DIR", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"],
openai: ["OPENAI_API_KEY", "CODEX_API_KEY", "CODEX_HOME", "OPENAI_BASE_URL"],
+1 -1
View File
@@ -46,7 +46,7 @@ export function isAiAuthenticationRepairable(run: AuthenticationFailure): boolea
/** Provider authentication signals only. Tool authorization and quotas need different repairs. */
export function isAiAuthenticationFailure(code: string | null | undefined): boolean {
return Boolean(code && (
/^(acpx|claude|codex|grok|opencode|gemini|kimi|pi|cursor)_auth_required$/.test(code)
/^(acpx|claude|codex|grok|opencode|gemini|kimi|pi|cursor|copilot)_auth_required$/i.test(code)
|| ["adapter_auth_missing", "authentication_required", "auth_required", "refresh_token_reused", "refresh_token_expired", "refresh_token_invalidated"].includes(code)
));
}
@@ -26,6 +26,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 = [
"COPILOT_GITHUB_TOKEN",
"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",
@@ -309,6 +310,7 @@ export async function prepareManagedAiRuntime(
...stripAiAuthBindings(input.config.env),
...Object.fromEntries(AI_AUTH_ENV_KEYS.map((key) => [key, ""])),
...managedAiHomeEnvironment(home),
...(input.binding.provider === "github" ? { GH_TOKEN: "", GITHUB_TOKEN: "" } : {}),
};
const capability =
AI_CONNECTION_CAPABILITIES[input.binding.provider].methods[
+1 -1
View File
@@ -332,7 +332,7 @@ export async function probeAiConnectionUsage(
options: { request?: typeof fetch } = {},
): Promise<Omit<AiConnectionUsage, "connectionId" | "grantId">> {
const base = { ...metadata, checkedAt: new Date().toISOString(), source: null, planType: null, limits: [], overage: null };
if (metadata.provider === "google" || !supportsAiConnectionUsage(metadata.provider, metadata.method)) {
if (metadata.provider === "google" || metadata.provider === "github" || !supportsAiConnectionUsage(metadata.provider, metadata.method)) {
return { ...base, status: "unsupported", errorCode: "unsupported", message: messages.unsupported };
}
try {
@@ -0,0 +1,89 @@
import { randomUUID } from "node:crypto";
import type { Db } from "@paperclipai/db";
import { probeCopilotMetadata, validateCopilotMetadata } from "../vendor/paperclip-runner/live/index.js";
import { runAdapterExecutionTargetShellCommand, type AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { environmentService } from "./environments.js";
import { environmentRuntimeService } from "./environment-runtime.js";
import { instanceSettingsService } from "./instance-settings.js";
import { resolveEnvironmentExecutionTarget } from "./environment-execution-target.js";
import { assertEnvironmentSelectionForCompany } from "../routes/environment-selection.js";
import { buildLoginLeaseAcquireArgs } from "./adapter-login-lease.js";
import { logger } from "../middleware/logger.js";
import { forbidden, unprocessable } from "../errors.js";
export async function probeCopilotConnection(db: Db, companyId: string, token: string, suppliedEnvironmentId?: string | null, model?: string, runtimeOptions: Parameters<typeof environmentRuntimeService>[1] = {}) {
const environments = environmentService(db), settings = instanceSettingsService(db);
const experimental = await settings.getExperimental();
let environmentId = suppliedEnvironmentId ?? (await settings.get()).defaultEnvironmentId;
if ((await settings.getGeneral()).executionMode === "kubernetes") {
const kubernetes = await environments.findKubernetesEnvironment(companyId);
if (!kubernetes) throw unprocessable("The Kubernetes execution environment is unavailable.", { code: "copilot_environment_unavailable" });
environmentId = kubernetes.id;
} else if (experimental.enableManagedSandboxOnly && (!environmentId || (await environments.getById(environmentId))?.driver === "local")) {
const managed = await environments.findManagedSandboxEnvironment(companyId);
if (!managed) throw unprocessable("The managed environment is unavailable.", { code: "copilot_environment_unavailable" });
environmentId = managed.id;
}
if (!environmentId) return requireVerified(await probeCopilotMetadata(token, model));
await assertEnvironmentSelectionForCompany(environments, companyId, environmentId, { allowedDrivers: ["local", "ssh", "sandbox", "plugin"] });
const boundCompanyIds = await environments.listBoundCompanyIds(environmentId);
if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(companyId)) throw forbidden("The selected environment belongs to another company.", { code: "environment_company_mismatch" });
const environment = await environments.getById(environmentId);
if (!environment || environment.status !== "active") throw unprocessable("Choose an active Copilot execution environment.", { code: "copilot_environment_unavailable" });
if (environment.driver === "local") return requireVerified(await probeCopilotMetadata(token, model));
const runtime = environmentRuntimeService(db, runtimeOptions);
let lease: Awaited<ReturnType<typeof runtime.acquireRunLease>> | undefined;
let succeeded = false;
let probeFailed = false;
try {
if (environment.driver !== "ssh") {
lease = await runtime.acquireRunLease(buildLoginLeaseAcquireArgs({ metadata: { companyId, environment, adapterType: "paperclip_runner" }, assertCompanyBinding: true, requestedExpiresAt: new Date(Date.now() + 90_000) }));
await runtime.realizeWorkspace({ environment, lease: lease.lease, workspace: {} });
}
const target = await resolveEnvironmentExecutionTarget({ db, companyId, adapterType: "paperclip_runner", environment,
leaseId: lease?.lease.id, lease: lease?.lease, leaseMetadata: lease?.lease.metadata ?? null, environmentRuntime: runtime });
if (!target || target.kind !== "remote") throw unprocessable("The selected environment cannot run the Copilot metadata probe.", { code: "copilot_environment_unavailable" });
const verified = await probeCopilotExecutionTarget(token, target, model);
succeeded = true;
return verified;
} catch (error) {
probeFailed = true;
throw error;
} finally {
if (lease) {
try {
const driver = runtime.getDriver(environment.driver);
if (!driver) throw new Error("Copilot verification lease cleanup driver is unavailable");
await driver.releaseRunLease({ environment, lease: lease.lease, status: succeeded ? "released" : "failed" });
} catch {
// Report only owned identifiers; driver errors may contain credentials.
logger.warn({ companyId, environmentId: environment.id, leaseId: lease.lease.id, code: "COPILOT_PROBE_CLEANUP_FAILED" }, "Copilot verification lease cleanup failed");
if (!probeFailed) throw unprocessable("Copilot verification could not release its execution environment.", { code: "COPILOT_REQUEST_FAILED", cleanupCode: "COPILOT_PROBE_CLEANUP_FAILED" });
}
}
}
}
function requireVerified(value: unknown) {
const result = validateCopilotMetadata(value);
if (result.status !== "verified") throw unprocessable(result.message, { code: result.code });
return result;
}
export async function probeCopilotExecutionTarget(token: string, target: AdapterExecutionTarget | null | undefined, model?: string) {
if (!target || target.kind === "local") return requireVerified(await probeCopilotMetadata(token, model));
// Same standard installed locations used by native runner admission. The
// runtime verifier binds the inner executable and distribution before spawn.
const command = 'for pack in /opt/paperclip-runner/provider-pack "$HOME/.local/share/paperclip-runner/provider-pack"; do if [ -f "$pack/provider-pack.json" ] && [ -f "$pack/dist/cli/copilot-metadata-probe.js" ]; then export PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT="$pack" PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST="$pack/package.json"; exec "$pack/node_modules/node/bin/node" "$pack/dist/cli/copilot-metadata-probe.js"; fi; done; exit 127';
// The exact model is an environment value and never enters shell source.
const response = await runAdapterExecutionTargetShellCommand(`copilot-metadata-${randomUUID()}`, target, command, {
cwd: target.remoteCwd, env: { COPILOT_GITHUB_TOKEN: token, ...(model ? { PAPERCLIP_COPILOT_PROBE_MODEL: model } : {}) }, timeoutSec: 35, graceSec: 3,
});
if (response.timedOut || response.stdout.length > 64 * 1024) throw unprocessable("Copilot metadata verification timed out.", { code: "COPILOT_REQUEST_FAILED" });
let result: unknown;
try { result = JSON.parse(response.stdout.trim()); } catch { throw unprocessable("Install the verified Copilot runtime in this environment.", { code: "COPILOT_INSTALLATION_INVALID" }); }
const verified = requireVerified(result);
if (response.exitCode !== 0 || response.signal) throw unprocessable("Copilot metadata verification did not exit cleanly.", { code: "COPILOT_REQUEST_FAILED" });
if (model && !verified.models.some(value => value.id === model)) throw unprocessable("The selected Copilot model is unavailable for this account.", { code: "COPILOT_MODEL_UNAVAILABLE" });
return verified;
}
@@ -30,6 +30,23 @@ describe("Paperclip Runner native provider configuration", () => {
expect(resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server",
adapterConfig: { provider: "codex", model }, codexCliVersion })).toMatchObject({ model });
});
it.each([undefined, "0.156.0", "0.159.0"])(
"keeps the exact Copilot model independent of Codex CLI version %s",
(codexCliVersion) => {
const adapterConfig = {
provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna",
acpxPermissionMode: "approve-paperclip",
};
expect(resolvePaperclipRunnerNativeProviderInput({
backend: "acpx_runtime", adapterConfig, codexCliVersion,
})).toEqual({
provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna",
acpxPermissionMode: "approve-paperclip",
});
expect(adapterConfig.model).toBe("gpt-5.6-luna");
},
);
it.each([undefined, "approve-all", "approve-paperclip", "approve-reads", "deny-all"])(
"passes Grok's full-auto default or explicit %s policy to the native runner",
(acpxPermissionMode) => {
+6 -6
View File
@@ -309,6 +309,7 @@ import {
isAiConnectionBusy,
AI_AUTH_ENV_KEYS,
} from "./ai-connection-runtime.js";
import { supportsManagedNativeWarmSession, supportsNativeWarmFiles } from "./native-runtime/native-warm-capability.js";
import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema, type AiConnectionRouterSelection } from "@paperclipai/shared";
import { aiConnectionRouterService, AiConnectionPoolExhausted, applyAiConnectionRouterTaskSettings } from "./ai-connection-router.js";
import { aiConnectionSessionCompatibilityInputs, managedAiSessionIdentityCompatible } from "./ai-connection-session.js";
@@ -9953,8 +9954,7 @@ export function heartbeatService(
const savedFileInput = parseObject(parseObject(run.runnerProfileJson).nativeExecutionInput);
const priorFileInput = Object.keys(savedFileInput).length ? savedFileInput : parseObject(parseObject(priorFileRun?.profile).nativeExecutionInput);
const priorWorkingCopy = parseObject(parseObject(parseObject(priorFileInput.runtimeContext).instructions).workingCopy);
const warmFiles = nativeRuntimeResolution.kind === "native" && (nativeRuntimeResolution.profile.backend === "codex_app_server" ||
(nativeRuntimeResolution.profile.backend === "acpx_runtime" && parseObject(agent.adapterConfig).acpxAgent === "cursor")) &&
const warmFiles = nativeRuntimeResolution.kind === "native" && (supportsNativeWarmFiles({ ...nativeRuntimeResolution.profile, acpxAgent: parseObject(agent.adapterConfig).acpxAgent }) || (nativeRuntimeResolution.profile.backend === "acpx_runtime" && parseObject(agent.adapterConfig).acpxAgent === "cursor")) &&
(executionTarget?.kind === "remote" && executionTarget.transport === "sandbox"
? executionTarget.runnerLifecyclePolicy?.mode === "warm"
: parseObject(agent.adapterConfig).lifecycleMode === "warm");
@@ -10274,11 +10274,11 @@ export function heartbeatService(
executionTarget.transport === "sandbox"
? (executionTarget.runnerLifecyclePolicy ?? null)
: null;
// Native Codex owns a durable, session-scoped home. It flushes refreshed
// auth into each invocation's private home before that home is removed.
// Other managed harnesses still require per-turn credential cleanup.
// Codex refreshes its durable auth home; Copilot owns a fenced home
// and an explicitly bound token. Both retire on credential generation
// changes. Other managed harnesses require per-turn cleanup.
const supportsManagedWarmSession = agent.adapterType === "paperclip_runner" &&
nativeRuntimeResolution.profile.backend === "codex_app_server";
supportsManagedNativeWarmSession({ ...nativeRuntimeResolution.profile, acpxAgent: parseObject(agent.adapterConfig).acpxAgent }, managedAiRuntime?.attribution);
const effectiveLifecyclePolicy = persistedNativeExecutionInput?.session.lifecyclePolicy ??
(nativeRuntimeResolution.profile.backend === "openai_dot_mcp" || managedAiRuntime && !supportsManagedWarmSession
? { mode: "per_turn" as const, idleTimeoutMs: null }
@@ -4,21 +4,11 @@ import { ACPX_QUALIFICATION_ENV, resolveAcpxQualification } from "./acpx-qualifi
import type { NativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
import { resolvePaperclipRunnerProviderProfile, resolvePaperclipRunnerNativeProviderInput } from "./provider-profile.js";
const provider = { kind: "acpx", agent: "copilot", model: "exact-model", permissionMode: "approve-all" } as NativeExecutionInput["provider"];
const provider = { kind: "acpx", agent: "copilot", model: "exact-model", permissionMode: "approve-all" } as Extract<NativeExecutionInput["provider"], { kind: "acpx" }>;
const authorize = (value: unknown) => ({ [ACPX_QUALIFICATION_ENV]: JSON.stringify(value) });
describe("host ACPX qualification admission", () => {
afterEach(() => vi.unstubAllEnvs());
it.each(["copilot"])("admits %s through agent validation and native input only for the exact host pair", (agent) => {
const config = { provider: "acpx", acpxAgent: agent, model: "exact-model" };
vi.stubEnv(ACPX_QUALIFICATION_ENV, undefined);
expect(() => resolvePaperclipRunnerProviderProfile(config)).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_agent_unavailable" }));
expect(() => resolvePaperclipRunnerProviderProfile({ ...config, env: authorize([{ agent, model: config.model }]), [ACPX_QUALIFICATION_ENV]: JSON.stringify([{ agent, model: config.model }]) })).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_agent_unavailable" }));
vi.stubEnv(ACPX_QUALIFICATION_ENV, JSON.stringify([{ agent, model: config.model }]));
expect(resolvePaperclipRunnerProviderProfile(config)).toMatchObject({ acpxAgent: agent, model: config.model });
expect(resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: config })).toMatchObject({ acpxAgent: agent, model: config.model });
expect(() => resolvePaperclipRunnerProviderProfile({ ...config, model: "other-model" })).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_qualification_invalid" }));
expect(() => resolvePaperclipRunnerProviderProfile({ ...config, model: "" })).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_model_required" }));
});
it("admits only the exact Pi model without host authorization and preserves permission modes", () => {
const config = { provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" };
for (const authorization of [undefined, "malformed unrelated candidate authorization"]) {
@@ -34,18 +24,12 @@ describe("host ACPX qualification admission", () => {
}
}
});
it("keeps normal candidate execution closed and admits only the exact operator pair", () => {
expect(resolveAcpxQualification(provider, {})).toBeUndefined();
expect(resolveAcpxQualification(provider, authorize([{ agent: "copilot", model: "exact-model" }]))).toBe("copilot");
for (const entries of [[{ agent: "pi", model: "exact-model" }], [{ agent: "copilot", model: "other-model" }]]) {
expect(() => resolveAcpxQualification(provider, authorize(entries))).toThrow("exact model");
}
});
it.each([[], {}, [{ agent: "cursor", model: "" }], [{ agent: "cursor", model: " exact-model" }],
[{ agent: "cursor", model: "exact-model", allowAll: true }], [{ agent: "claude", model: "exact-model" }],
[{ agent: "cursor", model: "exact-model" }, { agent: "cursor", model: "exact-model" }],
])("rejects malformed or broad authorization %j", entries => {
expect(() => resolveAcpxQualification(provider, authorize(entries))).toThrow("Invalid ACPX");
expect(() => resolveAcpxQualification({ ...provider, agent: "pi" }, authorize(entries))).toThrow("Invalid ACPX");
});
it("admits Cursor through ordinary configuration and ignores unrelated qualification authority", () => {
const config = { provider: "acpx", acpxAgent: "cursor", model: "exact-cursor-model" };
@@ -55,6 +39,17 @@ describe("host ACPX qualification admission", () => {
expect(resolveAcpxQualification({ ...provider, agent: "cursor" } as typeof provider, authorize([]))).toBeUndefined();
expect(() => resolvePaperclipRunnerProviderProfile({ ...config, model: "" })).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_model_required" }));
});
it.each([undefined, "malformed", JSON.stringify([{agent:"pi",model:"pi-model"}])])("admits ordinary Copilot without host overrides (%s)", host => {
vi.stubEnv(ACPX_QUALIFICATION_ENV,host);
const config={provider:"acpx",acpxAgent:"copilot",model:"gpt-5.6-luna"};
expect(resolvePaperclipRunnerProviderProfile(config)).toMatchObject({acpxAgent:"copilot",model:config.model});
expect(resolvePaperclipRunnerNativeProviderInput({backend:"acpx_runtime",adapterConfig:config})).toMatchObject({acpxAgent:"copilot",model:config.model});
expect(resolveAcpxQualification({...provider,model:config.model},{})).toBe("copilot");
});
it.each(["", " ", "auto", "default"])("refuses implicit Copilot model %s",model=>{
expect(()=>resolvePaperclipRunnerProviderProfile({provider:"acpx",acpxAgent:"copilot",model})).toThrow(expect.objectContaining({code:"paperclip_runner_acpx_model_required"}));
expect(()=>resolveAcpxQualification({...provider,model},{})).toThrow("explicit available model");
});
it("does not alter existing qualified providers", () => {
expect(resolveAcpxQualification({ ...provider, agent: "codex" } as typeof provider, authorize([]))).toBeUndefined();
});
@@ -1,3 +1,4 @@
import { PAPERCLIP_RUNNER_ACPX_PROFILES } from "@paperclipai/adapter-utils";
import type { NativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
/** Operator-only admission for an exact candidate/model during qualification. */
@@ -9,6 +10,15 @@ export function resolveAcpxQualification(
hostEnvironment: NodeJS.ProcessEnv,
): AcpxQualificationCandidate | undefined {
if (provider.kind !== "acpx" || !["copilot", "pi"].includes(provider.agent)) return undefined;
// The server release declaration admits qualified Copilot into the frozen
// SDK's explicit native transport slot. No operator environment is required.
// Pi retains the mainline transport and qualification behavior below.
if (provider.agent === "copilot" && PAPERCLIP_RUNNER_ACPX_PROFILES.some(profile => profile.value === "copilot" && profile.qualified)) {
if (!provider.model || provider.model !== provider.model.trim() || ["auto", "default"].includes(provider.model.toLowerCase())) {
throw new Error("GitHub Copilot requires an explicit available model ID");
}
return "copilot";
}
const encoded = hostEnvironment[ACPX_QUALIFICATION_ENV];
if (!encoded) return undefined;
let entries: unknown;
@@ -245,7 +245,13 @@ async function openedFilePath(fd: number): Promise<string> {
execFile(
"/usr/sbin/lsof",
["-a", "-p", String(process.pid), "-d", String(fd), "-F0n"],
{ encoding: "buffer", maxBuffer: 16_384, timeout: 1_000 },
{
encoding: "buffer",
maxBuffer: 16_384,
timeout: 1_000,
// lsof escapes non-ASCII names under the C locale, breaking descriptor verification.
env: { ...process.env, LC_ALL: "en_US.UTF-8" },
},
(error, stdout) => {
if (error) reject(error);
else resolve(Buffer.from(stdout));
@@ -9409,6 +9409,16 @@ describe("native process ownership", () => {
},
"acpx_runtime",
],
[
"GitHub Copilot ACPX",
{
kind: "acpx",
agent: "copilot",
model: "gpt-5.6-luna",
permissionMode: "approve-all",
},
"acpx_runtime",
],
[
"Codex ACPX",
{
@@ -9462,24 +9472,7 @@ describe("native process ownership", () => {
},
);
it.each(["copilot"])("rejects ACPX candidate %s without host authorization before constructing a backend", async (agent) => {
const piExecution = {
...execution,
binding: { ...execution.binding, runId: "run-acpx-pi-rejected" },
provider: { kind: "acpx", agent, model: "pi-model" },
session: { ...execution.session, driverKind: "acpx_runtime" },
} as unknown as NativeExecutionInputV1;
state.createBackend.mockClear();
await expect(
executePaperclipNativeSession({
db: leaseDb(piExecution),
execution: piExecution,
runnerInstanceId: "runner",
}),
).rejects.toThrow("exact host qualification authorization");
expect(state.createBackend).not.toHaveBeenCalled();
});
});
describe("runnerd provider runtime wiring", () => {
@@ -37,7 +37,7 @@ import {
import { nativeCompletionFeedback } from "./native-completion-feedback.js";
import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js";
import { stoppedCodexTurnIsTextOnly } from "./stopped-codex-turn.js";
import { prepareVerifiedRemoteProviderPack } from "./remote-provider-pack.js";
import { prepareVerifiedRemoteProviderPack, shouldUseBundledAcpxImageAssets } from "./remote-provider-pack.js";
import { selectRemotePiCompanion } from "./remote-pi-companion.js";
import { readNativeLocalProcessStop, PROCESS_START_REQUESTED } from "../native-local-process-stop.js";
import { remoteLeaseCleanupScope } from "../remote-execution-termination.js";
@@ -9907,7 +9907,7 @@ type RemoteProviderPackManifest = {
distDigest: string;
bridgeDigest: string;
acpxProfileDigests: typeof REMOTE_PROVIDER_PACK_PROFILE_DIGESTS;
providers?: Partial<Record<"pi" | "cursor", {
providers?: Partial<Record<"pi" | "cursor" | "copilot", {
version: string; profileDigest: string; closureDigest: string; qualification: "qualified" | "pending";
path: string; sha256: string;
}>>;
@@ -10096,10 +10096,9 @@ 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" ? ["pi", "cursor"] : ["cursor", "copilot", "pi"]).includes(provider) || !candidate
if (!(inventory === "providers" ? ["pi", "cursor", "copilot"] : ["cursor", "copilot", "pi"]).includes(provider) || !candidate
|| Object.keys(candidate).some(key => !["version", "profileDigest", "closureDigest", "qualification", "path", "sha256"].includes(key))
|| candidate.qualification !== (["pi", "cursor"].includes(provider) ? "qualified" : "pending")
|| candidate.path !== expectedPath
|| candidate.qualification !== (["pi", "cursor"].includes(provider) || inventory === "providers" && provider === "copilot" ? "qualified" : "pending") || candidate.path !== expectedPath
|| typeof candidate.version !== "string" || !candidate.version || candidate.version.length > 120
|| !/^sha256:[a-f0-9]{64}$/.test(candidate.profileDigest)
|| !/^sha256:[a-f0-9]{64}$/.test(candidate.closureDigest)
@@ -10117,6 +10116,21 @@ function readRemoteProviderPackIdentity(packRoot: string, verifyControllerFiles:
throw new Error("runner_remote_provider_artifact_incompatible: Cursor profile or closure does not match this release");
}
}
if (provider === "copilot") {
const closurePins = {
"darwin-arm64": "362f2663e967fb9e34a814bac4619cbf89e6b23069c4051ab1f2f686852d0a32",
"darwin-x64": "c08b7c3dd4e7bcf9a3e16ec6eba29cfa10308e865d196c5f120a3a15f6b3116a",
"linux-x64": "a3d8f4367cfa1694d79e3f8b7930b6fbfe42a229c272b375b11951d631db8d07"
} as const;
const target = `${payload.target.platform}-${payload.target.architecture}`;
const closure = Object.hasOwn(closurePins, target) ? closurePins[target as keyof typeof closurePins] : undefined;
if (!closure || (payload.providers?.copilot && payload.candidateProviders?.copilot)
|| candidate.version !== QUALIFIED_ACPX_PROFILES.copilot.agentServerVersion
|| candidate.profileDigest !== QUALIFIED_ACPX_PROFILES.copilot.commandDigest
|| candidate.closureDigest !== `sha256:${closure}`) {
throw new Error("runner_remote_provider_artifact_incompatible: Copilot profile or closure does not match this release");
}
}
if (verifyControllerFiles && sha256DirectoryTree(resolve(packRoot, candidatePath)) !== candidate.sha256) {
throw new Error("runner_remote_provider_artifact_incompatible: candidate asset tree digest mismatch");
}
@@ -11377,9 +11391,11 @@ async function createRunnerdBackendWithinSessionClaim(
const configuredProviderPackRoot =
input.runnerRemoteProviderPackPath?.trim() || remotePiCompanion?.providerPack || null;
let expectedProviderPackManifest: RemoteProviderPackManifest | null = null;
const useBundledCursorImageAssets = requiresRemoteProviderPack && !configuredProviderPackRoot &&
input.execution.provider.kind === "acpx" && input.execution.provider.agent === "cursor";
if (useBundledCursorImageAssets) {
const useBundledAcpxImageAssets = shouldUseBundledAcpxImageAssets({
requiresRemoteProviderPack, configuredProviderPackRoot,
provider: input.execution.provider,
});
if (useBundledAcpxImageAssets) {
expectedProviderPackManifest = readBundledRemoteProviderPackManifest();
} else if (requiresRemoteProviderPack) {
if (
@@ -11408,7 +11424,7 @@ async function createRunnerdBackendWithinSessionClaim(
// When an explicit remote artifact is configured, prepareRemoteRunner stages
// these exact bytes at remoteBinary before launch.
const controllerRunnerBinary = remoteTarget
? input.runnerRemoteBinaryPath?.trim() || remotePiCompanion?.runnerBinary || (useBundledCursorImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary())
? input.runnerRemoteBinaryPath?.trim() || remotePiCompanion?.runnerBinary || (useBundledAcpxImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary())
: resolvePaperclipRunnerBinary();
const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null;
const remoteCodexNpmSpec = input.runnerRemoteCodexNpmSpec?.trim() || null;
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { supportsManagedNativeWarmSession, supportsNativeWarmFiles } from "./native-warm-capability.js";
import type { PaperclipRunnerProviderProfile } from "./provider-profile.js";
describe("native warm credential and file admission", () => {
const copilot: PaperclipRunnerProviderProfile = {
provider: "acpx", backend: "acpx_runtime", acpxAgent: "copilot", model: "gpt-5.6-luna",
};
it("admits the saved Copilot token with its session-owned home and file handoff", () => {
expect(supportsNativeWarmFiles(copilot)).toBe(true);
expect(supportsManagedNativeWarmSession(copilot, { provider: "github", method: "api_key" })).toBe(true);
});
it.each([undefined, { provider: "github", method: "subscription" }, { provider: "openai", method: "api_key" }])(
"refuses a missing or mismatched Copilot credential: %j", (attribution) => {
expect(supportsManagedNativeWarmSession(copilot, attribution)).toBe(false);
},
);
it.each(["cursor", "pi", "claude", "grok"] as const)("preserves %s managed per-turn admission", (acpxAgent) => {
const profile = { ...copilot, acpxAgent };
expect(supportsNativeWarmFiles(profile)).toBe(false);
expect(supportsManagedNativeWarmSession(profile, { provider: "github", method: "api_key" })).toBe(false);
});
it("preserves Codex warm support", () => {
const profile: PaperclipRunnerProviderProfile = { provider: "codex", backend: "codex_app_server", model: "gpt-5.6-sol" };
expect(supportsNativeWarmFiles(profile)).toBe(true);
expect(supportsManagedNativeWarmSession(profile, undefined)).toBe(true);
});
});
@@ -0,0 +1,20 @@
import type { PaperclipRunnerProviderProfile } from "./provider-profile.js";
type WarmProviderSelection = { backend: PaperclipRunnerProviderProfile["backend"]; acpxAgent?: unknown };
/** Providers with a session-owned home and the current instruction-grant handoff. */
export function supportsNativeWarmFiles(profile: WarmProviderSelection): boolean {
return profile.backend === "codex_app_server" ||
(profile.backend === "acpx_runtime" && profile.acpxAgent === "copilot");
}
/** Copilot carries a token in its fenced session environment, not a temporary auth file.
* Credential generation changes still retire the exact warm owner before attachment. */
export function supportsManagedNativeWarmSession(
profile: WarmProviderSelection,
attribution: { provider: string; method: string } | undefined,
): boolean {
return profile.backend === "codex_app_server" ||
(profile.backend === "acpx_runtime" && profile.acpxAgent === "copilot" &&
attribution?.provider === "github" && attribution.method === "api_key");
}
@@ -30,6 +30,7 @@ export const DEFAULT_ACPX_RUNNER_MODELS = {
// These profiles require explicit configuration. Admission belongs to the runner.
codex: null,
cursor: null,
copilot: null,
pi: null,
} as const;
@@ -476,6 +477,9 @@ export function resolvePaperclipRunnerProviderProfile(
}
const acpxAgent = config.acpxAgent ?? "claude";
if (acpxAgent === "copilot" && (!model || ["auto", "default"].includes(model.toLowerCase()))) {
throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_model_required", "GitHub Copilot requires an explicit model ID; there is no default model.");
}
const pendingAcpxProfile = PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === acpxAgent && !profile.qualified);
if (pendingAcpxProfile) {
if (!model) throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_model_required", `${pendingAcpxProfile.label} requires an explicit model ID; there is no default model.`);
@@ -495,7 +499,7 @@ export function resolvePaperclipRunnerProviderProfile(
if ((acpxAgent === "cursor" || acpxAgent === "pi") && !model) {
throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_model_required", "This ACPX agent requires an explicit model ID; there is no default model.");
}
if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok" && acpxAgent !== "cursor" && acpxAgent !== "pi") {
if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok" && acpxAgent !== "cursor" && acpxAgent !== "copilot" && acpxAgent !== "pi") {
throw new PaperclipRunnerProviderProfileError(
"paperclip_runner_acpx_agent_unavailable",
"Paperclip Runner ACPX requires a qualified agent profile.",
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { prepareVerifiedRemoteProviderPack } from "./remote-provider-pack.js";
import { prepareVerifiedRemoteProviderPack, shouldUseBundledAcpxImageAssets } from "./remote-provider-pack.js";
describe("remote provider pack reuse", () => {
it("keeps a verified staged pack without consulting the image or uploading again", async () => {
@@ -37,3 +37,19 @@ describe("remote provider pack reuse", () => {
await expect(prepareVerifiedRemoteProviderPack(input)).resolves.toBe("uploaded");
});
});
describe("ordinary ACPX image artifacts", () => {
const provider = (agent: string) => ({ kind: "acpx", agent, model: "selected-model" } as Parameters<typeof shouldUseBundledAcpxImageAssets>[0]["provider"]);
it.each(["cursor", "copilot"])("selects bundled artifacts for remote %s without an operator override", agent => {
expect(shouldUseBundledAcpxImageAssets({ requiresRemoteProviderPack: true, configuredProviderPackRoot: null, provider: provider(agent) })).toBe(true);
expect(shouldUseBundledAcpxImageAssets({ requiresRemoteProviderPack: false, configuredProviderPackRoot: null, provider: provider(agent) })).toBe(false);
expect(shouldUseBundledAcpxImageAssets({ requiresRemoteProviderPack: true, configuredProviderPackRoot: "/explicit/pack", provider: provider(agent) })).toBe(false);
});
it.each(["pi", "claude", "codex", "grok"])("does not change the artifact authority for %s", agent => {
expect(shouldUseBundledAcpxImageAssets({ requiresRemoteProviderPack: true, configuredProviderPackRoot: null, provider: provider(agent) })).toBe(false);
});
it("does not admit a non-ACPX provider through bundled image selection", () => {
expect(shouldUseBundledAcpxImageAssets({ requiresRemoteProviderPack: true, configuredProviderPackRoot: null, provider: { kind: "codex", model: null } })).toBe(false);
});
});
@@ -1,3 +1,16 @@
import type { NativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
/** Qualified ACPX releases carry their Linux image manifest and daemon. */
export function shouldUseBundledAcpxImageAssets(input: {
requiresRemoteProviderPack: boolean;
configuredProviderPackRoot: string | null;
provider: NativeExecutionInput["provider"];
}): boolean {
return input.requiresRemoteProviderPack && !input.configuredProviderPackRoot &&
input.provider.kind === "acpx" &&
["cursor", "copilot"].includes(input.provider.agent);
}
/** Reuse only a pack that passes the same full verification as a new upload. */
export async function prepareVerifiedRemoteProviderPack(input: {
verifyStaged: () => Promise<void>;
+4 -2
View File
@@ -1039,7 +1039,8 @@ function connectionMethodFor(app: AppDefinition, methodKey?: string | null) {
const availableMethods = new Set(getAvailableConnectionMethods(app));
const toolMethods = app.methods.filter(
(candidate) =>
candidate.purpose !== "channel" && candidate.transport !== "chat_sdk"
candidate.purpose !== "channel" && candidate.purpose !== "ai"
&& candidate.transport !== "chat_sdk" && candidate.transport !== "runtime_auth"
&& (availableMethods.has(candidate) || isPaperclipCloudConnectorStrategy(candidate.oauthStrategy)),
);
const method = normalizedMethodKey
@@ -12455,7 +12456,8 @@ export function toolAccessService(
galleryEntry &&
getAvailableConnectionMethods(galleryEntry).filter(
(candidate) =>
candidate.purpose !== "channel" && candidate.transport !== "chat_sdk",
candidate.purpose !== "channel" && candidate.purpose !== "ai"
&& candidate.transport !== "chat_sdk" && candidate.transport !== "runtime_auth",
).length > 1 &&
!inferredMethodKey
) {