mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
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:
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"],
|
||||
|
||||
@@ -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[
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
Reference in new issue
Block a user